๐UNbreakable 2026
Probably my last CTF for a while. Pretty late publishing this, was same weekend as DiceCTF.
cryptography
toxicwaste
Description
The service implements a KZG-style opening check on the supersingular curve y^2 = x^3 + 1 over GF(p), with p = 6q - 1 and subgroup order q.
It publishes a shuffled SRS:
points
alpha^i * G1fori = 0..39matching coefficients of a degree-39 polynomial
A(x)such thatA(alpha) = 0
The intended protection is the shuffle, but the curve is pairing-friendly and exposes enough structure to undo it.
Solution
Let P_i = alpha^i * G1. The important leak is:
sum coeff_i * P_i = 0
which means the published coefficients form a vanishing polynomial A(x) with root alpha.
The only obstacle is that the tuples are shuffled. On this curve there is an efficient distortion map:
psi((x, y)) = (zeta * x, y) where zeta^3 = 1, zeta != 1
and therefore
e(P_i, psi(P_j)) = e(G1, psi(G1))^(alpha^(i+j))
So pairings let us recognize when two published points correspond to exponent addition. Using that, we recover the hidden order of the SRS points:
identify
G1 = alpha^0 * G1find the unique published point acting as
alpha^1 * G1repeatedly add exponents via pairing comparisons to walk the whole chain
alpha^0, alpha^1, ..., alpha^39
Once the order is known, the shuffled coefficients become the actual polynomial
A(x) = a_0 + a_1 x + ... + a_39 x^39
We solve A(x) = 0 over GF(q) and keep the root whose powers really reproduce the published points. That gives the toxic waste alpha.
After recovering alpha, the verifier is completely broken. We commit to the degree-41 polynomial:
f(x) = x^41
This is enough because the service only prints the flag when the interpolated polynomial has degree > 40.
For a query point z, send:
y = z^41 mod qpi = ((alpha^41 - z^41) / (alpha - z)) * G1
Then the pairing check is exactly the KZG opening equation for f.
Solver used:
Flag:
flag{Alph4_h4s_t0_b3_1nc1n3r4t3d_947a1a1e8895d3d483ab}
forensics
RAM Vault Beacon Malware
Description
Given a Linux RAM dump (memory.lime) from a compromised system, recover the challenge flag.
Solution
Recovered malware source strings in RAM show the vault/key logic:
ts_window = floor(time(NULL)/600)*600ikm = SHA256(machine_id || TASK_ID || ts_window_le8 || SHA256(STAGE_ARGS_B64))key = SHA256(KDF_SALT || ikm || "rocsc/vault")aad = SHA256(machine_id || TASK_ID)Vault format: header (5 little-endian u32: version, nonce_len, pt_len, ct_len, crc32_ct) + nonce(24) + ciphertext(48)
AEAD:
XChaCha20-Poly1305.
Recovered runtime artifacts from RAM:
TASK_ID=1bcec366-9649-4a61-8c2d-9c6b2c2a702aKDF_SALT=d17025e353b5380823b9eef194ef33adSTAGE_ARGS_B64=TRVnDl1dSQDmaFZohHQdYe0nqpAS+w9qgphZ9rBC7gARGbEalpjyTCw+o/KopwZzIg4OQ8W/3m7tuiOy7/74AgA=POST body in RAM:
task=1bcec366-9649-4a61-8c2d-9c6b2c2a702ats=1772107800fp=aa7f195dcaa55dc92809fc10278fcb13feea281ac564cd70141ba17f64bd5c5d00000000c2b6b1572b7e2b0417fd86c9819af720a80df32383d03567b93a7bb71deb27b9hmac=4b322b9f3362762f829ef3b43a461fdeeba8de09d5ec3d2e98bae67235fe66fa
Machine-id recovered by matching
SHA256(machine_id)to the first 32 bytes offp:machine_id=783abf8dcd8846d889fee75ae6b1046a
Reproducible solver code used:
Output plaintext:
hex:
55eb337497c226fadcd74648227da4831106f06439145d500bb383b47bfa8745
Submitted flag:
CTF{55eb337497c226fadcd74648227da4831106f06439145d500bb383b47bfa8745}
Relay in the Noise
Description
A packet capture from a Linux packet-radio relay box is provided. Recover the hidden message/flag from the capture.
Solution
The useful traffic is in the KISS/AX.25 packets (udp 49712 -> 8001) near the end of the pcap.
Key observations:
BLT/xx/17:<base32>messages fromN9VHF-9contain the ciphertext fragments.A chat line gives the mask rule:
sha256(lower(grid)|ssid).A beacon includes
qth=FN31pr, solower(grid) = fn31pr.Sender SSID for the real BLT stream is
9(N9VHF-9).
Reconstruction/decryption that works:
Reassemble
/17bulletin chunks in index order01..17.Base32-decode the concatenated text to get 127-byte ciphertext.
Build keystream in counter mode using SHA-256 with seed
b"fn31pr|9":block
i=sha256(seed + i.to_bytes(4, "big"))
XOR ciphertext with keystream.
zlib-decompress the XOR output.
Recovered plaintext: OPORDER|relay=old_water_tower|window=0215z|flag=UNR{4x25_p47h5_4nd_6r1d_5qu4r35_73ll_7h3_570ry_2fee56dc8f22f6a7}|note=burn_after_reading
Flag: UNR{4x25_p47h5_4nd_6r1d_5qu4r35_73ll_7h3_570ry_2fee56dc8f22f6a7}
Tokio Magic
Description
Forensics challenge about a malware detonation on a Windows image. The flag format was:
UNR{ans1_ans2_ans3_ans4_ans5}
Questions:
Keyboard layouts installed/used by the user
Modification timestamp of the Defrag prefetch file
SHA-256 hash of the malware detonated on the machine
First part of the flag string
Last part of the flag string
Solution
Part 1 was normalized as English. The local layout evidence pointed to a single English/US layout.
Part 2 came from the prefetch file, not defrag.exe itself. The correct file was Windows/Prefetch/DEFRAG.EXE-738093E8.pf, MFT record 103949.
Relevant timestamp:
So part 2 was:
Part 3 was the 2026 sample, not the older rejected bf575... branch. The strongest chain was:
I_see_you.zip.7878kr5jxin Downloads had a preservedZone.Identifier.That ADS pointed to MalwareBazaar download URL
.../723d1cf3d74fb3ce95a77ed9dff257a78c8af8e67a82963230dd073781074224/.USN showed
723d...execreated on the Desktop and renamed tosvch.exe.UserAssist showed
C:\Users\Masquerade\Desktop\svch.exeexecuted.The recovered
svch.exehashed to the same723d...value.
Useful commands:
The final malware hash was:
Part 4 came from Chrome JumpList data. jumplist_31180.bin contained both the MalwareBazaar download URL and a Pastebin entry whose title already exposed the answer.
Relevant lines:
The page itself also confirmed it:
Output:
So part 4 was:
Part 5 came from decrypting secret.enc using the XOR keystream derived from the known plaintext pair know.txt and know.txt.7878kr5jx.
Recovered text:
So part 5 was:
Final flag:
pwn
atypical heap
Description
The binary is a musl-based note manager with two useful bugs:
read noteonly checkssz <= 0x100, notsz <= notes[idx].size, so it over-reads past the note.Hidden menu option
5is an unlimited aligned 8-byte arbitrary write. The code setsmagic_used = 1once, but never checks it.
The goal is to turn a musl mallocng heap leak into a PIE leak, then into full arbitrary read/write, and finally into code execution.
Solution
For a first malloc(0x70), the over-read at offset 0x80 leaks meta0 + 0x28, which is the next free struct meta slot in the same meta_area. That gives a reliable heap pointer.
Using the arbitrary write:
Treat that next free
metaslot as a fake active group.Rewire the real
meta0so the next0x70allocation advances to the fake one.Make the fake
metareturn a note whosedatapointer lands on the originalmeta0.Reading that forged note reveals
meta0->mem, which points at the live group in the anonymous mapping next to the PIE.For the first
0x70allocation,meta0->memis alwayschall_base + 0x3f20, sochall_base = meta0->mem - 0x3f20.With the PIE base known, overwrite a
notes[]entry to point anywhere and use note read/write as arbitrary read/write.Leak
printf@gotto recover the musl base.Overwrite musl's
atexitbuiltin list soexit(0)callssystem("sh -c 'cat ...flag...'").
Exploit:
atyipical-heap-revenge
Description
The binary has two obvious bugs:
NOTE_READtrusts the user-supplied read length up to0x100instead of the note's real size, so small allocations become bounded OOB reads.Hidden menu choice
5is an unlimited aligned 8-byte arbitrary write becausemagic_usedis never enforced.
The intended twist is musl's allocator. Small allocations live inside nested groups, so an OOB read from the last slot of a group can leak the next group's header and therefore a musl meta pointer. Once one accessible group's meta->mem field is overwritten, a normal allocation can be redirected to an arbitrary address.
Solution
The exploit uses two musl groups:
Allocate one
0x70note and read0x100bytes from it. At offset0x80this leaks a heapmetapointer for a single-slotsc3group. I use that group as an arbitrary-address reader for one allocation of size0x30.Allocate 30 notes of size
1. Reading0x40bytes from the 30th note leaks another heapmetapointer at offset0x10, this time for a single-slotsc11group.Overwrite the leaked
sc3group'smeta->memwithmeta_a - 0x10, then allocate a0x30note. That note lands onmeta_a, so reading it leaksmeta_a->mem.meta_a->memis always atmapping_start + 0x2ec0, wheremapping_startis the anonymous RW mapping placed after libc.libcis still a fixed delta from there, but the PIE delta was not stable between local and remote, so I do not guess it.Instead, I reuse the leaked
sc11group once more to read the post-libc pointer table atmapping_start + 0x1680. The qword at table offset0x40is the exact PIE base for the current run.The same post-libc mapping contains a stable stack anchor at
mapping_start + 0x1da0. During the blockingread()used byNOTE_WRITE, the saved return address is always atstack_anchor - 0x88.Write
"cat flag.txt"into an unusednotesentry in.bss, repoint a note at the liveread()return address, and useNOTE_WRITEitself to place a short ROP chain there:ret; pop rdi; "cat flag.txt"; system; pop rdi; 0; exit
That returns out of the in-flight read() directly into system("cat flag.txt").
reverse_engineering
jumpy
Description
The binary reads up to 0x100 bytes from stdin, pads to 32-byte blocks, transforms each block, and writes the result to enc.sky. The provided enc.sky is the encrypted target.
The interesting part is that the per-block logic is stored in 14 code blocks around 0x401fd3, but those blocks are XOR-masked and only decrypted right before execution. The dispatcher also re-encrypts the previous block after each jump.
Solution
The block decryptor is:
Decoding those 14 blocks shows that only a subset is live. The effective encryption on each 32-byte block is:
Build
seed = SHA256("UNBR26::GrayInterleaveSbox::v1" || 1337c0de26aabbccdeadbeef42241999).Build a 256-byte permutation with a Fisher-Yates shuffle driven by
SHA256(seed || counter_le32)output bytes.For block index
blk, buildks = SHA256(seed || "KS" || blk_le32).For each byte pair
(pos, pos+1)inside the 32-byte block:x ^= ks[pos]x = x + (31*blk + 17*pos) mod 256x = x ^ (x >> 1)(Gray encode)Do that for both bytes.
Swap the low nibbles between the two bytes.
Substitute each byte through the shuffled permutation.
Rotate each byte left by
ks[pos] & 7.
The file uses PKCS#7-style padding to 32 bytes.
To decrypt, invert those steps in reverse order:
Running it prints:
riga crypto
Description
Reverse an npm package that drops a PyInstaller/PyArmor GUI wrapper around a custom Go shared library and recover the plaintext behind attachments/flag.enc.
Solution
embedded_app is a distraction layer. The useful path is:
Deobfuscate the package enough to extract the dropped binaries.
Execute the PyArmor payload under a local CPython 3.13 build with a stub
pygamemodule.Confirm the Python code only calls
libmylib.so'sEncryptFileHex(path, ignored)onflag.txt.Recover the fixed AES layer from the Go library globals:
key bytes: ASCII
021b49755fb4961a40f3a539ee80fa8fIV bytes: ASCII
8cc46e76876a55c1trailer: ASCII
67e672f4049b06ee
Decrypt
flag.encto get the transformed flag bytes.Reverse the Go byte pipeline with chosen-input tests and gdb snapshots.
Re-encrypt the recovered candidate through the original library and verify it matches
attachments/flag.encexactly.
Recovered flag:
Exact solver:
substrate
Description
The userland binary SubstrateUM.exe talks to the driver SubstrateKM.sys with two IOCTLs. It reads 0x45 bytes from stdin, sends them one byte at a time with IOCTL 0x228124, then sends IOCTL 0x228128 to ask the driver whether the whole input is correct.
Static reversing of the first IOCTL handler shows it is only a setter:
input buffer is 2 bytes:
[index, value]valueis stored in a global 72-byte buffer atbuf[index]the userland program only sends 69 bytes, so the last 3 bytes in the driver buffer stay
0
The real work is in the second IOCTL. The code is flattened and annoying to single-step, so the clean path is:
Reverse the userland IOCTL usage.
Reverse the setter in the driver.
Recover the checker logic from the driver.
Solve the recovered equations modulo 256.
The checker operates on 8 chunks of 9 bytes. Each chunk is a 3x3 upper-triangular matrix built from a 9-byte entry block in the driver. The matching target bytes come from another 9-byte block in .data.
For one chunk with entry bytes e[0..8], the matrix is:
If a plaintext row is [a, b, c], the checker compares:
Because M is upper-triangular and every diagonal byte is odd (| 1), each row can be solved directly with modular inverses in Z/256Z.
Solution
The following script extracts the two 72-byte tables from SubstrateKM.sys, reconstructs the matrices, solves every row, and prints the accepted flag.
Running it prints:
the flag is a lie
Description
The shipped game contains a fake visible flag and a hidden dev scene. The real signal is the bundled encrypted replay log session-20260225-111621.unrl.
LogRecorder in the hidden dev scene stores the AES key in serialized scene data, and the .unrl file is a stream of encrypted replay records. After decrypting and parsing the records, the useful view is not the late static grid; the solve comes from the early moving entities (id <= 180). In a rotated orthographic projection near the end of the replay, those entities form mirrored text. Flipping the image horizontally reads:
CERTIFIED_CRATE_PUSHER
So the flag is:
UNR{CERTIFIED_CRATE_PUSHER}
Solution
The important recovered AES key from the hidden dev scene is:
The .unrl container is:
magic
UNRLversion
2encrypted-record flag byte
then alternating length-prefixed blobs:
16-byte IV
ciphertext blob
Each decrypted record is:
typebyteentity idint32timestampdoubleoptional transform payload for 41-byte records:
Vector3 positionQuaternion rotation
I used the following script to decrypt and parse the bundled replay:
Then I used a small interactive viewer over the parsed replay. The key point is to look at only the early entities (id <= 180) and rotate the projection. Near the end of the replay, they collapse into a mirrored line of text.
At the end of playback, rotating the early-entity projection reveals mirrored text. Flip that image horizontally and it reads:
Final flag:
webd-art
Description
The challenge ships a browser app backed by a Dart-to-Wasm module. The visible UI only accepts a phrase and renders art on a canvas. The interesting logic lives inside main.wasm.
Solution
main.mjs is just the standard Dart wasm loader. The real work is in main.wasm.
Using binaryen to print the module to text showed:
The app checks the input against
^CTF\\{[ -~]{8,80}\\}$.On success it can draw:
CERTIFICATE UNLOCKEDa second string
stamp: verified locally
That unlock path is gated by a
br_ifin function$115.
I patched a copy of the wasm to bypass that single branch so the hidden middle string would be rendered for any CTF{...} input. That confirmed the middle string is not a constant; it is generated from a 32-bit seed and then UTF-8 decoded.
The patch was:
The relevant part of the unlock path is:
Build a 32-bit seed from the input-dependent hash state.
Generate 40 bytes with an
xxhash32-style avalanche.XOR those bytes with a static 40-byte table embedded in the wasm.
Decode the result as UTF-8 and draw it on the canvas.
Because the final hidden string is itself a flag, its prefix is known: CTF{.
The avalanche step is a permutation on 32-bit values, so the first known output byte reduces the search from 2^32 seeds to 2^24 candidates. I inverted the avalanche, enumerated all candidates matching the first byte, and filtered them with the remaining known prefix/suffix plus printable-ASCII constraints. That yields a unique result.
Code used:
Build and run:
Output:
web
demolition
Description
Web challenge with a public app at https://demolition.breakable.live/ and an admin bot at https://demolition-bot.breakable.live/.
The app auto-runs a render pipeline on page load using query parameters:
p: profile blobd: draft HTMLtpl: compose template
The bot sets a non-HttpOnly FLAG cookie for the challenge origin, then visits a submitted challenge URL.
Solution
The intended bug chain is:
The frontend lets
p=render.engine=goswitch/api/renderfrom the Python escape path to the Go sanitizer path.Flask blocks script tags with:
This is ASCII-only.
The Go sanitizer canonicalizes allowed tags with Unicode-aware
strings.EqualFold, so<ลฟcript>usingลฟ(U+017F, long s) is accepted asscriptand rewritten to a real<script>tag:
The frontend inserts the returned HTML with
innerHTML, then explicitly re-arms script tags:
So a payload in d becomes executable JavaScript in the botโs browser.
Since the botโs
FLAGcookie is notHttpOnly, the XSS can readdocument.cookieand exfiltrate it.
I used postb.in as a temporary request collector.
First create a bin:
It returned:
Then build the exploit URL:
That produced:
Submit it to the bot:
After the bot visited the page, read the captured request:
Relevant part of the response:
Flag:
nday-1
Description
The challenge deploys Apache Airflow and gives default credentials admin/admin.
The instance was running Airflow 3.0.4 and exposed the bundled example DAGs. One of them, example_dag_decorator, is vulnerable because it accepts a trigger-time url, fetches JSON from that URL, copies raw_json["origin"] into a shell command, and passes it directly to BashOperator.
Solution
Login with admin/admin, then abuse example_dag_decorator.
Relevant DAG source:
httpbin has /response-headers, which returns arbitrary query parameters as JSON. So trigger the DAG with:
That makes the final rendered command:
I used the API directly:
The task log printed:
larpin
Description
Larp guru says: wake up in miami beach all I see is sand, take a look out my kitchen window all I see is land. Get larpin premium to larp harder.
Solution
The report bot renders reported profiles in HeadlessChrome 145 through a local wrapper origin. Reported profile content is sanitized with DOMPurify, but an SVG <style> survives and applies globally. The rendered profile page also contains an inline config script:
The trick was to exfiltrate premiumToken from that inline script with CSS only.
Target the inline config script with:
Force that script to render as text and give it an anchor.
Use a custom font where every glyph is zero-width except the single character that appears immediately after the known context
premiumToken: "<known_prefix>.Map candidate characters to different glyph widths.
Use an absolutely positioned probe with
width: anchor-size(--cfg inline)and a container query to translate the resulting width into a webhook hit.Repeat one character at a time until the terminating
"is observed.
That recovered the premium token:
Then I activated premium and opened /premium, which displayed the final flag directly:
Commands used:
Helper font builder used during the solve:
Extractor used during the solve:
minegamble
Description
Welcome to MineGamble, the #1 Pay-to-Win Minecraft server! Ranks are expensive, the economy feels rigged, and their Terms and Conditions update faster than you can read them. Rumor has it that you can directly buy the Owner rank that has support directly from the admins, but that's crazy expensive...
Solution
The solve is two bugs chained together:
POST /api/sellis raceable, so the same inventory can be sold twice concurrently.Ticket bodies are rendered as raw HTML, and the ticket page CSP allows scripts from
https://cdnjs.cloudflare.com, sohyperscriptcan run in the admin bot when it reviews our ticket.
First, register a user, race the sell endpoint until the balance is over $10000, then buy OWNER.
Then submit a support ticket whose body reads document.cookie in the admin bot, logs back into our owner account, and submits the cookie value as a new ticket. The important detail is that the admin session cookie is HttpOnly, but there is also a non-HttpOnly cookie named flag, so document.cookie directly exposes the flag.
Submit that HTML as the ticket body:
When the admin bot reviews the ticket, it creates a new ticket containing:
svfgp
Description
Web challenge with two public endpoints:
https://svfgp.breakable.live/https://svfgp-bot.breakable.live/
Bot behavior (from handout bot.js):
Visit challenge origin.
Store flag in
localStorage["svfgp.notes.v1"]as a sealed note.Visit attacker URL.
Sleep 60 seconds.
Challenge bug (from static/app.js):
mode=probeloads sealed secret from localStorage.If
secret.startsWith(candidate)it runs expensive PBKDF2 (3_000_000iterations).Then posts
{type:"svfgp-probe-done", sid, rid}towindow.opener.
This gives a cross-origin timing oracle: correct prefix => slower response.
Solution
I used an attacker page hosted via httpbin base64 endpoint, submitted to the bot. The page repeatedly probes mode=probe&q=<prefix+char> and times the delay until postMessage. Highest timing wins each character. I exfiltrated progress through fetch(..., {mode:"no-cors"}) to RequestBite.
Important reliability points discovered live:
Image beacons fail because bot runs Chrome with
--blink-settings=imagesEnabled=false.Opening many popups at once caused timeouts.
Reusing a single popup and re-navigating it for each probe worked reliably.
Recovered flag: CTF{1390e7327d4c2069a97e3a7f1eafed37e389f9fb9598b183455dc9f6cc2da658}
Solver code used:
Last updated