Simiyon Vinscent Samuel · Engineering

All posts / CTF

  • CTF
  • Cryptography
  • Reverse Engineering
  • Android
  • Security

TRIVARNA 2.0: 13 challenges, 3,400 points, and the solves worth writing up

A write-up of TRIVARNA 2.0, a CSEMA capture-the-flag event: how I triaged across categories, then deep dives into an ECDSA nonce-reuse key recovery, an ARM64 native library emulated with Unicorn, a JWT jku header-injection bug, and an MQTT forensics chain.

TRIVARNA 2.0 was CSEMA's August capture-the-flag event, Event 47, run across Web, Crypto, Forensics/Steganography, Android and Network categories. I solved and submitted 13 challenges for 3,400 points; my team, Blasterz, placed 11th out of 856 teams. This post covers how I triaged the board, then walks through all 13 solves, going deepest on five: an ECDSA nonce-reuse key recovery, a mass-assignment privilege escalation on a fake cyber-defense blog, a JWT trust-chain bug, an ARM64 native library emulated instruction-by-instruction with Unicorn, and an MQTT forensics trail with a rogue device on the wire. The other eight (a predictable BLE nonce, a five-part Google Form chain, a negative-quantity checkout, audio and file steganography, VoIP tone decoding, a telemetry covert channel and a four-stage VM chain) follow. Flags are redacted to TRIVARNA{…}; this is a normal write-up, not an exploit guide.

The Digital Sentry challenge target: a fake Independence Day cyber-defense blog with an Intelligence Archive and Operative Portal in its quick links
Operation Digital Sentry's target. A fake "cyber-defense blog" hiding two real internal routes behind an otherwise ordinary Independence Day post — the web challenge the mass-assignment deep dive below walks through.

How I worked the board#

The event opened with roughly fifty challenges across the five categories above. With no team coordination tooling beyond a shared channel, triage mattered more than any single exploit: read every prompt once, bucket by category, and separate challenges with a real, provable path from the ones that were broken, decoy, or already claimed by a teammate. Several boards mixed in flags that were never meant to be reachable — the Freedom Chain challenge shipped two files of plausible-looking flags explicitly labeled as recovered "from the wrong gate," and Smart Office's rogue MQTT client existed to be confused with four legitimate IoT devices sending routine telemetry.

Filtering out decoys was its own skill. Several challenges on the board were unsolvable by design (broken assets, wrong-event flag formats); the real work was separating those from the 13 with an actual path to a flag.

Points were not spread evenly across categories. Crypto and Web each made up close to a quarter of my total, steganography and network forensics were close behind, and the two reversing-heavy challenges (one Android, one pure reverse engineering) carried the highest points-per-challenge of the board.

Crypto and Web carried the event for me. The three Crypto challenges and three Web challenges each total 800 points; the single reverse-engineering chain was worth as much as the Android reversing challenge despite needing four separate stages solved in sequence.

Same k, Twice: recovering a private key from reused randomness#

This challenge handed over a bundle describing itself as "ECDSA over secp256k1, implemented from scratch": a public key, two signed messages, a challenge message with a stated deterministic-nonce scheme, and an AES-encrypted export blob. The prompt warned, specifically, to verify the generator point is actually on the curve before trusting anything in the file — a hint that the file's own curve parameters weren't to be trusted blindly, even though they happened to match the real constants here.

The two signatures in the bundle shared the exact same r value. Since r = (kG)ₓ mod n, an identical r across two different signed messages means the same nonce k signed both — textbook nonce reuse, and it leaks the private key algebraically.

Two signatures, one leaked key. Once k is known from the shared r, the private key d falls out of either signature equation directly.
Trust the file's own curve parameters

The bundle ships its own P, N and generator G fields alongside the signatures, and they look plausible. Using them directly is the fastest path to a working-looking script — until the challenge's own warning is remembered: nothing says those fields have to be the real secp256k1 constants. A file that controls both the curve and the "proof" built on top of it can make an invalid-curve attack look like a clean nonce-reuse recovery, producing a d that passes its own file's checks but is meaningless.

Hardcode the real constants, skip the on-curve check

Hardcoding the well-known secp256k1 p, a=0, b=7, G and n instead of the file's copies closes the invalid-curve risk in practice, since the recovered key is later checked against the given public key. But skipping the explicit y² = x³ + 7 mod p assertion on G leaves the one check the challenge text asked for undone — it happens to be fine here only because the constants are hardcoded correctly by hand.

Hardcode the constants and assert the curve equation

The solver hardcodes the real secp256k1 constants and asserts the generator satisfies y² = x³ + 7 mod p before using it anywhere, rather than trusting the file's copy of those parameters — exactly what the prompt's warning was pointing at, and the only version that would have caught a tampered file.

The recovered key verified against the given public key on the first try: point_mul(d, G) == pub. The bundle also supplied its own deterministic-nonce recipe — k = SHA256(private_key || message) mod n — for a stated challenge message. Using the recovered d to reproduce that nonce and sign the challenge message yields a fresh signature whose s value, SHA-256 hashed and truncated to 16 bytes, turned out to be the AES-128 key protecting the export blob.

Every assertion in the script has to pass before the flag prints. Sample values shown; the real run checks the recovered key against the given public key before it ever touches the AES blob.
CSEMA platform showing Same k, Twice marked SOLVED at 250 points, 28 solves
Same k, Twice confirmed solved on the CSEMA platform. 28 solves across the event by the time this screenshot was taken.
CSEMA platform page for Same k, Twice showing the challenge text about verifying the generator point, marked SOLVED, 250 points, 24 solves
The challenge page and its warning. "Verify the generator point is actually on the curve before using it" is the sentence the solver's assertion answers; 24 solves when this was captured.

Operation Digital Sentry: mass assignment into an admin session#

The task text was Atbash-ciphered, and decoding it pointed at a live site: a fake "Digital Sentry" cyber-defense blog for Independence Day, with an archive.php page rendering roughly 150 fake crawled URLs and a gated dashboard.php. Almost all of those 150 URLs were decoys that 404; two carried real secrets in their query strings — a flag fragment, and what turned out to be a live API bearer token.

Brute-force the gated admin routes directly

dashboard.php and admin.php both redirect unauthenticated requests straight back to the login page with a 0-status opaque redirect — there is no credential form to brute-force and no visible login bypass on those routes themselves. Attacking the gate head-on goes nowhere; the bug is upstream, in how a session gets its privilege level in the first place.

Use the leaked API key to register a normal account

The bearer token found in the archive turns 401 Unauthorized into 400 Missing required parameters on api/register.php, and a normal signup with just email/password succeeds and logs in. That proves the token is real and the auth flow works, but a non-admin session still can't reach dashboard.php or admin.php — it stops one step short of the flag.

Add is_admin: true to the same signup request

api/register.php performs no server-side filtering of the registration body at all: it writes back whatever JSON keys the client sends, including ones that should only ever be set server-side. Appending is_admin: true (and role: 'admin', for good measure) to the exact same signup call used above persists an admin-privileged account on the first try.

The vulnerability is in what the server reads, not what it documents. The registration endpoint's real contract is "whatever JSON keys you send," not "email and password."

Logging in with that account set an authenticated, admin-privileged session cookie. dashboard.php ("Command Center") and admin.php ("High Command") then unlocked, each revealing one further flag fragment; concatenating the URL-parameter fragment with those two gave the full flag.

Browser console showing a fetch to api/register.php with is_admin true in the body, a 201 response, then the login call and two flag fragments read from gated pages
The whole escalation in four browser-console calls. Register with the extra field, log in, read the two gated pages, concatenate the three fragments.

Bring Your Own Keys: a JWT verifier that trusts its own token#

Vantage Cloud's custom JWT auth layer issued RS256 tokens whose header carried a jku (JWK Set URL) claim telling the verifier where to fetch the public key that should validate that same token. Route enumeration, using 404-versus-405 behavior to tell real routes from decoys, found /api/signup, /api/login, and a protected /admin/flag route requiring role: admin. A real signup call showed the token's jku pointing at an internal-only address, with no allow-list on which host the verifier would fetch a JWKS from — the classic jku header-injection class of bug, CWE-347.

Try to recover the server's real signing key

The server's actual JWKS lives at an internal-only address (http://localhost:8080/.well-known/jwks.json), visible in a legitimate token's header but not reachable from outside. There is no key-disclosure bug to exploit here — attacking the real key directly is a dead end by design.

Look for a role field that can be tampered with client-side

A legitimate role: user token is correctly rejected by /admin/flag (403), and the server verifies the signature properly against its real key — simple payload tampering without a valid signature fails immediately. The gate itself is not broken; the trust decision feeding it is.

Host your own JWKS and point jku at it

Since the verifier fetches whichever URL the token's own jku header names, hosting an attacker-controlled JWKS and pointing a forged token at it makes the server fetch, and correctly verify against, a key the attacker generated. The signature check passes — truthfully — against the wrong key entirely.

The server verifies the forged token against the attacker's own key, correctly. That's the bug: the verifier never checks whether the jku host is one it should trust in the first place.

The server fetches the attacker's key exactly as instructed by the token's own header, verifies the attacker's signature against it, finds it valid — trivially, since it's the attacker's own key — and treats the forged role: admin claim as trustworthy. /admin/flag returns the flag.

Terminal evidence: a decoded JWT header with an internal jku URL, a 403 on the admin route with a real user token, then an attacker-hosted JWKS document with its own RSA key
Three steps of recon before the forge. The real token's jku, the confirmed role gate, and the attacker's own hosted JWKS document.
CSEMA platform page for Bring Your Own Keys marked SOLVED, Medium, 200 points, 9 solves, with the Vantage Cloud challenge text about a custom JWT layer and an avatar from URL feature
Bring Your Own Keys on the platform. The prompt mentions tokens carrying their own pointer to the verifying key, plus an "avatar from URL" feature; 9 solves when this was captured.

Phantom Wallet: emulating an ARM64 JNI library instead of reversing it by hand#

Phantom Wallet was an Android cryptocurrency wallet app — login, wallet, transaction history, security center, recovery screens — backed by a custom authentication protocol talking to a live server. The APK was decompiled with jadx. Despite R8/ProGuard minification collapsing most class names to one or two letters, the crypto and protocol classes were findable by tracing outward from NativeBridge.java, the one class whose name survives obfuscation, since native method names can't be renamed.

The app's native library, libphantomcore.so (arm64-v8a), registers its five JNI methods dynamically through RegisterNatives rather than exporting Java_... symbols, so they had to be located by resolving the JNINativeMethod[] array through .rela.dyn relocation entries. The one method that matters for authentication, nativeDeriveMaterial, disassembles to a hand-rolled 8-round Feistel network.

Hand-transcribe the disassembly into Python

An 8-round Feistel network is short enough to read, and in principle every ARM64 instruction maps to an equivalent Python line. In practice this is exactly the shape of function that's easiest to get subtly wrong — one swapped register, one misread rotate amount, one round in the wrong order — and a Feistel network's own symmetry means a transcription bug can still produce output that looks plausible without being correct.

Reimplement the algorithm from a partial read

Understanding the overall shape (8 rounds, XOR and rotate, a fixed round-key schedule) without reproducing every instruction exactly can get close, but "close" fails a protocol that checks the derived material against a live server response. Any deviation is invisible until the server rejects the request.

Run the real native code in a CPU emulator

A Unicorn-based ARM64 emulation harness maps the library's loadable segments into a real CPU emulator, applies the needed relocations, and stubs the small set of externs the function touches — then runs the actual compiled instructions against arbitrary inputs. There's no translation step to get wrong, because nothing is translated.

The native code runs unmodified. Every external symbol the function touches (operator new, memcpy, pthread_once, JNI array accessors) is stubbed with a Python callback; everything else executes as real ARM64 instructions.

With the native key-derivation function running for real, the full protocol — challenge fetch, session registration, HMAC-signed requests, encrypted wallet and transaction fragments, HOTP-style two-factor, final recovery-unlock proof, each app screen mapping to one protocol step — was reproduced end to end in Python against the live backend, and the server handed back the flag directly.

CSEMA platform showing the Phantom Wallet challenge page: Android Reversing, Hard, 300 points, 1 solve
Phantom Wallet's challenge page. One solve on the board at the time — the Unicorn harness was the only path that didn't risk a transcription bug.
CSEMA platform page for Phantom Wallet marked SOLVED, Hard, 300 points, 9 solves
Phantom Wallet marked solved. 300 points, 9 solves by the time of this capture.

Ghosts in the Smart Office: a rogue MQTT client hiding in plain telemetry#

This one came as two forensic artifacts: an MQTT traffic capture and the Mosquitto broker's own log, with the story that cameras rebooted, doors unlocked and alarms disarmed unexpectedly. No Wireshark or tshark was available, so I parsed the pcap directly with scapy's rdpcap and a hand-rolled MQTT decoder that reassembles TCP payloads per direction and walks them as an MQTT byte stream — fixed header, varint remaining length, then CONNECT/PUBLISH/SUBSCRIBE/CONNACK payloads.

Trust each client's self-reported identity

Read from the pcap alone, a client that calls itself rogue_client looks like exactly what it says — nothing hides there. Taking the MQTT CONNECT packet's client id at face value treats the wire-level claim as the ground truth, which is precisely what a broker that doesn't cross-check its own logs would also do.

Flag it by traffic shape alone

The fifth conversation is short — eight packets against the other four's steady telemetry — which is a real anomaly signal, but shape alone doesn't prove impersonation, and a legitimate but less chatty device could look the same. It narrows the search without confirming the actual vulnerability.

Cross-reference the broker's own log

The broker's log records the same TCP conversation — identical source IP:port and timestamp — under a client id that does not match what that client declared on the wire. That mismatch between what a device claims and what the trusted broker recorded is the actual tell: a rogue device impersonating a trusted gateway, confirmed independently rather than inferred from traffic volume.

Text
def decode_remaining_length(data, idx):
    multiplier, value = 1, 0
    while True:
        b = data[idx]; idx += 1
        value += (b & 0x7F) * multiplier
        if (b & 0x80) == 0: break
        multiplier *= 128
    return value, idx
The rogue client hid its payload inside what looked like diagnostics. Four "diagnostic" publishes carried a one-byte XOR key that incremented per chunk; decoding and concatenating them in sequence order spelled out the flag.

Reconstructing the timeline from that rogue stream showed unauthorized control-topic publishes — a camera reboot, a door-lock unlock, an alarm disarm — interleaved with four "diagnostic" publishes of the form SEQ:n|XKEY:0x..|ENC:<hex>, with the XKEY byte incrementing by one per chunk.

Terminal output showing the rogue MQTT client's control-topic publishes and four diagnostic chunks, each XOR-decoded and base64-decoded, concatenating to the flag
The full decode, start to finish. Four control-topic abuses sit next to four diagnostic chunks that reassemble into one flag.

The other eight solves#

The five deep dives above are the ones with the most moving parts. The other eight challenges in my write-up are just as much part of the 3,400 points, and several of them taught me more per hour than the big ones did. I have grouped them by category: Crypto, Web, Steganography, Network and audio forensics, Hardware, and finally the four-stage Reverse Engineering chain. Each follows the same pattern: what was handed over, the obvious approach, and the one that worked.

Silent Pairing: a "random" nonce that was just the clock#

Crypto, 100 points, Easy. A smart hub's local BLE controller had logged 24 hours of pairing traffic in a custom JSON format, hub_capture.json, while pairing several bulbs and pushing an OTA update to one of them. The OTA push to that one device was suspected of leaking the firmware-signing manifest, and the job was to recover it.

The capture is a flat packets array mixing four packet types: ADV, PAIR_INIT, PAIR_ACK and OTA_CHUNK, all routed through one hub. Five devices complete a full pairing handshake and three receive an OTA chunk. Only one device, D5:21:A8:29:00:16, appears in both sets, which matches the prompt's wording ("pushed an OTA update to one of the devices").

Attack all three OTA recipients

Three chunks look like three targets, so the instinct is to try to derive keys for each. The other two OTA recipients have no pairing exchange anywhere in the capture, so there is no key material to derive for them at all. They are decoys, and time spent on them is wasted.

Intersect the sets, then inspect the one nonce

Intersecting "paired" with "got OTA" leaves exactly one device. Its PAIR_INIT nonce is identical to the packet's own Unix timestamp, while every other device's pairing nonce is an unrelated random-looking 32-bit value. This device's "random" nonce is just time.time(), fully predictable without ever intercepting it over the air.

The key was never secret. Because the nonce equals the pairing timestamp, an eavesdropper can rebuild the session key from the clock alone.

Finding the key-derivation construction was a brute-force search over combinations of (nonce, ack_nonce, MAC bytes), hashed with MD5, SHA1 or SHA256, against CBC, CTR, CFB, OFB and ECB, filtering for valid PKCS#7 padding. The winner was key = MD5(str(nonce)), AES-128-CBC, with the packet's own IV. Decrypting the device's OTA chunk and reading the plaintext in reverse byte order gave a readable message. The ack_nonce field turned out to be an unused decoy.

Terminal output of the Silent Pairing solver: target device, pairing nonce equal to its PAIR_INIT timestamp, unused ack_nonce, derived AES key, valid PKCS7 padding and the reversed decrypted manifest
The solver's output. The pairing nonce is flagged as equal to its own timestamp, ack_nonce is marked as an unused decoy, and the decrypted manifest only reads correctly once reversed.

U - Image Chain: five visual ciphers, and the answers were in the page source#

Crypto, 450 points, Hard. The prompt read: "The challenge is designed to test your knowledge of chaining. Decode it:" followed by an obfuscated short link. This was my highest-scoring single challenge, and it did not need a single visual-cipher alphabet to be cracked by hand.

The obfuscated string is Atbash-ciphered, an a-to-z mirror substitution. The catch is that the entire string has to be decoded, including the path segment; decoding only the domain led to a dead "Dynamic Link Not Found" short link. Fully decoded, it resolves to a live Google Form. The form is a five-part chain. Each part shows an image encoding a short phrase through a different visual cipher (pastel color-block swatches, a Hexahue-style tile grid, pictographic glyphs, and two runic or glyph alphabets), and Google Forms' built-in exact-match response validation gates advancement to the next part.

Reverse-engineer all five picture alphabets by hand

Five bespoke visual ciphers means five alphabets to identify, transcribe and decode, with a wrong guess only discovered when the form rejects it. That is a lot of effort to spend on gates whose answers are already on the client.

Read the validation constants from the page itself

The validation constants, and the flag itself on the final page, ship directly to the client inside Google Forms' public window.FB_PUBLIC_LOAD_DATA_ JavaScript object, readable from the browser console. I then entered all five recovered answers through the actual form UI as well, walking the chain end to end to confirm the form fully submits.

Client-side validation is not secrecy. If the browser can check the answer, the browser has the answer.

The form's internal validation and flag reveal use the legacy UNI6CTF{} wrapper. Per this event's rule, the inner content was carried over byte-identical and re-wrapped as TRIVARNA{}.

Part 1 of the Image Chain Google Form: a row of eleven pastel color-block swatches above a Decode the Plain Text answer box; the signed-in account line is blacked out
Part 1 of the form chain. The pastel color-block cipher. The signed-in account line was on the original screenshot and is blacked out here.
Final page of the Image Chain form, titled Your Flag is Here, showing a handwriting-style image of the flag and an answer box; the signed-in account line is blacked out
The last page of the chain. The flag is shown as a distorted handwriting image under the legacy UNI6CTF{} format instruction. Account line blacked out.
CSEMA platform page for U - Image Chain marked SOLVED, 450 points, 23 solves
Confirmed on the scoreboard. U - Image Chain marked solved at 450 points, with 23 solves when this was captured.

Nightly Audit: one filter bypass, one missing bounds check#

Web, 200 points, Medium. Meridian Retail's profile system strictly filters user-submitted display names against a blocklist of SQL metacharacters (' - ; #). The prompt mentioned international full-width input normalization and a nightly content-moderation job, and flagged the checkout flow as having "its own problems."

Chase the SQL-injection story only

The narrative points loudly at the filter. The first bug I investigated was a Unicode compatibility-normalization (NFKC) bypass: full-width look-alike code points for ', -, ; and # (for example U+FF07 FULLWIDTH APOSTROPHE) are not in the literal-byte blocklist, so they pass submission, but the server later runs NFKC normalization on the stored value and folds them back into real ASCII metacharacters. I confirmed this byte-for-byte via a hex dump of the server's own response, not a terminal rendering artifact. It is exactly the "normalize-after-validate" bug the narrative describes, but the specific internal endpoint that triggers the nightly job on demand could not be located despite extensive black-box enumeration, so that leg is reported as unverified rather than assumed.

Test the checkout arithmetic as well

The second bug alone was sufficient. POST /api/checkout accepts a quantity field and debits quantity x unit_price_cents with no lower-bound check. A large negative quantity turns the debit into a massive credit, and a single call from a freshly registered, unprivileged account pushed the balance past the $100,000 gate on /api/flag. I reproduced it twice, from two independently registered accounts, getting the identical flag both times.

The flashy bug was not the one that paid out. The NFKC bypass is real and confirmed; the unvalidated quantity was the path to the flag.
Nightly Audit exploit evidence, step 1: a POST to the profile endpoint with full-width U+FF07 bytes returning a normalized display name containing ordinary apostrophes, then the start of the checkout step
The normalization bypass, byte for byte. The hex shows the full-width apostrophe going in; the response shows ordinary ASCII coming out. A literal apostrophe is rejected.
Nightly Audit exploit evidence, step 2: checkout with quantity minus 1,000,000 returns a balance of 499,999,500 cents, then the flag endpoint returns 200
The negative-quantity credit. The balance jumps to 499,999,500 cents, the gate on /api/flag clears, and the flag comes back.

Echoes of Freedom: three audio files, three encodings, one password#

Steganography, 400 points, Hard. An archive of audio files that look ordinary, each hiding a piece of a larger puzzle. The job was to analyze the audio, recover a password to unlock a protected archive, and then inspect what was inside.

exiftool recon on the three provided Part{1,2,3}.wav files revealed a custom RIFF Comment field written by a tool called AudioCipher, which named each file's encoding mode up front. That turned one big puzzle into three small ones.

  • Part1.wav, Morse code. Envelope detection (rectify and smooth, threshold at 15% of peak) recovered dot, dash and gap timings consistent with 15 WPM Morse, decoding to INDIA.
  • Part2.wav, DTMF. FFT peak detection across the standard DTMF grid on 56 tone segments gave a 56-digit raw button sequence. The trick was that this string itself tiles perfectly into 8 chunks of a 4-digit column frequency plus a 3-digit row frequency, each re-decoding to one DTMF button, giving 19472026, India's independence year plus the CTF's year.
  • Part3.wav, visual spectrogram. A high-resolution spectrogram (grayscale, contrast-clipped) spells the word Freedom in the time/frequency plane.
Decode only what the first pass gives you

The Part2 raw digit string is not a phone number or a password; read as a single DTMF sequence it is a dead end. It only makes sense once you notice it is itself an encoding of button presses, in groups of seven digits.

Treat each file as its own decoder, then concatenate in filename order

Decoding every part with the method its metadata named gives INDIA, 19472026 and Freedom. Concatenated in filename order that is the ZIP password INDIA19472026Freedom, which immediately unlocked challenge.zip.

The metadata told me the plan. The AudioCipher comment on each file named its encoding before any signal processing started.

Inside the ZIP were audio.mp3 and backup.txt. The latter hinted at a metadata field visible in Windows' built-in file-properties UI but hidden from exiftool by default. Re-running exiftool with -ee ("extract embedded") revealed a WM/Mood ID3 frame containing ROT13-encoded, leetspeak-obfuscated text; decoding it gave the final flag content. I also ran a stereo channel-difference analysis on audio.mp3 to rule out a hidden left/right channel.

Spectrogram of Part3.wav with the word Freedom drawn in the time and frequency plane
Part3.wav, seen as an image. The third password fragment is drawn in the spectrogram rather than spoken or beeped.
Grayscale spectrogram of the left-minus-right side channel of audio.mp3, showing ordinary speech-like energy bands and no hidden pattern
Ruling out a hidden stereo channel. The left-minus-right difference of audio.mp3 shows nothing but ordinary signal energy, so the real payload had to be the metadata frame instead.

Carved Secret: a ZIP behind the PNG, a known-plaintext crack, and an alpha channel#

Steganography, 150 points, Medium. A PNG that opens normally in any viewer, described as hiding a password-protected ZIP appended after the PNG's IEND chunk: a classic file-carving target.

Walking the chunk structure of panel_export.png confirmed a valid PNG ending in IEND at offset 111282, with 14,825 trailing bytes beginning with the ZIP local-file-header magic PK\x03\x04. Carving that trailer out produced carved.zip, containing one entry, inner_capture.png, encrypted with legacy ZipCrypto and stored uncompressed.

Guess the password

A short thematic password wordlist failed. Nothing in the challenge suggested the password was guessable, so more guessing was the wrong direction.

Known-plaintext attack on PKZIP's stream cipher

Because the entry is stored, not deflated, and PNGs always start with the same fixed 16-byte signature plus IHDR header, the known-plaintext requirement for Biham and Kocher's attack on PKZIP's stream cipher is satisfied for free. Running bkcrack with those 16 known bytes recovered the internal cipher-state keys in about three minutes, without ever needing the literal password, and those keys stripped the encryption.

No password needed. A fixed file header and an uncompressed entry were enough to attack the cipher directly.

The extracted 64x64 RGBA image looks like plain color noise, but per-channel statistics show the alpha channel only ever takes the values 254 or 255 (normally a constant 255 for an opaque image), a clear tell of an LSB-carried bitstream. Extracting bit 0 of the alpha channel for all 4,096 pixels, row-major, MSB-first, decoded cleanly to the flag.

The recovered inner_capture.png, a 64 by 64 square of random-looking multicolored noise
inner_capture.png after the known-plaintext crack, enlarged 8x. It looks like plain color noise; the data is in the alpha channel's least significant bit, not the colors.
CSEMA platform page for Carved Secret marked SOLVED, 150 points, 4 solves
Carved Secret confirmed on the scoreboard. Solved at 150 points, with only 4 solves when this was captured.

The Silent VoIP Call: decoding touch-tones and a hidden pure-tone alphabet#

Web / Forensics, 200 points, Medium. The SOC detected a short, unusual VoIP call from an internal extension (101) to an external gateway (902) shortly before a simulated breach. I was given sip.log (the Asterisk PBX log) and voip_capture.pcap, and had to work out what happened during the call.

Neither Wireshark nor tshark nor ffmpeg was available, so I parsed the pcap with scapy and decoded the RTP audio entirely in Python with a hand-rolled G.711 mu-law decoder. Of 700 UDP packets, 7 are SIP signaling for a single call (101 to 902). The SDP negotiation accepts only PCMU audio, not RFC2833 DTMF events, so any touch-tones must travel as in-band audio. sip.log independently records six DTMF digits detected during the call: 801775.

Decode everything as DTMF

The first 6 tone bursts do fit the DTMF grid, but the stream has 42 bursts. The remaining 36 do not fit the DTMF grid at all, so a DTMF-only decoder either drops them or produces garbage.

Validate on the log, then measure the odd tones

The remaining 693 RTP packets (one continuous stream, zero sequence gaps) were reassembled, mu-law decoded to 16-bit PCM and written to a WAV file. An energy-envelope scan found 42 discrete tone bursts. A Goertzel filter bank over the standard DTMF grid decoded the first 6 to 801775, an exact match to the Asterisk log, confirming the pipeline. For the other 36, a high-resolution FFT showed each is a single pure tone. Testing the hypothesis frequency = 400 + ascii_code x 10 against all 36 measured frequencies produced an exact integer ASCII code for every one (36 for 36, zero rounding error).

The log was the ground truth. The six digits in sip.log proved the decoder right before I trusted it on the 36 unknown tones.
Spectrogram of the recovered call audio, showing 42 discrete tone bursts: six DTMF-style two-tone bursts, then single pure tones
The recovered call, 42 bursts. The first six are two-tone DTMF; the remaining 36 are single pure tones, one per character.

Sensor Fusion: a covert channel in the tenths digit#

Hardware, 100 points, Easy. The bundle held three files: device_config.json, registry.sqlite and telemetry.csv, covering 24 devices (1000 to 1023) with 320 readings each at a 300 second interval. The title said it all: correlate the three files.

Look for odd magnitudes

Comparing device 1021's temp_humidity readings to other devices such as 1001 and 1004, the raw values look statistically unremarkable. The covert channel is not in how large the readings are.

Correlate the registry, config and telemetry

registry.sqlite has a second table beyond the obvious devices table: carriers(id, device_id, role), listing four devices with special roles. Only one, 1021, is flagged covert_carrier. device_config.json is for device_id: 1021 too, the only config included in the leak. Then, for each of that device's 320 telemetry rows, take the tenths digit of humidity_pct and its parity: odd is bit 1, even is bit 0.

Three files, each necessary. The registry names the device, the config confirms it, and only the telemetry carries the payload.

Packing the 320 bits MSB-first into bytes gives 40 bytes: 36 bytes of clean printable ASCII (a well-formed, braced leetspeak phrase) followed by 4 bytes of leftover noise, since the message does not fill the whole 320-bit capacity. One quirk worth noting: the wrapper embedded in the telemetry was CSEMA{...}, not TRIVARNA{...}, so I reported it as found rather than force-fitting it into a different wrapper. The device's sensor_flags bit pattern (10110101) reads as a flavor-text hint of tampering but is not needed to decode the payload.

Terminal output of the covert-channel extractor: 320 telemetry rows for device 1021, raw bytes and the decoded printable prefix
The extractor's output. 320 rows in, 40 bytes out, with the printable prefix followed by the four noise bytes.

U - Freedom Chain: four VM stages, each keyed by the one before#

Reverse Engineering, 400 points, Hard. The prompt: "The full Freedom Chain archive has surfaced: four sealed artifacts recovered from the Independence Day parade route, the Red Fort, and the Ashoka seal that binds them all together." I was given four numbered parts, each a small custom-VM "flag checker" in Python plus its encrypted data blob.

Each part is a hand-rolled bytecode VM or tape validator, encrypted with a SHA-256-derived XOR keystream keyed by a secret that only becomes known after solving the previous part: a genuine four-stage chain, matching the "Ashoka seal that binds them together" flavor text. Two more files, strings.txt and flags.txt, are decoys containing plausible-looking flags explicitly labeled as recovered "from the wrong gate"; neither is used anywhere in the real solve path.

Trust the helper's name and the decoy flags

Part 2 defines a ror() helper, but the transform actually performs an inline left rotate. Inverting a right rotate would produce plausible-looking but wrong bytes. The decoy flag files are the same trap at chain level.

Brute-force each stage

It is tempting to brute-force a validator, but Part 1 is directly invertible per byte, and Part 3 is invertible because every multiplier used is odd. Brute force would only add time where algebra gives the answer.

Invert each stage, passing the secret to the next

Each stage's output feeds the next stage's key material, so the order is forced. Part 1 recovers FLAG1 plus a route_note; Part 2 decrypts with sha256(route_note) and recovers FLAG2 plus a next_marker; Part 3 recovers FLAG3; Part 4 decrypts using sha256("FLAG1|FLAG2|FLAG3").

No stage can be skipped. Each secret exists only after the previous part is solved, so the chain has to be solved in order.

Part 3 (redfort_vm.py) is a straight-line bytecode program with no branches, with per-byte and pairwise constraints. Part 4 (ashoka_vm.py) is the reverse-engineering-heavy piece. Disassembling its 357 bytes of bytecode reveals real control flow: an unconditional jump skips a dead decoy block of constraints, then two parity-conditioned branches gate the winning path (flag byte 0 must be odd, flag byte 23 must be even). Both "wrong turn" branches run off the end of the program and fail by construction. Solving every constraint on the winning path fully determines all 35 flag bytes, and the derived bytes independently satisfy the parity conditions that were assumed to reach that path, confirming the correct branch was traced. All four stages were then re-verified by executing the organizers' own validator scripts with the derived arguments; each printed correct.

CSEMA submission page for U - Freedom Chain, 400 points, with the final flag typed into the submit box
The flag entered on the platform. The challenge text mentions the four sealed artifacts; the page shows 39 solves at this point.
CSEMA page for U - Freedom Chain marked SOLVED, 400 points, 45 solves
Freedom Chain confirmed solved. 400 points, 45 solves when this was captured.

What I'd do differently#

The brute-force-first instinct cost time on at least two of the thirteen: Same k, Twice took roughly 250 candidate key derivations before landing on the right one, and a tighter read of the challenge's own hints would have narrowed that faster. Ghosts in the Smart Office would have gone faster too if I'd cross-referenced the broker log against the pcap from the first pass instead of only reaching for the second artifact once the first read stalled.

On the reversing side, the Unicorn harness for Phantom Wallet paid for itself immediately — slower to set up than hand-translating a short function, but immune to the kind of transcription bug a Feistel network is exactly the wrong shape to debug by eye. I'd reach for an emulator earlier next time rather than attempting the hand translation first.

Test yourself#

Two ECDSA signatures over different messages share the same r value. What does this most directly reveal?

  1. The private key was weak
  2. The same nonce k was used for both signatures
  3. The curve parameters were invalid
  4. The messages were identical
Since r = (kG)ₓ mod n depends only on the nonce, identical r values across different messages mean the same nonce was reused — which is exactly what lets the private key be solved algebraically from the two signature equations.

Why hardcode the real secp256k1 constants instead of trusting the challenge file's own curve parameters?

  1. The file's parameters were in the wrong byte order
  2. Hardcoded constants are faster to compute with
  3. A file that controls both the curve and a "proof" built on it could use an invalid or malicious curve
  4. Python's built-in curve library requires named constants
The challenge explicitly warned to verify the generator point is on the curve before trusting it — a file supplying its own curve parameters alongside the data it wants verified against that curve is exactly the setup an invalid-curve attack needs.

In the Digital Sentry challenge, what made the privilege escalation possible?

  1. The registration endpoint wrote back whatever JSON fields the client sent, with no server-side filtering
  2. The admin password was stored in plaintext in a cookie
  3. SQL injection in the login form
  4. A default admin account with no password
Mass assignment: the endpoint's documented contract was email and password, but it actually persisted any field in the request body, including a client-supplied is_admin: true.

What made the jku header a forgeable trust anchor?

  1. RS256 is a weak signing algorithm
  2. The server's real signing key was exposed publicly
  3. JWTs cannot be signature-verified at all
  4. The verifier fetched the signing key from a URL supplied inside the token it was verifying, with no allow-list on that host
A JWT can carry a legitimate signature and still be untrustworthy if the verifier lets the token itself decide which key counts as authoritative — hosting your own JWKS and pointing jku at it makes any forged claim pass real signature verification.

Why run libphantomcore.so's Feistel function in a CPU emulator rather than hand-translating the disassembly?

  1. Emulation is always faster to set up than reading disassembly
  2. Unicorn can recover stripped symbol names automatically
  3. Running the real compiled instructions removes the risk of a silent transcription error in a hand port
  4. JNI methods cannot be disassembled at all
An 8-round Feistel network's symmetry makes a mistranslated round or register easy to miss by eye; executing the actual machine code sidesteps that entire class of bug because nothing is being re-derived by hand.

What specifically identified the rogue MQTT client in Ghosts in the Smart Office?

  1. It connected from an IP address outside the local subnet
  2. Its self-declared client id in the CONNECT packet did not match the client id the broker's own log recorded for that same connection
  3. It published to a topic no other device used
  4. Its packets were unencrypted
The pcap alone shows a client calling itself rogue_client; cross-referencing the broker's independent log for the identical IP:port and timestamp shows a different client id recorded there — the mismatch, not the traffic pattern, is what proves impersonation.

In Carved Secret, why could bkcrack recover the ZIP's internal keys without the password?

  1. The password was in the wordlist after all
  2. ZipCrypto has no encryption at all
  3. The entry was stored uncompressed and a PNG always starts with the same 16 known bytes, satisfying the known-plaintext attack
  4. The PNG's alpha channel contained the password
Biham and Kocher's attack on PKZIP's stream cipher needs known plaintext. A stored (not deflated) PNG supplies its fixed signature and IHDR header for free, so the cipher state could be recovered in about three minutes.

In The Silent VoIP Call, what proved the audio-decoding pipeline was correct before the 36 unknown tones were trusted?

  1. The first 6 DTMF bursts decoded to 801775, exactly matching the digits recorded in the PBX's sip.log
  2. The WAV file played back as speech
  3. All 700 packets were RTP
  4. The call lasted exactly 42 seconds
The log was an independent source of truth. An exact match on six digits showed that reassembly and mu-law decoding were right, which is why the 400 + ascii x 10 Hz hypothesis could be tested with confidence (36 of 36 exact).

In Silent Pairing, what made the AES key for one device's OTA update recoverable?

  1. The OTA payload was not encrypted
  2. The ack_nonce was reused across devices
  3. The hub used a weak AES mode
  4. That device's pairing nonce was identical to its own packet timestamp, so the key MD5(str(nonce)) was predictable
A "random" nonce that equals time.time() is fully predictable. The other two OTA recipients had no pairing handshake in the capture, so only this device was a real target; ack_nonce was a decoy.

In Freedom Chain's Part 2, why did re-reading the arithmetic matter more than trusting the ror() helper's name?

  1. The helper was never defined
  2. The transform actually performs an inline left rotate, so the inverse has to rotate right
  3. Part 2 had no encryption
  4. The helper rotated by 8 bits
A function named for one operation sat next to a transform doing the opposite. Inverting by name would have produced plausible but wrong bytes and a wrong key for the next stage.