ForensicsHidden Investigations CTF 2026
The Quiet Divide
Dissect a 152 MB pcap spanning ~12 services, rebuild a TLS keylog from XOR escrow shares, hand-decrypt QUIC v1, then brute-force the correct transaction across three SQLite archives to open an AES-GCM seal.
What this one teaches
- Implementing QUIC v1 decryption from scratch when Wireshark is unavailable — header protection removal, packet-number reconstruction, and AEAD with the unprotected header as AAD.
- Using AES-GCM authentication failure as an oracle: when you cannot determine the correct input analytically, try all candidates and let the tag tell you which one is right.
- Checking that a brute-force search actually covers the full candidate space — a reused SQLite cursor silently replaced the outer result set, making the search skip most transactions.
Task description
Asterion Dynamics finished an overnight maintenance window with an uncomfortable problem: its systems no longer agreed on what had actually happened. A single packet capture was preserved before the environment was taken offline.
Find out what the traffic can prove, and recover the artifact left behind.
| CTF | hiddeninvestigations |
| Category | Forensics (network) |
| Points | 80 |
| Flag | hiCTF{c8c46fdbd6e5e4eacabfba53a156ff9ab10567f2} (confirmed by the platform) |
Asterion Dynamics finished an overnight maintenance window with an uncomfortable problem: its systems no longer agreed on what had actually happened. A single packet capture was preserved before the environment was taken offline. Find out what the traffic can prove, and recover the artifact left behind.
Evidence: files/the-quiet-divide/the-quiet-divide.pcap, about 152 MB, classic libpcap with Ethernet link type.
TL;DR
The capture records a full “record vault” pipeline. The archive service holds three encrypted SQLite archives (AVLT10). A ledger service publishes a reference SDK, a signed custody chain and an encrypted final seal. There are two parallel deployments: fleet-prod, which is real, and a dr-ops recovery branch, which is a decoy.
To open the seal you rebuild every evidence input from the traffic:
- the firmware ELF
- a UDP “Pulsewire” frame
- a signed attestation
- a QUIC observation record, which needs a TLS keylog rebuilt from XOR escrow shares
From those you derive the archive key and decrypt the archives. Then you pick the one transaction that matches every signed fact, unmask its fragments, and derive the seal key. AES-GCM authentication on the seal confirms the result.
Tooling
- No
tshark/Wireshark was available on the host, so everything is pure Python 3 withdpkt(pcap parsing plus hand-written TCP reassembly),cryptography(HKDF, AES-GCM, AES-ECB, Ed25519) andsqlite3. - QUIC was decrypted with a small hand-written QUIC v1 decryptor (header-protection removal, packet-number reconstruction, AEAD, frame parsing).
- All scripts are in
solve/. The pcap path is always passed as an argument, and the scripts run fromsolve/, never fromfiles/.
Triage: the archive service and the cover traffic
solve/triage.py builds the protocol hierarchy. About 108k of the 110k packets are TCP/8110 to 10.77.40.61. It is plain HTTP, Server: Asterion Archive Service/2.8, with the following endpoints:
GET /api/v2/statusPUT /api/v2/archive/<bucket>/<id>, which returns anarchive-write-receipt/2containing asha256GETof the same path
solve/reassemble8110.py and solve/carve.py reassemble every request and response and carve the bodies into solve/carved/. The readback hash matches the write receipt for every object, so the vault itself is consistent.
| Blob set | Writer | Size | Magic | Role |
|---|---|---|---|---|
ops-17/20261101T0718..-* ×3 |
.63 | 8,192,034 | AVLT2\0 |
cover traffic |
arch-19/20261101T0718..-* ×3 |
.62 | 8,208,418 | AVLT2\0 |
cover traffic |
ops-17/affbe6…, 22eaa7…, 5a2c8a… |
.68 | 4,861,987 | AVLT10\0 |
the artifact |
solve/diffgroups.py shows that blobs of the same length are completely different. They are independent high-entropy ciphertexts, not near-duplicates to diff. The three AVLT10 objects are also exactly the candidates in POST /v10/finalize-batch, whose archive_batch_sha256 is 672e46f7….
Mapping the other services
solve/http_other.py, solve/timeline.py, solve/dump_ledger.py and solve/find_streams.py reassemble everything outside port 8110. Keyword hits are saved to solve/kwstreams/ and ledger bodies to solve/ledger/.
| Host:port | Service | Relevant content |
|---|---|---|
| .30:8080 / .50:80 | Control plane, behind nginx | asset registry INC-3142: ARCH-14, ARCH-19, OPS-17 |
| .33:8083 | Asterion-Custody | mb-committee/1: Ed25519 keys C0–C4, threshold 3, chairs C0/C1, domain MIRRORBREAK/CUSTODY/B1\0 |
| .34:8084 | Device-Trust | enrollment (ARCH-14 key ec67d10c…), challenge nonce 12060f75…, /v2/attestations |
| .35:8085 | Distribution | signed mb-firmware/2 manifest and pulse-codec_7.7.0_amd64.elf |
| .36:9002 (UDP) | Pulsewire | 32-byte PLS2 frame from ARCH-14 |
| .37:4433 (UDP) | QUIC collector | encrypted observation (production) |
| .37:8088 | Collector status | mb-quic-status/3: signed_sha256 60369e7f… |
| .38/.39:8093 | Diagnostics escrow | /v3/escrow keylog shares |
| .66:8122 | Ledger | /v8/*, /v10/* |
| .69:8123 | Checkpoint | W0 witness delegation and frozen checkpoint |
Ledger endpoints (10.77.40.66:8122)
/v10/interopsaysarchive_wire_magic: AVLT10,archive_content: authenticated-sqlite, AES-256-GCM, HKDF-SHA256, Ed25519 and canonical JSON (sorted keys, compact, ASCII). It also lists the release check: “scope, signed freeze, deployed observation, durable receipt, transaction ancestry”./v10/sdkis the reference implementation, saved assolve/ledger/GET__v10_sdk.txt. It containscanonical,decode_pls2,evidence_components,archive_key,decrypt_archive,unmask_fragment,transaction_material_commit,final_key,seal_aadanddecrypt_seal. It deliberately leaves out which records to feed in (“no select_authoritative_record(), database SQL, winning archive label, trust root…”). Choosing those records is the puzzle./v8/policyis signed by C0. It setsproduction_domain: fleet-prodandrecovery_domain: dr-ops, along withrecovery_cannot_finalize_production,firmware_qualification_is_not_installation,quorum: 3,chair_requiredandreplication_conflict_rule: "independently witnessed terminal checkpoint"./v8/auditholds two things:-
The custody chain (
mb-custody/2, operationROL-7044):epoch phase event_sha256 signers 732 ROOT 8c3c3046…C0 C1 C2 733 PREP 4999e542…C0 C2 C3 733 COMMIT f61ce4fe…C1 C3 C4 734 PREP 9a5e1332…C0 C2 C4 734 COMMIT b34dc728…(terminal)C0 C1 C3 Each
event_sha256equalssha256(canonical(event)). Every signature verifies with Ed25519 overb"MIRRORBREAK/CUSTODY/B1\x00" + canonical(event)under the committee keys (solve/sigs.py). Each event has a quorum of 3 that includes a chair. -
The final seal (
mb-final-seal/10, signed by C0):ciphertext_b64,nonce_hex 0de2e11f0e0d47651a0c1b45,salt_hex ded3d12c…(for the final key),archive_salt_hex 6d562eb7d1e5ef0b993548337b8a2b9b,terminal_event_sha256 b34dc728…andcheckpoint_sha256 5e4f78ad…, plus the attestation, observation, firmware and batch hashes.
-
The divide: fleet-prod vs the recovery decoy
The capture contains two parallel stories with the same shape:
| fleet-prod (real) | dr-ops recovery (decoy) | |
|---|---|---|
| Device / host | ARCH-14 / 10.77.40.14 | 10.77.40.59 |
| Pulsewire | PLS2 → .36:9002 |
PLS3 → .56:9010 |
| QUIC | → .37:4433 | → .58:4434 |
| Escrow batch | 84e2f6dce89de72d37fe90f1 (keylog fbe54727…) |
fe3785612f9ddb7cd02844ec (keylog b52d5cda…) |
| Observation | 60369e7f… |
72b56f0b… |
The policy says recovery cannot finalize production, and conflicts are resolved by an independently witnessed terminal checkpoint. That checkpoint comes from 10.77.40.69:8123:
- a witness delegation (
asterion-witness-delegation/9, signed by C0) appoints W0 (public keyb5f6abad…) for scope fleet-prod - a release checkpoint (
asterion-release-checkpoint/9, signed by W0, signature1d8cf57f…) freezesterminal_event_sha256 = b34dc728…at position 734, phase COMMIT
sha256 of the full canonical checkpoint object {body, signature, signer} is 5e4f78ad…, which is exactly the seal’s checkpoint_sha256. That binds the seal to the fleet-prod chain and to W0’s signature. Everything from the recovery branch is a distractor.
Rebuilding the evidence inputs
The SDK’s evidence_components(custody_records, firmware, attestation, quic_observation, frame) returns:
chain = sha256(canonical(custody_records))
fw = HMAC-SHA256(chain, b"MB10/FULL/ELF\0" + firmware)
a, q = attestation["signature"], quic_observation["signature"] (raw 64 bytes each)
codec = sha256(b"MB10/CODEC/STATE\0" + canonical(decode_pls2(frame)) + frame)
evidence = chain || fw || a || q || codec (224 bytes)
4.1 Firmware ELF
ARCH-14 downloads /v2/dist/pulse-codec_7.7.0_amd64.elf from .35:8085 in four Range requests (0–4095, 4096–8191, 8192–12287, 12288–14527). solve/carve_elf.py puts them back together by Content-Range into solve/carved_elf/pulse-codec_7.7.0_amd64.elf. The result is 14,528 bytes with sha256 0835dfce…, which equals the signed manifest, the attestation and the seal.
4.2 Pulsewire frame
UDP 10.77.40.14 → 10.77.40.36:9002 carries one 32-byte datagram, 504c5332020e217b304cffe0a96d4fbed8d6fe172e5901e05a3ac5c3583af37d. It starts with the SDK magic PLS2\x02\x0e\x21\x7b and its CRC is valid. The reply ACK2/10da1fc0d74d0025 repeats the start of its sha256, 10da1fc0…, which is the attestation’s frame_sha256. decode_pls2 gives sequence = 10450910, scheduler_queue = 23, load = 71 and epoch_ms = 1793517529318, which matches the attestation’s epoch_ms.
4.3 Attestation
GET /v2/attestations on .34:8084 returns the mb-attestation/2 record from ARCH-14: codec_selftest: "PULSE-CODEC SELFTEST PASS", nonce 12060f75d51d36eb9db836f46f49f575 and signature 74fdca8b…. sha256 of its canonical body is b04946c7…, which is the seal’s attestation_sha256.
4.4 TLS keylog from the escrow shares
POST /v3/escrow (mb-diagnostic-share/3) sends one base64 share to each escrow host for batch 84e2f6…: slot A to .38 and slot B to .39. Each share is 760 bytes, which equals total_length. The scheme is 2-of-2 XOR (solve/keylog.py):
keylog = bytes(x ^ y for x, y in zip(b64decode(shareA), b64decode(shareB)))
# sha256 == fbe54727… == keylog_sha256
The result is a standard NSS keylog for client_random 62caade3…, with CLIENT_/SERVER_HANDSHAKE_TRAFFIC_SECRET and CLIENT_/SERVER_TRAFFIC_SECRET_0. The secrets are 48 bytes, so the suite is TLS_AES_256_GCM_SHA384. Output: solve/keylog_prod.txt.
4.5 QUIC decryption: the observation record
solve/quic_decrypt.py <pcap> keylog_prod.txt 10.77.40.14 10.77.40.37 4433 implements QUIC v1 for 13 datagrams:
- Derive
quic key(32 bytes),quic iv(12) andquic hp(32) with HKDF-Expand-Label over SHA-384. - Split coalesced long-header packets, skipping Initial packets.
- Remove header protection with an AES-ECB mask over the 16-byte sample at pn_offset+4.
- Rebuild the packet number, then AES-256-GCM decrypt with nonce = iv ⊕ pn and the unprotected header as AAD.
- Parse ACK, CRYPTO and STREAM frames and reassemble stream 0 in each direction.
Client stream 0:
{"body":{"device_id":"ARCH-14","epoch_ms":1793517535155,"firmware_sha256":"0835dfce…",
"load_pct":8,"queue_depth":31,"schema":"mb-quic-observation/3","sequence":848971,"shard":"IDX-S9"},
"signature":"798fa204c169eacd…b8a602","signer":"ARCH-14"}
The server answers with mb-quic-ack/3, signed_sha256 = 60369e7f…. That equals sha256 of the canonical full observation object, and it is the seal’s observation_sha256.
Archive key and SQLite decryption
The core solve script implements the SDK primitives (canonical JSON, PLS2 decoder, fragment unmasking, HKDF key derivation, seal decryption) and tries every transaction across all three archives, using the AES-GCM tag as an oracle:
import json, hashlib, hmac, struct, sqlite3, base64
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
MAGIC = b'AVLT10\x00'; MASK = (1 << 32) - 1
def canonical(obj):
return json.dumps(obj, sort_keys=True, separators=(',', ':'),
ensure_ascii=True, allow_nan=False).encode('ascii')
def digest(raw):
return hashlib.sha256(raw).digest()
def hex_digest(raw):
return hashlib.sha256(raw).hexdigest()
def hkdf(raw, salt_hex, context):
return HKDF(algorithm=hashes.SHA256(), length=32,
salt=bytes.fromhex(salt_hex), info=context).derive(raw)
def decode_pls2(raw):
def rot_r(x, n):
return ((x >> n) | (x << (32 - n))) & MASK
seq = rot_r(int.from_bytes(raw[8:12], 'big'), 7) ^ 0xC0FFEE21
queue = int.from_bytes(raw[20:22], 'big') ^ ((seq * 73) & 0xffff)
load = int.from_bytes(raw[22:24], 'big') ^ ((seq * 173 + queue * 7) & 0xffff)
epoch = ((int.from_bytes(raw[12:20], 'big') - ((seq << 13) & ((1 << 64) - 1)))
& ((1 << 64) - 1)) ^ 0xA96D4E0B7C2296F1
return {'sequence': seq, 'scheduler_queue': queue, 'load': load, 'epoch_ms': epoch}
def unmask_fragment(codec, tx_id, ordinal, encoded):
pad = hmac.new(codec, b'MB10/FRAGMENT\x00' + tx_id.encode('ascii')
+ struct.pack('>I', ordinal), hashlib.sha256).digest()
return bytes(a ^ b for a, b in zip(encoded, pad))
def transaction_material_commit(material, terminal_hex, tx_id, codec):
return hex_digest(material + bytes.fromhex(terminal_hex)
+ bytes.fromhex(tx_id) + codec)
def final_key(material, evidence_bytes, witness_signature_hex, salt_hex):
return hkdf(material + evidence_bytes + bytes.fromhex(witness_signature_hex),
salt_hex, b'MIRRORBREAK/10/PRODUCTION-CASE')
def seal_aad(body):
fields = ('schema', 'terminal_event_sha256', 'attestation_sha256',
'observation_sha256', 'firmware_sha256',
'archive_batch_sha256', 'checkpoint_sha256')
return canonical({k: body[k] for k in fields})
def decrypt_seal(cipher, key, nonce_hex, body):
return AESGCM(key).decrypt(bytes.fromhex(nonce_hex), cipher, seal_aad(body))
Only the full audit event objects ({event, event_sha256, signatures} ×5, in order) produce a key that passes the GCM tag:
key = HKDF-SHA256(evidence, salt=archive_salt_hex, info=b"MIRRORBREAK/10/RECORD-VAULT")
nonce = blob[7:19]
aad = canonical({"schema": "mb-corpus/10", "commit": "b34dc728…"})
AESGCM(key).decrypt(nonce, blob[19:], aad) # -> "SQLite format 3\0…"
All three AVLT10 blobs decrypt to distinct SQLite databases, saved as solve/archive_{affbe6,22eaa7,5a2c8a}.sqlite. Each has the same schema:
transfers(tx_id, case_commit, device_nonce, counter, origin, version, release_checkpoint), 7 rowsreceipts(tx_id, observed_state, disposition, fragment_commit)fragments(tx_id, ord, payload, digest), 4 × 8-byte fragments per txoperations(id, node, stamp_ns, event_code, opaque), 10,000 noise rows
Picking the authoritative transaction
Every release-check criterion becomes a SQL-level filter that can be checked against signed facts from the traffic (solve/diag.py, solve/diag2.py, solve/commitbrute.py):
| Criterion | Field | Must equal |
|---|---|---|
| signed freeze | case_commit |
terminal b34dc728… |
| witnessed checkpoint | release_checkpoint |
5e4f78ad… = sha256 of the W0 checkpoint |
| deployed observation / attestation | device_nonce |
attestation nonce 12060f75… |
| transaction ancestry | counter |
Pulsewire sequence 10450910 |
| durable receipt | receipts.disposition / observed_state |
accepted / codec c358a8d6… |
| integrity | fragment_commit |
transaction_material_commit(material, terminal, tx_id, codec) |
Rows that point at the W0 checkpoint hash:
| Archive | tx_id | device_nonce | counter | Verdict |
|---|---|---|---|---|
| affbe6 | ee247c15… |
78fc6086… |
10450910 | wrong nonce |
| 22eaa7 | 06817d30… |
12060f75… |
10451011 | wrong counter |
| 5a2c8a | 6dd9ec588c9914ca95df44b2 |
12060f75… |
10450910 | all criteria met |
Many decoy rows also pass the receipt commit check, so the commit alone does not identify the answer. Only the combination of all criteria does.
Fragment unmasking, final key, seal
material = b"".join(
unmask_fragment(codec, tx_id, ord, payload) # payload ^ HMAC(codec, b"MB10/FRAGMENT\0"+tx_id+be32(ord))
for ord, payload in fragments_of(tx_id)) # 4 x 8 = 32 bytes
key = HKDF-SHA256(material + evidence + bytes.fromhex(W0_checkpoint_signature),
salt=seal["salt_hex"], info=b"MIRRORBREAK/10/PRODUCTION-CASE")
AESGCM(key).decrypt(bytes.fromhex(seal["nonce_hex"]), b64decode(seal["ciphertext_b64"]),
canonical({k: seal[k] for k in ("schema","terminal_event_sha256","attestation_sha256",
"observation_sha256","firmware_sha256","archive_batch_sha256","checkpoint_sha256")}))
solve/brute.py uses the seal as an oracle. It tries all 21 transactions with raw and unmasked material, all cross-archive triples, and four candidate witness signatures (W0 checkpoint, C0 certificate, seal, policy). Exactly one combination authenticates: archive 5a2c8a, tx 6dd9ec588c9914ca95df44b2, unmasked material, W0 checkpoint signature. That is the same row selected by hand in section 6.
SEAL OK ('5a2c8a', '6dd9ec588c9914ca95df44b2', 'um') W0ckpt b'hiCTF{c8c46fdbd6e5e4eacabfba53a156ff9ab10567f2}'
Pitfalls and lessons
- SQLite cursor reuse. My first final-stage scripts iterated
for row in cur.execute(...)and calledcur.execute(...)on the same cursor inside the loop. The inner query replaced the outer result set, so only the first transaction of each archive was ever tried. No error was raised; the seal brute-force just reported “no match”. Fix:.fetchall()the outer query, or use a separate cursor. Lesson: when a search finds nothing, check that the search actually covered the whole candidate space before doubting the crypto. - Dictionary key mismatch in the QUIC decryptor. I looked up keys with
'c'/'s'while storing them under'client'/'server'. Every packet was silently skipped. Logging at each early-return path found it immediately. - The hashed object varies by record type. The observation and W0-checkpoint hashes cover the full
{body, signature, signer}object; the attestation hash covers onlybody;custody_recordsis the full event list. Check each assumption against a published hash before using it. - Most of the bytes are cover traffic. The 8 MB AVLT2 blobs, the recovery-branch QUIC and escrow batch, and the
operationstable are noise. The useful material is a few KB of JSON spread across about 12 services. python -Iand user site-packages.pipinstalleddpktto the user site, which-Iignores. I ran the scripts from thesolve/directory without-I, and only ever passed the pcap as a path argument.
The flagReveal
hiCTF{c8c46fdbd6e5e4eacabfba53a156ff9ab10567f2}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.