Neven Marić
Hidden Investigations CTF 2026

Hidden Investigations CTF 2026

Pruned Ledger

Reconstruct a pruned case file from a 400-tx blockchain archive by RLP-decoding transactions, identifying the right ledger contract, and unmasking sealed storage slots and note values using domain-specific XOR keys.

  • blockchain
  • rlp
  • calldata-forensics
  • xor-masking
  • storage-slots
  • smart-contract
  • foundry

What this one teaches

  • Storage pruning zeros slot values but calldata is forever — the chain archive preserves every argument ever passed to configure(), seal(), and unlock().
  • When a sealed value is stored as m XOR keccak(domain || address || record), you need the exact record that was current at seal time, not the last value before pruning.
  • A failed tx (status false) is still in the archive and makes a convincing decoy if you don't check its receipt.

Task description

A settlement-ledger system was decommissioned, and the operator cleared selected fields from current contract storage. The complete chain archive remains available.

Reconstruct the pruned case file for case 0xca5ef11e.

  • Category: blockchain (hard)

Decode all 400 transactions

The archive (transactions.jsonl) contains 400 pre-signed legacy transactions. The decoder RLP-decodes each raw tx, recovers the sender, computes CREATE addresses, and resolves function selectors:

import json, sys, rlp
from eth_account import Account
from eth_utils import keccak, to_checksum_address

sel = {}
for s in ["configure(bytes32)","seal(bytes32)","prune()","unlock(bytes32)",
          "forward()","clear()","record()","caseReference()"]:
    sel[keccak(text=s)[:4].hex()] = s

out = []
for line in open(sys.argv[1]):
    line = line.strip()
    if not line: continue
    j = json.loads(line)
    raw = bytes.fromhex(j["raw"][2:])
    f = rlp.decode(raw)
    nonce = int.from_bytes(f[0], 'big')
    to = f[3]; value = int.from_bytes(f[4], 'big'); data = f[5]
    sender = Account.recover_transaction(j["raw"])
    rec = {"n": j["n"], "ts": j["timestamp"], "from": sender,
           "nonce": nonce, "value": value}
    if len(to) == 0:
        addr = to_checksum_address(
            keccak(rlp.encode([bytes.fromhex(sender[2:]), nonce]))[12:])
        rec["create"] = addr
        rec["datalen"] = len(data)
        rec["data"] = data.hex()
    else:
        rec["to"] = to_checksum_address(to)
        s = data[:4].hex()
        rec["call"] = sel.get(s, s) if data else "(transfer)"
        rec["args"] = data[4:].hex()
    out.append(rec)
json.dump(out, open(sys.argv[2], "w"), indent=1)

The archive contains 26 deployments (18 Relay, 6 Clearing, 2 Ledger), plus configure/seal/prune/unlock/forward/clear calls and plain value transfers.

Identify case 0xca5ef11e’s ledger

Relay contracts carry a bytes4 case tag in their constructor. Four relays are tagged 0xca5ef11e (txs 66, 72, 79, 85); following their nextHop chain 0xcaec06 -> 0xCC0805 -> 0x137B85 -> 0x65dBce lands on Ledger 0x68Fb8760B1353f7349c431442d6E97Ab61685Ee7 (deployed in tx 61). The other ledger, 0xb52e9D..., is case 0xca5ef00d — a decoy whose fields read archived_state / history / always / remembers.

Case reference = pruned_storage

It is the first constructor argument of the Ledger, fixed at deployment and still readable (caseReference()); taken straight from the tx 61 creation calldata.

Working record = calldata

configure(bytes32) sets slot 0 record. The operator wrote several values over the ledger’s life: tx 123 set "calldata", then txs 144 and 163 overwrote it with junk, and prune() (tx 325) zeroed whatever was last. The field we want is the record in place when seal() ran (tx 136), which is "calldata". Two decoys sit nearby: tx 156 tried configure("settled") but came from a non-operator address and reverted (confirmed status false), and the separate 0xb52e9D ledger belongs to a different case entirely.

Archive entry = never

seal(m) stores m ^ keccak(SEAL_DOMAIN || ledger || record) at slot keccak("hictf.pruned-ledger.archive" || record) - 1, with no getter. Using the record current at seal time ("calldata"), the slot is 0x7fecf332...ce59; unmasking the sealed value there gives "never".

Note = forgets

The constructor stores _maskedNote; unlock() reveals _maskedNote ^ keccak(NOTE_DOMAIN || ledger || caller) in its revert string, readable only for the address it was filed for. Only caller 0x123332679fb9643Cb4302dE5DC8F7EFba76D060f (the address that made the failed unlock("letmein") call in tx 291) unmasks to readable text: "forgets".

The case-file reconstructor computes the note and seal unmask for every candidate record:

import json
from eth_utils import keccak

SEAL = keccak(text="hictf.pruned-ledger.seal")
NOTE = keccak(text="hictf.pruned-ledger.note")
xor = lambda a, b: bytes(x ^ y for x, y in zip(a, b))
pr = lambda b: ''.join(chr(c) if 32 <= c < 127 else '.' for c in b)

t = json.load(open('/solve/txs.json'))
L = {'0x68fb8760b1353f7349c431442d6e97ab61685ee7':
     ('a147d1ef...', 'e961e38a...')}  # (maskedNote, unlockHash) from storage

for led, (mn, uh) in L.items():
    lb = bytes.fromhex(led[2:])
    # find the note recipient: XOR maskedNote with keccak(NOTE || ledger || addr)
    for addr in all_addresses:
        v = xor(bytes.fromhex(mn), keccak(NOTE + lb + bytes.fromhex(addr[2:])))
        s = v.rstrip(b'\0')
        if all(32 <= c < 127 for c in s) and len(s) > 3:
            print('note for', addr, repr(s))
    # compute seal entries for each configure() record seen
    recs = [bytes.fromhex(r['args'])
            for r in t if r.get('to', '').lower() == led
            and r['call'] == 'configure(bytes32)']
    for r in t:
        if r.get('to', '').lower() == led and r['call'] == 'seal(bytes32)':
            m = bytes.fromhex(r['args'])
            for rec in recs:
                e = xor(m, keccak(SEAL + lb + rec))
                print('seal rec', rec.hex()[:16], '->', pr(e))

Replay verification

verify.sh boots anvil in ghcr.io/foundry-rs/foundry:v1.8.5, replays every tx, and confirms on the rebuilt chain: the archive slot holds "never", record is 0 and pruned is true, an eth_call to unlock from 0x1233... reverts with “…the stored value is: forgets”, and the decoy txs 156/291 reverted while the operator’s calls succeeded.

Lessons

  • Storage pruning zeros slot values, but calldata is forever. The chain archive preserves every argument ever passed to configure(), seal(), and unlock().
  • When a sealed value is stored as m XOR keccak(domain || address || record), you need the exact record that was current at seal time, not the last value before pruning.
  • A failed tx (status false) is still in the archive and makes a convincing decoy if you don’t check its receipt.
The flagRevealhiCTF{pruned_storage_calldata_never_forgets}

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