Neven Marić
Hidden Investigations CTF 2026

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.

  • forensics
  • pcap
  • network-analysis
  • quic
  • tls
  • aes-gcm
  • hkdf
  • ed25519
  • sqlite
  • brute-force

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 with dpkt (pcap parsing plus hand-written TCP reassembly), cryptography (HKDF, AES-GCM, AES-ECB, Ed25519) and sqlite3.
  • 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 from solve/, never from files/.

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/status
  • PUT /api/v2/archive/<bucket>/<id>, which returns an archive-write-receipt/2 containing a sha256
  • GET of 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/interop says archive_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/sdk is the reference implementation, saved as solve/ledger/GET__v10_sdk.txt. It contains canonical, decode_pls2, evidence_components, archive_key, decrypt_archive, unmask_fragment, transaction_material_commit, final_key, seal_aad and decrypt_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/policy is signed by C0. It sets production_domain: fleet-prod and recovery_domain: dr-ops, along with recovery_cannot_finalize_production, firmware_qualification_is_not_installation, quorum: 3, chair_required and replication_conflict_rule: "independently witnessed terminal checkpoint".
  • /v8/audit holds two things:
    • The custody chain (mb-custody/2, operation ROL-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_sha256 equals sha256(canonical(event)). Every signature verifies with Ed25519 over b"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… and checkpoint_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 key b5f6abad…) for scope fleet-prod
  • a release checkpoint (asterion-release-checkpoint/9, signed by W0, signature 1d8cf57f…) freezes terminal_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:

  1. Derive quic key (32 bytes), quic iv (12) and quic hp (32) with HKDF-Expand-Label over SHA-384.
  2. Split coalesced long-header packets, skipping Initial packets.
  3. Remove header protection with an AES-ECB mask over the 16-byte sample at pn_offset+4.
  4. Rebuild the packet number, then AES-256-GCM decrypt with nonce = iv ⊕ pn and the unprotected header as AAD.
  5. 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 rows
  • receipts(tx_id, observed_state, disposition, fragment_commit)
  • fragments(tx_id, ord, payload, digest), 4 × 8-byte fragments per tx
  • operations(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 called cur.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 only body; custody_records is 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 operations table are noise. The useful material is a few KB of JSON spread across about 12 services.
  • python -I and user site-packages. pip installed dpkt to the user site, which -I ignores. I ran the scripts from the solve/ directory without -I, and only ever passed the pcap as a path argument.
The flagRevealhiCTF{c8c46fdbd6e5e4eacabfba53a156ff9ab10567f2}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.