๐Ÿ’Ž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 * G1 for i = 0..39

  • matching coefficients of a degree-39 polynomial A(x) such that A(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 * G1

  • find the unique published point acting as alpha^1 * G1

  • repeatedly 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 q

  • pi = ((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)*600

  • ikm = 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-9c6b2c2a702a

  • KDF_SALT=d17025e353b5380823b9eef194ef33ad

  • STAGE_ARGS_B64=TRVnDl1dSQDmaFZohHQdYe0nqpAS+w9qgphZ9rBC7gARGbEalpjyTCw+o/KopwZzIg4OQ8W/3m7tuiOy7/74AgA=

  • POST body in RAM:

    • task=1bcec366-9649-4a61-8c2d-9c6b2c2a702a

    • ts=1772107800

    • fp=aa7f195dcaa55dc92809fc10278fcb13feea281ac564cd70141ba17f64bd5c5d00000000c2b6b1572b7e2b0417fd86c9819af720a80df32383d03567b93a7bb71deb27b9

    • hmac=4b322b9f3362762f829ef3b43a461fdeeba8de09d5ec3d2e98bae67235fe66fa

  • Machine-id recovered by matching SHA256(machine_id) to the first 32 bytes of fp:

    • 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 from N9VHF-9 contain the ciphertext fragments.

  • A chat line gives the mask rule: sha256(lower(grid)|ssid).

  • A beacon includes qth=FN31pr, so lower(grid) = fn31pr.

  • Sender SSID for the real BLT stream is 9 (N9VHF-9).

Reconstruction/decryption that works:

  1. Reassemble /17 bulletin chunks in index order 01..17.

  2. Base32-decode the concatenated text to get 127-byte ciphertext.

  3. Build keystream in counter mode using SHA-256 with seed b"fn31pr|9":

    • block i = sha256(seed + i.to_bytes(4, "big"))

  4. XOR ciphertext with keystream.

  5. 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:

  1. Keyboard layouts installed/used by the user

  2. Modification timestamp of the Defrag prefetch file

  3. SHA-256 hash of the malware detonated on the machine

  4. First part of the flag string

  5. 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:

  1. I_see_you.zip.7878kr5jx in Downloads had a preserved Zone.Identifier.

  2. That ADS pointed to MalwareBazaar download URL .../723d1cf3d74fb3ce95a77ed9dff257a78c8af8e67a82963230dd073781074224/.

  3. USN showed 723d...exe created on the Desktop and renamed to svch.exe.

  4. UserAssist showed C:\Users\Masquerade\Desktop\svch.exe executed.

  5. The recovered svch.exe hashed to the same 723d... 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 note only checks sz <= 0x100, not sz <= notes[idx].size, so it over-reads past the note.

  • Hidden menu option 5 is an unlimited aligned 8-byte arbitrary write. The code sets magic_used = 1 once, 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:

  1. Treat that next free meta slot as a fake active group.

  2. Rewire the real meta0 so the next 0x70 allocation advances to the fake one.

  3. Make the fake meta return a note whose data pointer lands on the original meta0.

  4. Reading that forged note reveals meta0->mem, which points at the live group in the anonymous mapping next to the PIE.

  5. For the first 0x70 allocation, meta0->mem is always chall_base + 0x3f20, so chall_base = meta0->mem - 0x3f20.

  6. With the PIE base known, overwrite a notes[] entry to point anywhere and use note read/write as arbitrary read/write.

  7. Leak printf@got to recover the musl base.

  8. Overwrite musl's atexit builtin list so exit(0) calls system("sh -c 'cat ...flag...'").

Exploit:

atyipical-heap-revenge

Description

The binary has two obvious bugs:

  • NOTE_READ trusts the user-supplied read length up to 0x100 instead of the note's real size, so small allocations become bounded OOB reads.

  • Hidden menu choice 5 is an unlimited aligned 8-byte arbitrary write because magic_used is 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:

  1. Allocate one 0x70 note and read 0x100 bytes from it. At offset 0x80 this leaks a heap meta pointer for a single-slot sc3 group. I use that group as an arbitrary-address reader for one allocation of size 0x30.

  2. Allocate 30 notes of size 1. Reading 0x40 bytes from the 30th note leaks another heap meta pointer at offset 0x10, this time for a single-slot sc11 group.

  3. Overwrite the leaked sc3 group's meta->mem with meta_a - 0x10, then allocate a 0x30 note. That note lands on meta_a, so reading it leaks meta_a->mem.

  4. meta_a->mem is always at mapping_start + 0x2ec0, where mapping_start is the anonymous RW mapping placed after libc. libc is still a fixed delta from there, but the PIE delta was not stable between local and remote, so I do not guess it.

  5. Instead, I reuse the leaked sc11 group once more to read the post-libc pointer table at mapping_start + 0x1680. The qword at table offset 0x40 is the exact PIE base for the current run.

  6. The same post-libc mapping contains a stable stack anchor at mapping_start + 0x1da0. During the blocking read() used by NOTE_WRITE, the saved return address is always at stack_anchor - 0x88.

  7. Write "cat flag.txt" into an unused notes entry in .bss, repoint a note at the live read() return address, and use NOTE_WRITE itself 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:

  1. Build seed = SHA256("UNBR26::GrayInterleaveSbox::v1" || 1337c0de26aabbccdeadbeef42241999).

  2. Build a 256-byte permutation with a Fisher-Yates shuffle driven by SHA256(seed || counter_le32) output bytes.

  3. For block index blk, build ks = SHA256(seed || "KS" || blk_le32).

  4. For each byte pair (pos, pos+1) inside the 32-byte block:

    • x ^= ks[pos]

    • x = x + (31*blk + 17*pos) mod 256

    • x = 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.

  5. 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:

  1. Deobfuscate the package enough to extract the dropped binaries.

  2. Execute the PyArmor payload under a local CPython 3.13 build with a stub pygame module.

  3. Confirm the Python code only calls libmylib.so's EncryptFileHex(path, ignored) on flag.txt.

  4. Recover the fixed AES layer from the Go library globals:

    • key bytes: ASCII 021b49755fb4961a40f3a539ee80fa8f

    • IV bytes: ASCII 8cc46e76876a55c1

    • trailer: ASCII 67e672f4049b06ee

  5. Decrypt flag.enc to get the transformed flag bytes.

  6. Reverse the Go byte pipeline with chosen-input tests and gdb snapshots.

  7. Re-encrypt the recovered candidate through the original library and verify it matches attachments/flag.enc exactly.

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]

  • value is stored in a global 72-byte buffer at buf[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:

  1. Reverse the userland IOCTL usage.

  2. Reverse the setter in the driver.

  3. Recover the checker logic from the driver.

  4. 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 UNRL

  • version 2

  • encrypted-record flag byte

  • then alternating length-prefixed blobs:

    • 16-byte IV

    • ciphertext blob

Each decrypted record is:

  • type byte

  • entity id int32

  • timestamp double

  • optional transform payload for 41-byte records:

    • Vector3 position

    • Quaternion 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 UNLOCKED

    • a second string

    • stamp: verified locally

  • That unlock path is gated by a br_if in 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:

  1. Build a 32-bit seed from the input-dependent hash state.

  2. Generate 40 bytes with an xxhash32-style avalanche.

  3. XOR those bytes with a static 40-byte table embedded in the wasm.

  4. 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 blob

  • d: draft HTML

  • tpl: 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:

  1. The frontend lets p=render.engine=go switch /api/render from the Python escape path to the Go sanitizer path.

  2. Flask blocks script tags with:

This is ASCII-only.

  1. The Go sanitizer canonicalizes allowed tags with Unicode-aware strings.EqualFold, so <ลฟcript> using ลฟ (U+017F, long s) is accepted as script and rewritten to a real <script> tag:

  1. 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.

  1. Since the botโ€™s FLAG cookie is not HttpOnly, the XSS can read document.cookie and 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.

  1. Target the inline config script with:

  1. Force that script to render as text and give it an anchor.

  2. Use a custom font where every glyph is zero-width except the single character that appears immediately after the known context premiumToken: "<known_prefix>.

  3. Map candidate characters to different glyph widths.

  4. Use an absolutely positioned probe with width: anchor-size(--cfg inline) and a container query to translate the resulting width into a webhook hit.

  5. 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:

  1. POST /api/sell is raceable, so the same inventory can be sold twice concurrently.

  2. Ticket bodies are rendered as raw HTML, and the ticket page CSP allows scripts from https://cdnjs.cloudflare.com, so hyperscript can 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):

  1. Visit challenge origin.

  2. Store flag in localStorage["svfgp.notes.v1"] as a sealed note.

  3. Visit attacker URL.

  4. Sleep 60 seconds.

Challenge bug (from static/app.js):

  • mode=probe loads sealed secret from localStorage.

  • If secret.startsWith(candidate) it runs expensive PBKDF2 (3_000_000 iterations).

  • Then posts {type:"svfgp-probe-done", sid, rid} to window.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