๐ฎ๐นSrdnlenCTF 2026
Writeups for all but two challenges
crypto
FHAES
Description
Service provides garbled-circuit evaluation of AES-based operations with a fixed per-connection secret key. Available circuits include encrypt, decrypt, add, multiply, and custom_circuit (wrapped as Enc_k(custom(Dec_k(ct)))). Goal: recover the 16-byte AES key and submit it.
Solution
The break is on garbling metadata, not AES cryptanalysis.
Build a
custom_circuitthat adds exactly one attacker-controlled AND gate:a = x0 XOR x0b = NOT(a)y0 = a AND by1..y127 = xi XOR xi
In this construction, the custom AND gate leaks the global Free-XOR offset:
delta = gate0 XOR gate1for that custom AND table entry.
Key-schedule section of the AES circuit is fully garbled before evaluator-dependent AES rounds.
First 1360 AND gates are key-schedule-only.
We can evaluate this prefix locally as evaluator using received
key_evaluatorlabels and AND tables.
Record the first 40 key-schedule S-box input-wire sets (done locally by instrumenting circuit construction).
For each of the first 16 key-schedule S-box calls (4 rounds of schedule bytes):
Known: active wire labels for that S-box input byte (
A0..A7) anddelta.Unknown: semantic input byte bits.
For each candidate byte
v in [0..255], derive candidate zero labels (Zi = Aiif bit 0 elseAi ^ delta), re-garble one optimized S-box chunk (34 ANDs), and compare with observed AND tables at that chunk offset.Each chunk yields a unique byte.
Reconstruct words
w3, w7, w11, w15from chunk order (chunks are onRotWordorder).Recover initial words
w0, w1, w2algebraically from AES-128 schedule recurrence using:w7, w11, w15andg(w3), g(w7), g(w11).
Key is
w0 || w1 || w2 || w3.Send empty circuit line to exit loop, submit recovered key, receive flag.
Recovered flag: srdnlen{I_hope_you_didn't_slop_this_one...although_I_don't_know_if_you_can_slop_it...}
Solution code used:
Faulty Mayo
Description
The service exposes a one-byte fault injection in a patched MAYO-2 signer (chall) before returning (pk, sm) for a fixed secret key. The allowed patch window sits inside mayo_sign_signature, specifically in the final s = v + O*x construction. With carefully chosen one-byte patches, each signature query leaks linear equations in one row of secret matrix O over GF(16). Recovering all 64 rows of O lets us forge valid signatures for arbitrary messages and obtain the flag from option 2.
Solution
Reverse
challand map patchable offsets to instructions inmayo_sign_signature.Use faulted signatures to obtain equations for unknown row entries of
O.Solve each row as a 17-variable linear system over GF(16).
Rebuild an equivalent signer using recovered
Oand publicseed_pkfromcpk.Forge a valid signature for the challenge message, submit as signed message hex
sm = sig || msg.
Fault offsets used per row (MAYO-2):
row 0: patch0x62f5 -> 0x7drow 1: patch0x630d -> 0x7drows 2..62: patch0x6323 + 0x16*(row-2) -> 0x25row 63: patch0x6866 -> 0x25
Exploit script (solve.py):
Custom signer (forge_mayo.c):
Build and run:
Recovered flag: srdnlen{M4YO+0n3_N1bBl3_F4ulT=Br0k3N}
Lightweight
Description
We are given an oracle based on a 4-round Ascon-like permutation:
Secret key:
key[0], key[1](128 bits total), fixed for the session.Per query we choose
diff = (d0, d1).Server samples random nonce
(n0, n1), prints:nonce
F_k(n0, n1)(first two 64-bit words after 4 rounds)F_k(n0^d0, n1^d1)
After up to
2^16queries we must guess the key.
The core weakness is reduced-round diffusion: for d0=d1=1<<i, specific output-bit differentials have strong key-dependent biases.
Solution
Reproduce the permutation exactly (important detail: after S-box,
x4must be set tot4).Invert only the linear layer of
x0(x0 ^= rotr19(x0) ^ rotr28(x0)) using a precomputed 64x64 GF(2) inverse matrix.For each bit position
i:Query many times with
diff=(1<<i, 1<<i).Let
u = Linv(x0_a) ^ Linv(x0_b)from the two outputs.Measure two empirical biases:
e1from bitj1=(i+1) mod 64e2from bitj2=(i+14) mod 64
Classify
(k0[i], k1[i])by nearest centroid among 4 key-bit-pair classes.Use two centroid tables depending on
iviaCMASK=0x73.
Build full candidate key
(k0,k1).Verify candidate in-session by issuing a few random differentials and checking predicted outputs from local
ascon_eval.If verification fails, add more samples only for low-margin bit positions and reclassify.
Submit recovered key, receive flag.
Recovered flag: srdnlen{https://www.youtube.com/shorts/8puNABA4rxw}
Threshold
Description
A lattice-based FROST-like threshold signature service lets us request partial signatures from signers 1..15 (we are signer 0) for any message except "give me the flag". We are given vk and our share sk[0].
The service computes each partial as:
z_i = r_i + lambda_i * c * s_i (mod q)
where:
r_iis fresh Gaussian masking noise,lambda_iis the Lagrange coefficient for signer setS,cis hash-derived challenge from aggregated commitment high bits.
Solution
Key observations:
The preprocessing cap is queue-depth-based (
<=8), not total-usage-based. By alternating menu options, we can collect many signatures.We can force a fixed challenge
cby choosing our commitmentw_0each query so the aggregate commitment sum for selected signers is exactly zero before high-bit extraction.With fixed
c, each coefficient becomes a 1D modular noisy equation:z = lambda * u + noise (mod q), whereuis a coefficient ofc*s_i.
We choose many signer subsets to get multiple
lambdascales (small/mid/huge) for each target signer. For each coefficient, we solve via interval intersection + maximum-likelihood selection.Recover 7 signer shares (
8..14), combine with our share (0), reconstruct/validate the master secret via interpolation (it must be small Gaussian), then forge a valid signature on target message offline and submit.
Full exploit code used:
misc
The Trilogy of Death Volume I: Corel
Description
Forensics challenge on a Corel Linux disk image. A WordPerfect macro file (fc.wcm) is present and contains the clue The key is in what is left plus encrypted byte arrays.
Solution
The direct image-repair path was a dead end, so I pivoted to the macro artifact.
fc.wcm contains:
A 4-byte key array (
k1..k4) initially set toFAKE.A phrase printer:
The key is in what is left.Two encrypted arrays (
docbody,rh) decoded with:
This expression is bitwise XOR (bb ^ kb).
So the payload is XOR-encrypted with a repeating 4-byte key. Using the given fake key prints nonsense. I brute-forced the 4-byte key under a strict flag charset ([a-z0-9_{}]) against docbody.
Code used:
Output includes:
Submitted flag:
The Trilogy of Death Volume II: The Legendary Armory
Description
Forensics challenge on a Windows minidump (chall.dmp) with the hint that two relics in volatile memory must be XORed.
Solution
The visible SRDNLEN{REALLY_EASY?} image text was a decoy.
The real path came from the d.iso clue in a recovered image fragment and recovered ISO directory entries (K.;1, T.;1). The clean T payload copy in memory is at 0x7625d8b (size 176578), and the 8-byte XOR key is:
f4 14 a5 31 17 02 0b 84
XORing T with this repeating key yields a ZIP local-header stream (no central directory). Extracting entries from local headers recovers multiple ZZT files, including TOWN.ZZT.
Inside TOWN.ZZT, the Armory text is stored as repeated control triples \x01\x35<char>. Decoding that run reveals the flag.
Repro script:
Output:
The Trilogy of Death Volume III: The Poisoned Apple
Description
Given poisoned_apple.zip (contains poisoned_apple.dmg), encrypted_flag.bin, and a slow decryptor (decrypt_flag.py) with 500,000 candidate keys (keys/key_*.txt) inside APFS.
Bruteforce is intentionally impractical (PBKDF2-SHA256, 140000000 iterations).
Solution
The intended path is APFS forensics, not crypto.
Extract and inspect image:
Extract APFS partition and locate APFS volume superblocks (
APSB):
Enumerate APFS root and confirm key directory size:
Parse
.fseventsdand find outlier activity:
Read
key_449231across APFS superblock states (history):
This shows two historical values for inode 449414 (keys/key_449231.txt):
old (xid <= 5526):
39f520679fd68654500f9cd44e8caed2bc897a3227dc297c4520336de2a59dd7new (xid >= 5527):
b1a64c6e89971c26ce98d5984ec0499756306813c692ebb26cc039ad4c9b3319
The newer one is the poisoned value; the older snapshot value is the real key.
Decrypt and verify:
Recovered flag: srdnlen{b3h0ld_th3_d34dl1_APFS!}
pwn
common_offset
Description
common_offset is a 64-bit non-PIE ELF with NX, no canary, and partial RELRO. The program lets you write to one of 4 file-buffers with a shared offset.
Bug: in change_files(), index and offset overlap in stack bytes:
indexis stored at[rsp+0x49]offsetis awordat[rsp+0x48]
By first setting index=0 and increasing offset by 1, then setting index=3 and increasing by 255, carry corrupts effective index to 4 and produces OOB table access into the change_files stack frame. That gives RIP control on return.
Solution
Two-stage exploit:
Stage1 RIP overwrite to call
read_stdinagain and land onadd rsp,0x28; ret.Stage2 ROP that:
leaks
puts@gotto compute libc base,runs a small write-VM (
get_number -> mov rdi,rax ; add rsp,0x58 ; ret -> read_stdin) to place arbitrary 8-byte chunks in.bss,finally jumps to
setcontextwith a crafted fake ucontext.
Final payload uses:
fopen("/challenge/flag.txt", "r")mov rdx, rax ; retto pass returnedFILE*tofgetsfgets(buf, 0x80, fp)puts(buf)
Important gotcha: this service accepted libc symbol offsets (puts/fgets/fopen/setcontext) from the provided libc.so.6, but gadget offsets differed between Ubuntu 2.42-0ubuntu3 and 2.42-0ubuntu3.1. So the exploit tries both gadget sets:
set A:
pop rdi=0x11b93a,mov rdx,rax=0x145f17set B:
pop rdi=0x11b8ba,mov rdx,rax=0x145ed7
Remote solved with set B.
Recovered flag: srdnlen{DL-r35m4LLv3}
Echo
Description
The program implements an echo loop with a custom read_stdin routine.
Bug: read_stdin uses an 8-bit index and loop condition idx <= len, so for len = 0x40 it writes 65 bytes into a 64-byte buffer (1-byte overflow).
That single-byte overflow hits the adjacent len variable on the stack, letting us increase future read sizes and eventually control data up to canary/saved frame/return addresses.
Solution
Key stack layout inside echo:
buffer:
[rbp-0x50 ... rbp-0x11](64 bytes)len byte:
[rbp-0x10]canary:
[rbp-0x8 ... rbp-0x1]saved rbp:
[rbp+0x0 ... rbp+0x7]return address:
[rbp+0x8 ... rbp+0xf]
Exploit plan:
Overflow
lenfrom0x40to0x48.With
len=0x48, overwrite canary first byte with nonzero and leak:
canary bytes 1..7
saved
rbp(stack leak)
Set
len=0x77, then print past many stack values to leak mainโs libc return address from stack.Compute
libc_basefrom leaked return address (ret_off = 0x2a1ca), then choose one_gadget0xef52b.Final payload restores correct canary, sets a fake
rbpinto controlled stack memory, and overwrites RIP with one_gadget.one_gadget constraints are satisfied by placing NULL qwords at
[rbp-0x78]and[rbp-0x60].Spawn shell and read
/challenge/flag.txt.
Exploit code:
Registered Stack
Description
We get a PIE ELF that:
reads a hex string,
converts it to bytes (
hex_to_bytes),validates with Capstone that every instruction is only
push/popwith register operands,mmaps one RWX page,
zeros registers, sets
rsp = page_base, and jumps torsp.
Remote service: registered-stack.challs.srdnlen.it:1090.
Solution
Key points:
Validation is only on initial bytes; runtime self-modification is allowed.
push fs(0f a0) is validator-accepted; patching bytea0 -> 05yieldssyscall(0f 05).fgets(buf, 0x200, ...)only accepts at most 511 chars, so max reliable hex payload is 510 chars = 255 bytes. Sending 256 bytes (512 hex chars) truncates and breaks stage input alignment.pop spwith seeded bytes setsrsplow16 to0xc38f, so exploit is bucketed (works when mmap low16 bucket isc***, ~1/16).For
read,rsimust be full pointer (rsp), butrdxmust be small. Usingrdx=rspfails on high ASLR addresses (huge count/range issues). Userdx=rbx=0xc305instead.Stage-1 does
read(0, stage2_buf, 0xc305), then returns to stage2 buffer.Stage-2 is shellcode for marker +
/bin/sh, then send shell commands to read the flag.
Recovered flag: srdnlen{Pu5h1n6_4nd_P0pp1n6_6av3_m3_4_h34d4ch3}
rev
Artistic warmup
Description
We are given a Windows PE executable (rev_artistic_warmup.exe) and need to recover the flag.
Solution
Static reversing around the "Invalid flag."/"Valid flag!" references shows the core check at 0x1400bfb00:
It dynamically resolves GDI APIs.
It creates a
450x5032-bit DIB (CreateDIBSection), draws user input withCreateFontA("Consolas", 24)+TextOutA.It compares all
0x15f90 = 90000raw bytes of the rendered bitmap against a blob at.rdata+0x20(0x1400c5020) with XOR0xAA:check is
((rendered[i] ^ 0xAA) == blob[i]).so expected rendered bytes are
blob[i] ^ 0xAA.
That means the binary already contains the exact target rendered text image. Extract/decode it and OCR.
Code used:
OCR result is very close; using glyph consistency + CTF prefix gives the final exact flag:
srdnlen{pl5_Charles_w1n_th3_champ1on5hip}
Cornflake v3.5
Description
Given malware.exe and the hint:
The evolution of a Cereal Offender
The binary is a staged malware-like loader:
Stage1 checks the local username with an RC4-based check.
If it passes, it downloads
stage2.exe(DLL) from the challenge host.Stage2 reads
password.txtand validates it with a custom VM.
Flag accepted by platform:
srdnlen{r3v_c4N_l0ok_l1K3_mAlw4r3}
Solution
Reverse stage1 username gate:
RC4 key in binary:
s3cr3t_k3y_v1Compared hex:
46f5289437bc009c17817e997ae82bfbd065545dRC4-decrypting that value gives:
super_powerful_admin
Download stage2 from C2 endpoint:
http://cornflake.challs.srdnlen.it:8000/updates/check.php?SessionID=46f5289437bc009c17817e997ae82bfbd065545d
Reverse
stage2.exe:
MainThreadreadspassword.txt, strips CR/LF, calls VM checker, printsezornope.VM bytecode is embedded and interpreted with opcodes 0..18.
Extract VM equality constraints over a 34-char
srdnlen{...}string.
Verify candidate against extracted VM constraints and submit.
Code used:
Dante's Trial
Description
And at last, Dante faced the Ferocious Beast. Will they be able to tr(ea)it it? Note: the submitted flag should be enclosed in srdnlen{}.
We are given a Game Boy Advance ROM (dantestrial.gba).
ROM Structure
The GBA ROM contains a custom bytecode VM that validates user input through a hash function. The game presents a text-based interface where an NPC called "G." prompts the player for input (printable ASCII, 0x20-0x7e). The input is hashed and compared against a target value; if it matches, the game displays "Thou art correcteth."
VM Architecture
The ROM loads runtime code from 0x08024100 into IWRAM (0x03000000) and executes a bytecode VM. The VM script at 0x08022654 (169 bytes) is XOR-decoded with (13*i + 0x5a) & 0xff, and opcodes are permuted through a table at 0x0802270c.
The effective execution path is a simple loop:
op8: Pop next byte from input queueop11: If zero, jump to haltop10: Hash update stepop12: Jump back to step 1op13: Halt
Hash Function
The hash is a modified FNV-1a with several additions:
State: hlo (32-bit), hhi (32-bit), ptr (8-bit), seeded on first character with hlo=0x84222325, hhi=0xcbf29ce4.
Per character c:
Final comparison:
Critical Discovery: VM Memory is Zeros
The tri_mix function takes two arguments: the input character c and a byte d from VM memory at position ptr. Disassembly of the ROM's VM initialization code at 0x08000784 shows it calls memset(EWRAM, 0, 256) with NO subsequent copy of user input into this memory region. The VM script contains no op3 (store) instructions, so memory stays all zeros throughout execution.
This means d=0 always, making tri_mix(c, 0) depend only on the input character. This was verified by running the full VM dispatch loop in Unicorn Engine and comparing against a corrected Python model.
Meet-in-the-Middle Attack
The hash function's forward and backward steps are invertible (using modular inverses of P and CUP mod 2^64), enabling a meet-in-the-middle attack:
Forward pass: Enumerate all prefixes of length
p, starting from the seed state, storing(hhi, hlo, ptr)in a hash table.Backward pass: Compute the required final state from the target hash by inverting
fmix64and the final multiply. Then enumerate all suffixes of lengths, stepping backward from the required final state, and look up matches in the hash table.
Search Process
The answer turned out to be 6 characters long, containing mixed case and digits. The key insight was that prior exhaustive searches only covered [a-z0-9_] for short lengths. Running MITM with the full printable ASCII charset (95 characters) for length 6 (split 3+3) immediately found the answer:
Hash verification:
Flag
Rev Juice
Description
We are given Verilog for a vending machine. Product 8 (rev_juice) is not directly selectable (SP1..SP7 only), but selector.v has a hidden condition that sets ENABLE <= 8'h80, which enables product 8 (price 0).
The flag format is a move string:
I<n>C= insertncoins one-by-oneSPm= select productm(1..7)CNL= cancelBuying consumes coins.
Cancel refunds remaining inserted coins.
The challenge is based on using exactly 19 coins with reuse allowed.
Solution
Reverse
selector.vhidden condition. The conjunction overCOINS_HISTORY[...]forces the key taps (at trigger cyclet):
H[0]=1H[7]=4H[28]=H[33]=H[38]=6H[63]=H[73]=2H[80]=9and modular sum:
(H[19]+H[21]+H[56]+H[69]) mod 32 = 0
Build timing model from stable-cycle behavior. The solve uses these effective stable-to-stable durations:
Insert 1 coin: 3 cycles
Successful selection: 7 cycles
Failed selection: 5 cycles
CNLwith coins inserted: 4 cyclesCNLat 0: 2 cycles
Search for a sequence that places those history values at the required offsets and triggers product 8. The working sequence is:
srdnlen{I9C_SP6_CNL_I2C_SP2_I6C_SP6_SP6_SP5_CNL_I4C_SP1}
Why this sequence aligns:
Creates the required high past value
9(theH[80]tap).Creates the two
2taps (H[73],H[63]).Holds
6long enough forH[38],H[33],H[28].Uses
CNLtiming to place required zero-sum taps.Ends at
4 -> 1(SP1) soH[7]=4andH[0]=1line up when hidden condition is checked.
Verification helper script (timing + full selector equations):
This confirms cycles where the hidden selector condition is satisfied, which is the event that enables product 8.
web
Double Shop
Description
The site is a vending-machine frontend with two backend JSP endpoints:
/api/checkout.jsp(creates receipt logs)/api/receipt.jsp?id=...(renders receipt file contents)
/api/manager returns 403, hinting at a hidden โManagerโ target.
Solution
Inspect frontend JS and identify backend endpoints:
Confirm
receipt.jsppath traversal:
Read Tomcat config via traversal:
Important findings:
server.xmlcontains:RemoteIpValveinternalProxies=".*"remoteIpHeader="X-Access-Manager"
tomcat-users.xmlcontains:username="adm1n"password="317014774e3e85626bd2fa9c5046142c"
This means we can spoof client IP for Tomcat with:
Bypass Apache block on
/api/managerusing path-parameter trick (;) and reach Tomcat Manager:
Authenticate to Tomcat Manager with leaked creds:
Read application list in Manager response. One deployed context path is:
Flag:
MSN Revive
Description
Web challenge with frontend + gateway + backend source. The backend seeds the flag into initial chat history, and an export endpoint can render any session's messages. Gateway tries to protect that endpoint as local-only.
Solution
The key bug chain:
src/backend/utils.pyseeds the flag in chat session00000000-0000-0000-0000-000000000000.src/backend/api.pyhasPOST /api/export/chatwith no auth/membership check (onlysession_idexists).src/gateway/gateway.jsblocks/api/export/chatfor non-local clients.src/gateway/gateway.jsrewritesContent-Lengthfor/api/chat/eventwhen content-type isapplication/x-msnmsgrp2p, deriving length from attacker-controlled MSN P2PTotalSizefield.Gateway still forwards the full body buffer to backend, so backend sees fewer bytes than actually sent. Extra bytes become a smuggled second HTTP request on keep-alive connection (CL desync / request smuggling).
Exploit strategy:
Send
POST /api/chat/eventwith malicious P2P header whereTotalSize=0so gateway forwardsContent-Length: 48to backend.Append a full smuggled request after the first 48 bytes:
POST /api/export/chatwith JSON{"session_id":"000...000","format":"html"}.Trigger follow-up proxied requests (
/api/foo) to consume poisoned backend response queue.Parse response bodies for
srdnlen{...}.
Recovered flag:
srdnlen{n0st4lg14_1s_4_vuln3r4b1l1ty_t00}
Full exploit code used:
TodoList
Description
The app is client-side only and uses Handlebars templates. The admin bot:
Visits the challenge page.
Stores
{"secret":"<FLAG>"}in app state and saves to cookie.Visits attacker-controlled URL via
/report/.
Even without XSS, we can build a blind oracle against the bot by making template rendering intentionally expensive when a guessed condition is true.
Solution
The key primitive is:
Template condition checks secret characters via
lookup secret <idx>.If condition is true, render a heavy nested
#eachpayload.If false, render lightweight
ok.
When sent through /report/:
True branch causes fast bot failure (
500 {"error":"Admin failed..."}).False branch reaches the bot wait path and usually returns
504around 60s.
That gives a character-membership oracle.
This confirmed the final suffix and produced:
srdnlen{leakycstiggwp}
After Image
Description
A web challenge with three services behind afterimage-nginx:
PHP app (
index.php,profile.php,tokens.php)admin bot (Firefox) visiting attacker-supplied URLs via
/reportinternal camera at
CAMERA_IPexposing MJPEG/streamwith the real flag rendered on-frame
Goal: get the bot to leak the camera frame.
Solution
Find initial primitive (source-based)
profile.phpaccepts file uploads and writes them to/tmp/<sanitized filename>.PHP session files are also in
/tmpassess_<PHPSESSID>.Uploading a file named
sess_<target_sid>overwrites another session file.index.phprenders$_SESSION['nickname']unsafely -> stored XSS.
Exploit chain
Overwrite bot-target session with
nickname=<script>....Trigger bot with
/reportandurl=http://afterimage-nginx/index.php?PHPSESSID=<target_sid>.Stage1 redirects bot to attacker host (
http://ATTACKER_IP/r?...).Stage
/rprobes many random*-and-*1u.ms hosts (IP1=ATTACKER_IP,IP2=CAMERA_IP) using CORS/probe.On first host that resolves to attacker IP, spray several hidden iframe loads to
/p(same host).Stage
/psends/dieto shut attacker listener, then requests same-host/stream.Browser failover reaches
CAMERA_IP, making/streamsame-origin and readable.Parse first JPEG from MJPEG stream.
Exfiltrate by writing
JPEG_BASE64:<...>into a controlled session (loot<rand>) onafterimage.challs.srdnlen.itviaPOST /profile.php?PHPSESSID=<loot_sid>.
Recover flag and submit
Pull loot session page, extract
JPEG_BASE64, decode toframe_success.jpg.OCR + renderer matching yielded the exact flag:
srdnlen{s4me_0rig1n_is_b0ring_as_h3ll}
Exploit script (run_rebind_attempt.sh)
Stage server (oneshot80.py)
Stage payload (payload.html)
Frame extraction helper used
Last updated