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.
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.
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.
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.
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.
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.
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.
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.
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.
jku, the confirmed role gate, and the attacker's own hosted JWKS document.
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.
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.
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.
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, idxReconstructing 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.
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.
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.
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.
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{}.
UNI6CTF{} format instruction. Account line blacked out.
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.
/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
Freedomin 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.
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.
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.
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 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).
sip.log proved the decoder right before I trusted it on the 36 unknown tones.
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.
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.
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").
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.
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?
- The private key was weak
- The same nonce
kwas used for both signatures - The curve parameters were invalid
- The messages were identical
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?
- The file's parameters were in the wrong byte order
- Hardcoded constants are faster to compute with
- A file that controls both the curve and a "proof" built on it could use an invalid or malicious curve
- Python's built-in curve library requires named constants
In the Digital Sentry challenge, what made the privilege escalation possible?
- The registration endpoint wrote back whatever JSON fields the client sent, with no server-side filtering
- The admin password was stored in plaintext in a cookie
- SQL injection in the login form
- A default admin account with no password
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?
- RS256 is a weak signing algorithm
- The server's real signing key was exposed publicly
- JWTs cannot be signature-verified at all
- The verifier fetched the signing key from a URL supplied inside the token it was verifying, with no allow-list on that host
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?
- Emulation is always faster to set up than reading disassembly
- Unicorn can recover stripped symbol names automatically
- Running the real compiled instructions removes the risk of a silent transcription error in a hand port
- JNI methods cannot be disassembled at all
What specifically identified the rogue MQTT client in Ghosts in the Smart Office?
- It connected from an IP address outside the local subnet
- 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
- It published to a topic no other device used
- Its packets were unencrypted
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?
- The password was in the wordlist after all
- ZipCrypto has no encryption at all
- The entry was stored uncompressed and a PNG always starts with the same 16 known bytes, satisfying the known-plaintext attack
- The PNG's alpha channel contained the password
In The Silent VoIP Call, what proved the audio-decoding pipeline was correct before the 36 unknown tones were trusted?
- The first 6 DTMF bursts decoded to
801775, exactly matching the digits recorded in the PBX'ssip.log - The WAV file played back as speech
- All 700 packets were RTP
- The call lasted exactly 42 seconds
In Silent Pairing, what made the AES key for one device's OTA update recoverable?
- The OTA payload was not encrypted
- The
ack_noncewas reused across devices - The hub used a weak AES mode
- That device's pairing nonce was identical to its own packet timestamp, so the key
MD5(str(nonce))was predictable
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?
- The helper was never defined
- The transform actually performs an inline left rotate, so the inverse has to rotate right
- Part 2 had no encryption
- The helper rotated by 8 bits
