SCAN 2026 CTF: how I solved it
Ten challenges, 55 flags, 10,000 points, five chains. Tracing stolen funds through mixers and bridges, pulling malware C2 config out of memo fields, reversing unverified bytecode, and auditing two contracts with bugs in them.
Everything below was derived from canonical chain data and cross-verified on independent RPC endpoints. Two answers were rejected on first submission; both are written up as such, because those are the most useful parts.
Contents
- Ethereum — phishing fund-flow trail (300)
- Solana — blockchain C2 channel (1,000)
- Drainer-as-a-service (1,000)
- A C2 resolution layer hiding behind
balanceOf()(1,500) - Reconstructing the Radiant Capital attack (1,000)
- Through the mixer and out the other side (800)
- Did the stablecoin freezes land in time? (900)
- $27M, two weeks, four services (2,150)
- The DEX that let anyone withdraw its fees (600)
- Who got over-credited by the token locker (750)
Challenge 1 — Ethereum: phishing fund-flow trail
Seed 0x5604…a59e, an address tied to a September 2022 phishing incident. Every hop is defined by exact, deterministic rules — no explorer labels, no rounded values.
Ground rules and setup
Both challenges share a strict rulebook. A successful transaction has receipt status = 1. A direct native-ETH transfer requires to ≠ null, value > 0 and input = 0x. An address counts as an EOA only when eth_getCode at the given block returns exactly 0x. Amounts are summed in raw wei or raw integer units. Everything is evaluated at the historical block, which means an archive node is mandatory.
I verified the canonical chain first: both boundary block hashes for each scope matched the brief on three independent endpoints. State reads went through the archive-capable eth.drpc.org, tenderly and blastapi, with Blockscout for address-history enumeration.
The important control: I cross-checked explorer completeness against the on-chain nonce delta, which gives the exact count of outgoing transactions for an address over a block range. That turns "I found N transfers" into "N is all there is", so nothing can be silently missed.
Flag 1 — reconstruct the trail (100 pts)
Eight deterministic steps run from the seed A0 to a token, a consolidation address and a total.
- H1 — the biggest cumulative native recipient of
A0. A0's nonce rose 0 → 3, confirming exactly three outgoing transactions. Two went to0x0e05…e7b8(162.74 ETH) and one to0x1c6e…8b4d(2.53 ETH). Winner:0x0e05…e7b8. - H2 — repeat from H1, excluding A0 and H1. Nonce delta 4; largest recipient is
0x1c6e…8b4dat 188.19 ETH. - Token — among tokens where H2 is a transfer party and
decimals()returns cleanly, exactly one satisfiesIN = OUTandIN > 300000 × 10⁶: USDT (0xdac1…1ec7, d = 6), with IN = OUT = 301,665.542644. - C — grouping H2's outgoing USDT by EOA recipient, all of it consolidates into a single address,
0xf600…980b.TOTAL_RAW = IN_T.
| Symbol | Value | Meaning |
|---|---|---|
| H1 | 0x0e05aec89abf3ca6abcfa060edd21db7d846e7b8 |
1st hop |
| H2 | 0x1c6e28d3f5175e9093de62a188d87c5ba8148b4d |
2nd hop |
| Token | 0xdac17f958d2ee523a2206206994597c13d831ec7 |
USDT (d=6) |
| C | 0xf600c14e09c8997851b732d079d3b8e7b357980b |
consolidation |
| TOTAL_RAW | 301665542644 | 301,665.542644 USDT |
flag{0x0e05aec89abf3ca6abcfa060edd21db7d846e7b8|0x1c6e28d3f5175e9093de62a188d87c5ba8148b4d|0xdac17f958d2ee523a2206206994597c13d831ec7|0xf600c14e09c8997851b732d079d3b8e7b357980b|301665542644}
Flag 2 — follow the branch to a dormant wallet (200 pts)
Continuing from C, the trail widens into branches, a USDT→WETH swap, and finally a wallet that stays dormant through the observation cutoff.
- Branch set B — C's direct native transfers (nonce delta 8, all verified) leave two EOAs above 100 ETH:
0x0485…026dand0x4175…fad4. - Funding log — the single qualifying large USDT transfer from a branch, where the top-level sender is the branch itself and the recipient is an EOA, is
308529.865406USDT to S =0xe4ab…ff28. That isFUNDING_RAW. - Swap Q — S's only later transaction meeting
300000 × 10⁶ < INPUT_RAW ≤ FUNDING_RAWwithWETH_OUT > 160 ETHis0xdd69…0b48. INPUT_RAW = 308,529.865405, leaving a residue of exactly one raw unit, and WETH_OUT ≈ 166.92. - Dormant D — inside the window Q+1 … Q+128 blocks, S forwards 167.0029 ETH to
0xcc6a…117d, which hascode = 0xandnonce = 0at block 19,771,559. Confirmed dormant.
| Field | Value |
|---|---|
| SWAP_TX | 0xdd6980fe48ffc84dce29b162affbb7438bac4d8894d9941a195bdf98c97b0b48 |
| S (sender) | 0xe4ab1e4e895ecc528dee0c6b18ceacd33ae9ff28 |
| D (dormant) | 0xcc6ae8425deedfdc6941f3eab119493be5c1117d |
| FUNDING_RAW | 308529865406 |
| INPUT_RAW | 308529865405 |
| WETH_OUT_WEI | 166923566471660937170 |
| FORWARDED_WEI | 167002900000000000000 |
flag{0xdd6980fe48ffc84dce29b162affbb7438bac4d8894d9941a195bdf98c97b0b48|0xe4ab1e4e895ecc528dee0c6b18ceacd33ae9ff28|0xcc6ae8425deedfdc6941f3eab119493be5c1117d|308529865406|308529865405|166923566471660937170|167002900000000000000}
Challenge 2 — Solana: blockchain C2 channel
A malware campaign hides command-and-control server locations inside Solana transaction memos. From one seed address we trace the funding, decode the payloads and map the operator's wallets.
The operator's technique
Each C2 record is an SPL Memo instruction carrying JSON in the form {"link":"<base64>"}. The base64 decodes to an http://<IP>/… URL. Infected hosts simply read the seed's transaction history over standard RPC and follow the link, which leaves no trace of the read anywhere.
The operator also injects memos into other wallets' histories by sending them 0-lamport transfers. No value moves, but the memo becomes part of the target's ledger.
ChangeNOW G2Yx…wd3t
│ funds
▼
SEED 28PK…sEA2 ── 44 C2 memos, 14 unique IPs
▲
│ 0-lamport memo injections
OPERATOR W 5m49…HNMQ
├─▶ 6YGc…zqDJ (campaign wallet)
└─▶ BjVe…o8SC (campaign wallet · plaintext config · "I know you")
Flag 1 — which service funded the seed? (75 pts)
The seed's oldest transaction is a durable-nonce withdrawal: AdvanceNonceAccount plus a System transfer, with a separate fee-payer signing while a large hot wallet sends the SOL. That split-role shape is how exchanges sign offline.
The 13,689,670 lamports came exactly from hot wallet G2YxRa6wt1qePMwfJzdXZG62ej4qaTC7YURzuh2Lwd3t, publicly attributed to ChangeNOW, a no-KYC instant swap.
flag{ChangeNOW}
Flag 2 — the data-writing program (75 pts)
Every C2 transaction invokes the SPL Memo program, which logs Program log: Memo (len …) and carries the base64 link payload.
flag{MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr}
Flag 3 — IP from the earliest memo (100 pts)
The first memo, immediately after funding, carries aHR0cDovLzIxNy42OS4zLjIxOC8…, which base64-decodes to http://217.69.3.218/qQD%2F….
flag{217.69.3.218}
Flag 4 — unique C2 IP addresses on the seed (100 pts)
Of the seed's 45 signatures, one is the funding transaction and 44 are memos. Every one is a valid {"link":…} payload decoding to an http://<IPv4> URL — zero unparseable, zero without a valid host. Deduplicating the hosts yields 14 distinct IPv4 addresses.
My first pass was too permissive: it base64-decoded every token that looked like base64 anywhere in the memo, which is a good way to manufacture addresses that were never there. Rewriting it to parse strictly from the canonical link field produced the same 14, and that agreement is the actual proof.
45.32.147.205 45.32.150.97 45.32.151.157 45.76.45.151
107.191.62.170 136.244.114.52 137.184.198.91 162.33.177.177
199.247.10.14 199.247.10.166 217.69.3.51 217.69.3.218
217.69.11.60 217.69.13.229
flag{14}
Flag 5 — the memo-injection wallet (150 pts)
Not every transaction in the seed's history is signed by the seed. Four are signed by another wallet that sends a 0-lamport System transfer to the seed with a memo attached, injecting C2 records without moving any funds.
flag{5m49aU7pMecF7WWHi2n2aL7UaAjVCn2mYBnTVMUpHNMQ}
Flag 6 — other injection targets (125 pts)
Filtering all of W's transactions down to those with a 0-lamport transfer and a single-key {"link":…} memo decoding to an HTTP URL, 12 of 15 qualify. Removing the seed from the destination list leaves exactly two campaign wallets. Sorted in ascending ASCII, '6' (0x36) comes before 'B' (0x42).
flag{6YGcuyFRJKZtcaYCCFba9fScNUvPkGXodXE1mJiSzqDJ|BjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SC}
Flag 7 — C2 server port (100 pts)
Some memos publish a full plaintext config instead of a base64 URL. On BjVe…o8SC:
{"c2server":"http://217.69.11.99:5000","checkIp":"http://217.69.11.99","dht_data":"217.69.11.99:10000"}
The c2server endpoint listens on 5000. Port 10000 belongs to dht_data, which is the decoy the question explicitly warns against.
flag{5000}
Flag 8 — the hidden phrase (150 pts)
Rather than looking for a URL, invert the filter: find memos in the operator's exact format that fail to decode into one. Exactly one hit, on BjVe…o8SC, where the base64 SSBrbm93IHlvdQ== decodes to a message instead.
Someone used the operator's own memo format to tell them they had been spotted.
flag{I know you}
Flag 9 — the operator's exclusive IP (125 pts)
Attributing every memo to its signing wallet rather than the address it landed on, the operator W's own memo history references exactly one IPv4 — 137.184.198.91 — and no other campaign wallet references it. Both readings of "exclusively" land in the same place.
flag{137.184.198.91}
Toolchain
Ethereum — archive JSON-RPC (drpc, tenderly, blastapi) for historical eth_getCode, eth_call, receipts and nonces; Blockscout for address history; nonce-delta cross-checks for completeness; Python big integers and cross-multiplication for exact comparisons with no floating point anywhere.
Solana — getSignaturesForAddress and getTransaction with jsonParsed encoding over public RPC, with per-signature caching and endpoint rotation. Worth noting: only the official endpoint returned history for the seed. Two other public RPCs returned zero signatures without erroring, and starting from either would have led to the conclusion that the address was empty.
All data used here is public on-chain data. This write-up reconstructs contest-defined trails for a CTF; it does not assert beneficial ownership, common control or legal attribution among the addresses involved. No C2 host was contacted at any point.
Challenge 3 — Drainer-as-a-service
A wallet drainer running an affiliate programme. Victims get phished, funds land in a drainer contract, and periodically the operator's backend settles up — splitting the take between the affiliate who sourced the victim and the operator, atomically, in one transaction. The task is to reverse-engineer the business model from the settlements alone.
Seed settlement: 0xefeba275…5464
Flags 1–4 — the split and the tiers
The seed settlement has no logs at all. The ETH moves through internal calls, so the receipt tells you nothing and you need trace data or an explorer's internal-transaction view.
SEED=0xefeba2755c3cfbb4ddb48ab5d5705ec17256abfc3d19c1ee7f89489d00735464
curl -s "https://eth.blockscout.com/api?module=account&action=txlistinternal&txhash=$SEED" \
| python3 -c "
import json,sys
r = json.load(sys.stdin)['result']
tot = sum(int(i['value']) for i in r)
for i in sorted(r, key=lambda x:-int(x['value'])):
v = int(i['value'])
print(f'{i[\"to\"]} {v/1e18:12.6f} ETH {v*100/tot:8.4f}%')
"
One sender pays all three payouts, and the address is a vanity job: 0x00000f31…00000, zeros at both ends. That is the family signature.
Running the same query across the three settlements in the brief makes the structure obvious:
| Recipient | Tx A | Tx B | Tx C | Role |
|---|---|---|---|---|
| rotates every time | 79.99% | 74.98% | 69.71% | affiliate |
0x9fa7bb75…c726 |
20.0000% | 25.0000% | 30.0000% | operator |
0x63605e53…4a51 |
0.0073% | 0.0161% | 0.2935% | gas rebate |
The affiliate address is different each time. The middle wallet recurs and always takes a clean round number. The third gets a few thousandths of a percent that varies every time, which is a gas refund to whoever triggered the settlement, not a revenue share.
For the percentage I used Decimal rather than floats. The task says round half-up, and floats will happily hand you the wrong side of a .5. It works out to 79.9926…, so 80.
flag{0x00000f312c54d0dd25888ee9cdc3dee988700000}
flag{0x059f30bc3ce1f7e8b68257dd11ad0e6c35d299d4|80%}
flag{0x9fa7bb759641fcd37fe4ae41f725e0f653f2c726}
flag{20%|25%|30%}
Flags 5–6 — the one I got wrong
Task. Count every direct internal ETH transfer from the drainer to the operator's fee wallet between 2023-04-01 and 2024-02-29 inclusive, then sum them.
I pulled the internal transfers from Blockscout twice. Once from the drainer's side, filtering for transfers to the fee wallet. Once from the fee wallet's side, filtering for transfers from the drainer. Both came back with 706 and a matching total.
Two queries agreeing felt like verification. I submitted 706. It was wrong.
The reason is obvious in hindsight and I am slightly annoyed I missed it. Both queries hit the same index. If Blockscout's internal-transaction table is missing rows, it is missing them in both directions, and asking the same database the same question from two angles is not independent confirmation of anything. I had not verified the number, I had just asked twice.
The only authoritative source for internal transfers is trace data, and finding a public node that would serve trace_filter took longer than the actual analysis.
publicnode : "Archive requests require a personal token"
1rpc : "Method trace_transaction not allowed"
drpc : works
Two details in the script below matter. First, I filter from client-side rather than trusting the combined fromAddress/toAddress filter, because drpc returned a trace from the relayer when I used both. Second, the dedupe key.
import json, urllib.request, time
U = "https://eth.drpc.org"
DRAINER = "0x00000f312c54d0dd25888ee9cdc3dee988700000"
FEE = "0x9fa7bb759641fcd37fe4ae41f725e0f653f2c726"
START_BLK, END_BLK = 16950603, 19336606
def call(params):
back = 3.0
for _ in range(15):
try:
data = json.dumps({"jsonrpc":"2.0","id":1,
"method":"trace_filter","params":[params]}).encode()
j = json.loads(urllib.request.urlopen(urllib.request.Request(
U, data=data, headers={'Content-Type':'application/json'}),
timeout=120).read())
if isinstance(j.get("result"), list): return j["result"]
except urllib.error.HTTPError as e:
if e.code in (403,429): time.sleep(back); back = min(back*1.7, 90)
time.sleep(back); back = min(back*1.5, 60)
raise RuntimeError("fail")
matches, b = [], START_BLK
while b <= END_BLK:
e = min(b+249999, END_BLK)
matches += call({"fromBlock":hex(b), "toBlock":hex(e),
"fromAddress":[DRAINER], "toAddress":[FEE]})
b = e+1; time.sleep(2.5)
seen, cnt, total = set(), 0, 0
for t in matches:
if t.get("error"): continue # reverted-tx traces carry an error flag
a = t["action"]
if a["from"].lower() != DRAINER or a["to"].lower() != FEE: continue
v = int(a.get("value","0x0"), 16)
if v <= 0: continue
key = (t["transactionHash"], tuple(t.get("traceAddress",[])))
if key in seen: continue
seen.add(key); cnt += 1; total += v
Result:
QUALIFYING: 747 distinct txs: 746 excluded for error: 0
trace types: {'call': 747} callTypes: {'call': 747}
total wei : 352842185990546044826
total ETH : 352.842185990546044826
747, not 706. The index was missing 41 transfers, about 5.5 percent of them.
Deduping on (txHash, traceAddress) instead of just the hash matters here, because one transaction contains two separate drainer-to-fee transfers:
0x6a00d5f7f63445fd164c6a2d7795d66b9361a94c870ea839659847a9cb2e15de
traceAddress [0] 0.1354240250138896 ETH
traceAddress [1] 0.3159893916990757 ETH
The rules say each qualifying transfer counts once even if several happen in the same transaction, so it is 746 transactions and 747 transfers.
I also checked the window boundaries rather than trusting my block arithmetic, since being one block out at either end could shift the count:
block 16950602 ts 1680307199 block 16950603 ts 1680307211
block 19336606 ts 1709251199 block 19336607 ts 1709251211
targets: 1680307200 (2023-04-01T00:00:00Z), 1709251199 (2024-02-29T23:59:59Z)
flag{747}
flag{352.8}
Takeaway
Two agreeing queries against the same index is one query. Independent verification means a different data source, not a different question.
Challenge 4 — A C2 resolution layer hiding behind balanceOf()
The best-designed challenge of the set. Malware resolves its C2 domain at runtime by calling balanceOf(address) on a contract that throws the argument away and returns a domain string. To anything watching, it looks like a wallet checking a token balance.
BscScan wants an API key. Etherscan's v2 multichain endpoint wants a paid plan for BSC. Blockscout has no BSC instance. So everything here is raw RPC, and honestly most of the work was getting the data at all rather than interpreting it.
Cutoff: block 109839734 = 0x68c0576
Flag 1 — who deployed it (75 pts)
With no explorer, I found the deployment by binary-searching eth_getCode. 25 requests instead of scanning 42 million blocks.
SEED = "0x7cc3cfc1ac007b8c6566fd2c7419b15a75473468"
def has_code(bn):
return rpc("eth_getCode", [SEED, hex(bn)]) not in ("0x", "")
lo, hi = 67108864, 109839734
while lo + 1 < hi:
mid = (lo+hi)//2
if has_code(mid): hi = mid
else: lo = mid
# hi = 86938214, then scan that block's receipts for contractAddress == SEED
Finding a BSC endpoint that serves historical state took a few tries. bsc-dataseed gives "missing trie node", publicnode wants a token, drpc rate-limits. blastapi and zan.top both worked.
Then I confirmed the nonce cryptographically rather than trusting the receipt field:
block 86938214 2026-03-16T13:43:21Z (120 txs)
DEPLOY_TX 0x538c45776a80c71c255920854230935ab55038d4d11746999ac2fd5b630f92d2
DEPLOYER 0x3a35b409af86e79e8945d6a7ffb1dc59b8dbdf46
to = None (CREATE) nonce = 8 status = 1
create_address(deployer, 7) → 0xe79dc06c17cf5401aa2d7d22a7eef082e42fb98d
create_address(deployer, 8) → 0x7cc3cfc1ac007b8c6566fd2c7419b15a75473468 ← match
create_address(deployer, 9) → 0xd1ef47a35c6a864ff01e5bf9c4c8215b4afd1b20
flag{0x3a35b409af86e79e8945d6a7ffb1dc59b8dbdf46|0x538c45776a80c71c255920854230935ab55038d4d11746999ac2fd5b630f92d2|8}
Flags 2–3 — reading the bytecode (150 + 125 pts)
1,190 bytes of unverified runtime. I wrote a throwaway disassembler rather than pasting it into an online decompiler, mostly because the answer needed exact opcodes and I did not want a decompiler's guess at intent.
b, i = bytes.fromhex(code[2:]), 0
while i < len(b):
op, pc = b[i], i
if 0x60 <= op <= 0x7f:
n = op-0x5f
print(f"{pc:04x}: PUSH{n} 0x{b[i+1:i+1+n].hex()}"); i += 1+n
elif 0x80 <= op <= 0x8f: print(f"{pc:04x}: DUP{op-0x7f}"); i += 1
elif 0x90 <= op <= 0x9f: print(f"{pc:04x}: SWAP{op-0x8f}"); i += 1
else: print(f"{pc:04x}: {OP.get(op, hex(op))}"); i += 1
The dispatcher handles two selectors and reverts on everything else:
000d: PUSH0 / CALLDATALOAD / PUSH1 0xe0 / SHR
0013: PUSH4 0x47064d6a EQ PUSH2 0x002c JUMPI ← setter
001e: PUSH4 0x70a08231 EQ PUSH2 0x0041 JUMPI ← balanceOf
0028: JUMPDEST PUSH0 DUP1 REVERT
The setter's guard is the part the question is really asking about:
0077: CALLER ← opcode 0x33, not ORIGIN (0x32)
0078: PUSH1 0x01 PUSH1 0x01 PUSH1 0xa0 SHL SUB ; 20-byte mask
0080: PUSH32 0x…0000003a35b409af86e79e8945d6a7ffb1dc59b8dbdf46
00a1: AND / EQ / PUSH2 0x00df JUMPI ; else revert Error("not owner")
That is msg.sender, so caller_check. The distinction from tx.origin matters and it is a single byte in the bytecode.
The getter at 0x0125 does PUSH0 DUP1 SLOAD, so slot 0, then runs the standard Solidity string encoder. Calling it confirms the shape:
0x0000000000000000000000000000000000000000000000000000000000000020 offset 32
0000000000000000000000000000000000000000000000000000000000000012 length 18
6b666664332e7665786c61746563682e63630000000000000000000000000000 "kffd3.vexlatech.cc"
96 bytes total. ERC-20 says balanceOf returns uint256. This returns a string.
flag{70a08231|47064d6a|00|caller_check}
flag{uint256|string|kffd3.vexlatech.cc|96}
Flags 4–5 — how many contracts, how many families (225 + 175 pts)
The block tag. The brief says block 109839734 and also gives 0x68c0576. Those are the same number. I had been carrying 0x68c1d76 from the previous flag, which is 109845878, roughly six thousand blocks late. Nothing would have errored. The answers would just have been quietly wrong. Converting the decimal myself and checking the block hash against the brief caught it.
The second trap is subtler. The deployer's nonce is 80 at the cutoff and 103 today, so anyone enumerating CREATE addresses against latest state counts contracts that did not exist yet at the snapshot.
n_cut = int(rpc("eth_getTransactionCount",[DEP, CUT]), 16) # 80
n_now = int(rpc("eth_getTransactionCount",[DEP, "latest"]), 16) # 103
for n in range(0, n_cut):
a = create_address(DEP, n)
code = rpc("eth_getCode", [a, CUT])
if code not in ("0x",""): deployed.append((n, a, (len(code)-2)//2))
nonce 0 0xa6002d8c…5efa YES 1190 nonce 1 no
nonce 2 0x2a9c2c7c…041d YES 1190 nonce 3 no
… even nonces through 28 all YES … nonces 30–38 no
nonce 39 0x96044f6d…7233 YES 1190 nonces 40–79 no
TOTAL: 16
A clean deploy-then-configure rhythm through nonce 29, then a straggler at 39. Because a false negative here would silently cost the flag, I went back and re-checked all 64 no-code nonces on a second endpoint. Zero surprises.
For flag 5 I stripped the CBOR metadata Solidity appends. The last two bytes are a big-endian length, and the metadata sits just before them:
def strip_cbor(b):
ln = int.from_bytes(b[-2:], 'big')
return b[:len(b)-2-ln] if 0 < ln <= len(b)-2 else b
all 16: raw 1190 bytes → stripped 1137 bytes, cbor_len 51
distinct raw bytecodes: 1 distinct stripped families: 1
metadata: same IPFS hash, same solc 0.8.21
They are byte-identical, metadata and all. Cookie-cutter clones from one compile, differing only in the domain sitting in storage slot 0.
flag{16}
flag{1}
Flags 6–7 — every transaction the deployer ever sent (300 + 100 pts)
This is the flag that took longest, and not for interesting reasons. With no explorer API I needed the deployer's full transaction history, and the only handle I had was that nonces increase by one per transaction.
So: for each nonce, find the smallest block where getTransactionCount reaches n+1. My first attempt binary-searched the whole chain for every nonce and was still running after five minutes. The fix was to gallop upward from the previous nonce's block, since transaction n+1 cannot come before transaction n.
def tc(b): return int(rpc("eth_getTransactionCount", [DEP, hex(b)]), 16)
blocks, lo0 = {}, 1
for n in range(80):
target, lo, step = n+1, lo0, 1
hi = lo
while tc(hi) < target:
step *= 2; hi = min(lo+step, CUT)
a, b = max(lo0, hi-step), hi
while a < b:
mid = (a+b)//2
if tc(mid) >= target: b = mid
else: a = mid+1
blocks[n] = b
lo0 = b # next nonce starts here
Even then it was slow enough that I ran it in the background, and it got killed twice by rate limits before I moved the remaining nonces into a thread pool. The first 30 nonces are all within a few hundred blocks of each other; the last 50 are spread over 20 million.
nonce 0 to=CREATE sel=60a06040
nonce 1 to=0xa6002d8c…5efa sel=47064d6a
…deploy/set pairs through nonce 29…
nonce 39 to=CREATE sel=60a06040
…everything else is 47064d6a to one of the 16…
candidate setters: 64 successful: 64 deployments: 16 total: 80
Every non-deployment transaction is a setter, all 64 succeeded, none used another selector, none went anywhere unexpected. The tiering falls out of a counter:
| Contract | Setters | Tier |
|---|---|---|
0x7cc3cfc1…3468 (seed) |
37 | active |
0xe9d1a9fb…1ac9 |
6 | active |
| four more | 3 each | active |
| nine others | 1 each | reserve |
0x96044f6d…7233 |
0 | dormant |
6 + 9 + 1 = 16, and the setter counts sum to 64, so both flags cross-check each other.
flag{64}
flag{6|9|1}
Flags 8–9 — first money in, and update rhythm (75 + 275 pts)
Flag 8 has a shortcut I was pleased with. The deployer sent nothing before nonce 0, which means its balance can only go up before that point. So the first block where the balance is non-zero must be the block containing the first inflow, and I can binary-search for it.
def bal(b): return int(rpc("eth_getBalance", [DEP, hex(b)]), 16)
lo, hi = 1, 86937944 # block of the first outgoing tx
while lo < hi:
mid = (lo+hi)//2
if bal(mid) > 0: hi = mid
else: lo = mid+1
first funded block: 72604985 2025-12-23T02:09:03Z
txIndex 119 from 0xc44a7ddbbb2ef5c3e00ccc23fb2f98495f80d3b0
11546560212895441 wei → 0.011546560… → 0.011547 BNB status 1
That is before the scenario window starts, which is presumably why this flag explicitly drops the March cutoff.
Flag 9 is bookkeeping over the seed contract's 37 setters. No two share a block, so the intervals are unambiguous. Shortest is 6,303 seconds, longest 1,164,911 — about two weeks of silence in the middle of June.
flag{0xc44a7ddbbb2ef5c3e00ccc23fb2f98495f80d3b0|0x42e68ce143a0ba15742bcd2187dbdbe78181692393f22f4a44b38a0c50a3f32c|0.011547|2025-12-23T02:09:03Z}
flag{0x7cc3cfc1ac007b8c6566fd2c7419b15a75473468|37|6303|1164911}
Challenge 5 — Reconstructing the Radiant Capital attack
This one is a real incident from October 2024, which changes the character of it. The on-chain work is verifiable; the last flag needs open-source reporting.
Infrastructure deployed on four chains on 2 October, left dormant two weeks, then activated on 16 October when trusted signers approved what looked like a routine config change. No smart-contract vulnerability was involved — the signers' devices were compromised.
Flags 1–3 — the dormant infrastructure (75 × 3)
The deployment receipt gives you everything at once. to is null and contractAddress is populated, which is what a contract creation looks like.
Then I checked the same address on every chain I could think of:
arbitrum 11148 bsc 11148 ethereum 11148 base 11148
optimism, polygon, avalanche, linea, scroll, blast, zksync, gnosis: no code
Base briefly looked empty because llamarpc returned nothing while two other Base endpoints returned the full 11,148 bytes. Worth checking twice before concluding a chain is clean.
| Chain | Deployed | |
|---|---|---|
| Arbitrum | 2024-10-02T01:12:37Z | earliest |
| BSC | 2024-10-02T08:22:46Z | |
| Base | 2024-10-02T08:34:51Z | |
| Ethereum | 2024-10-02T08:41:35Z |
Takeover was 2024-10-16T13:27:26Z, so floor(1253689 / 86400) = 14 days sitting there doing nothing.
flag{0x57ba8957ed2ff2e7ae38f4935451e81ce1eefbf5}
flag{4|14}
flag{0x0629b1048298ae9deff0f4100a31967fb3f98962}
Flag 4 — what the signers actually approved (125 pts)
The takeover calldata is about 1,200 hex characters of nested calls. Rather than decode it by hand I searched it for selectors I recognised.
b = tx_input[2:]
for sel, name in {"63fb0b96":"outer multicall",
"6a761202":"Safe.execTransaction",
"f2fde38b":"transferOwnership"}.items():
for m in re.finditer(sel, b):
print(sel, name, "at nibble", m.start())
i = b.find("f2fde38b")
print("new owner:", "0x" + b[i+8+24 : i+8+64])
63fb0b96 outer multicall at nibble 0
6a761202 Safe.execTransaction at nibble 712
f2fde38b transferOwnership at nibble 1424
new owner: 0x57ba8957ed2ff2e7ae38f4935451e81ce1eefbf5
Three layers deep, the thing being approved hands ownership to the contract that had been sitting dormant since 2 October. Wrapped in a multicall inside a Safe execution, on a hardware wallet screen, it would look like exactly the routine parameter change it was disguised as.
flag{transferOwnership|0x57ba8957ed2ff2e7ae38f4935451e81ce1eefbf5}
Flag 5 — the other one I got wrong (150 pts)
I had Arbitrum's numbers and ranked from those alone: WBTC, wstETH, WETH, MIM. Submitted it. Wrong.
The problem was that I had never actually got BSC data. Every public BSC endpoint caps eth_getLogs somewhere between 10 and 50 blocks, drpc allows 10,000 but rate-limited me into the ground, and I had burned a lot of time on failed sweeps before submitting on partial data. That was the mistake: I knew the data was incomplete and answered anyway.
What eventually worked was giving up on logs entirely. I did not need the transfers, I needed the amounts, and balances are one call each.
OP=0629b1048298ae9deff0f4100a31967fb3f98962
for BLK in 43172200 43172800 43173024 43173500 43174000 43174500; do
H=$(printf '0x%x' $BLK)
for t in "BTCB:0x7130d2a1…ead9c" "ETH:0x2170ed08…933f8" \
"WBNB:0xbb4cdb9c…c095c" "USDT:0x55d39832…97955"; do
curl -s -X POST -H 'Content-Type: application/json' --data \
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_call\",\"params\":
[{\"to\":\"${t##*:}\",\"data\":\"0x70a08231000000000000000000000000$OP\"},\"$H\"]}" \
https://bsc-mainnet.public.blastapi.io
done
done
The whole BSC drain is visible in about four rows:
blk 43172800 BTCB=0.0 ETH=0.0 WBNB=0.0 USDT=0.0 USDC=0.0
blk 43173024 BTCB=160.3 ETH=470.4 WBNB=8,469.9 USDT=451,482.0 USDC=303,590.1
blk 43173500 BTCB=160.3 ETH=470.4 WBNB=8,469.9 USDT=451,482.0 USDC=303,590.1
blk 43174000 BTCB=144.0 ETH=470.4 WBNB=8,469.9 USDT=0.0 USDC=303,590.1
blk 43174500 BTCB=137.2 ETH=470.4 WBNB=0.0 USDT=0.0 USDC=0.0
Empty, full, still full, draining, gone. Six calls told me what a log sweep could not.
With both chains in hand the ranking changes, because BSC held the single biggest asset:
| # | Token | Chain | Amount | ≈ USD |
|---|---|---|---|---|
| 1 | BTCB | BSC | 160.35 | $10.8M |
| 2 | WBTC | Arbitrum | 150.91 | $10.2M |
| 3 | wstETH | Arbitrum | 2,404.47 | $7.4M |
| 4 | WETH | Arbitrum | 2,353.74 | $6.1M |
| 5 | WBNB | BSC | 8,469.86 | $5.0M |
| 6 | MIM | Arbitrum | 2,864,777 | $2.9M |
MIM drops from fourth to sixth. Total comes to about $50.3M, which lines up with the reported figure.
On the chain count: Ethereum did receive MIM, but it is bridged rather than drained. Arbitrum shows 2,864,777 and Ethereum 1,864,777, a difference of exactly one million, which is a bridge transfer and not a second theft. Base received nothing at all. Two chains.
flag{2|WBTC|BTCB|wstETH|WETH}
Flags 6–8 — the response, the money, the name (125 + 200 + 175 pts)
For flag 6 I first had to identify the right Safe. The takeover names three addresses and it is tempting to grab the one that appears inside execTransaction, but that is the contract whose ownership moved. The Safe is the one that received the call. getOwners() settles it: only 0x111ceeee…2177 returns an owner array.
Then the events. My first extraction returned 0xnull for all eleven rows, which confused me for a while.
Safe v1.3.0 does not index RemovedOwner. The address is in data, not topics[1]. I had assumed indexed because almost every event you meet indexes addresses. Reading the actual ABI would have saved ten minutes. The upside is that this one event covers both removeOwner and the outgoing side of swapOwner, so it answers the question exactly.
jq -r '[.blockNumber, .data] | @tsv' rem.json | while read blk data; do
echo "blk=$((16#${blk#0x})) removed=0x${data: -40}"
done
264543801 0x20340c2a71055fd2887d9a71054100ff7f425be5 2024-10-16T21:48:30Z
264544087 0x83434627e72d977af18f8d2f26203895050ef9ce 2024-10-16T21:49:43Z
264548878 0xbb67c265e7197a7c3cd458f8f7c1d79a2fb04d57 2024-10-16T22:09:43Z
264849889 …264854561 eight more 2024-10-17T19:09–19:28Z
Eleven removals total, but only three on the 16th. I also scanned back to the 15th to make sure nothing happened earlier in the day.
Flag 7 is a short chain to follow, and the answer is obvious once you see the dates:
| When | Hop | Amount | Sat for |
|---|---|---|---|
| 2024-10-16 20:36 | operator → 0x97a05b…e4e8 |
707.054 | 3 hours |
| 2024-10-16 23:28 | → 0x8B75E4…0887 |
707.054 | 26 hours |
| 2024-10-18 01:03 | → 0x961A19…3680e |
706.954 | 10 months |
| 2025-08-25 10:47 | → 0x828D40…bf69 |
707.054 | — |
The first two hops forwarded within hours. The third did nothing at all from October 2024 until August 2025. There are a few tiny inbound dust transfers in between, which look like someone tagging the address, but the 707 ETH did not move.
Flag 8 is the OSINT one. Mandiant attributed the intrusion to UNC4736, a DPRK-nexus group also tracked as AppleJeus or Citrine Sleet. Initial access was the INLETDRIFT macOS backdoor, delivered over Telegram by someone impersonating a former contractor, showing a legitimate-looking PDF while installing persistence. That is consistent with everything on-chain: no contract was ever exploited, the signers' machines were.
flag{3}
flag{0x961a19d16db31add5e257bc3d73b403ee0d3680e}
flag{UNC4736}
Challenge 6 — Through the mixer and out the other side
Six addresses feed Tornado Cash in June 2024 and the challenge wants to know what came out. Tornado is specifically built to make that unanswerable: the zero-knowledge proof means a withdrawal carries no cryptographic link to any deposit. Nothing you can compute from the pool will connect the two halves.
So the answer can't come from the mixer. It has to come from operator behaviour around the mixer — timing, funding patterns, and what the addresses do afterwards. That turned out to be more than enough.
Flag 1 — counting the deposits (100 points)
Task. Total ETH deposited to Tornado Cash from the six listed addresses, in whole ETH. Each deposit is 100, 10, 1, or 0.1.
First obstacle was getting the data at all. Etherscan's v1 API now returns a deprecation notice instead of results, and v2 wants a key. Blockscout's Ethereum instance serves the same txlist shape with no key at all, so that became the workhorse for the whole challenge.
The naive filter is "transactions to a Tornado pool", and it finds nothing. Every one of these deposits goes through the Tornado Router at 0xd90e2f92…f31b, which forwards into the pool. The pool address is in the calldata, not in to.
POOL = {"12d66f87…b8fc": 0.1, "47ce0c6e…2936": 1,
"910cbd52…9dbf": 10, "a160cdab…f291": 100}
ROUTER = "0xd90e2f925da726b50c4ed8d0fb90ad053324f31b"
for t in txs:
if (t["to"] or "").lower() != ROUTER: continue
if t["txreceipt_status"] != "1": continue
hit = [v for k,v in POOL.items() if k in t["input"]]
# cross-check: declared pool must match msg.value
assert hit and hit[0] * 10**18 == int(t["value"])
That assert is the whole point. Rather than trusting value, I confirmed the pool address encoded in the calldata agrees with the ETH actually sent. All 13 deposits are the 10 ETH pool, all 13 carry exactly 10 ETH, all succeeded.
0xD87786CA… 2 deposits 20 ETH
0x32d2fDaE… 2 deposits 20 ETH
0xE938B5f9… 2 deposits 20 ETH
0x18Dd22d0… 2 deposits 20 ETH
0x03eEa32d… 2 deposits 20 ETH
0x3BC3D3FA… 3 deposits 30 ETH
───────
13 deposits × 10 ETH = 130 ETH
flag{130}
The ordering of the six addresses is worth noting because it matters later. They're a peel chain: each one deposits its round 10 ETH chunks, then forwards the leftover dust to the next address in sequence.
0xD87786CA → 0x32d2fDaE → 0xE938B5f9 → 0x18Dd22d0 → 0x03eEa32d → 0x3BC3D3FA
Jun 2 18:33 Jun 4 10:13
Flag 2 — the thirteen withdrawal addresses (275 points)
Task. Identify all withdrawal addresses associated with the deposits. Exactly 13 addresses, order and case insensitive, set equality required.
This is the flag that looks impossible on paper. I started by pulling every event the 10 ETH pool emitted across June and July 2024, which meant finding an archive node that would serve historical eth_getLogs without a key. PublicNode wants a token for archive requests, Ankr wants an API key, dRPC times out on the free tier, 1rpc caps ranges at 50 blocks. rpc.mevblocker.io served 8,000-block chunks happily.
blocks 20005900 → 20310000
1783 logs 862 Deposit 921 Withdrawal 542 unique recipients
542 candidates for 13 slots. I crawled the full transaction history of all 542 and built a graph of where they each sent funds, looking for a consolidation point. One address stood out immediately.
destinations shared by many withdrawal addresses:
115 0xfc99f58a…ee9f ← too many, this is a service
72 0xa3754d63…9503
48 0xd90e2f92…f31b ← Tornado router (re-depositors)
14 0xe43fed4a…62bf ← 14 addresses, all single-withdrawal
13 0x77b20437…7931
The 14 senders feeding 0xe43fed4a each had exactly one 10 ETH withdrawal, and their timestamps lined up against my deposit list with a lag of a few minutes each. That was the tell. Once I saw it, the actual rule was simpler than any graph analysis: map each deposit to the very next withdrawal from the pool.
W = sorted(withdrawals, key=lambda x: x["blk"])
for blk, ts, frm in deposits:
nxt = [w for w in W if w["blk"] > blk][0]
print(frm, "→", nxt["to"], (nxt["ts"] - ts) // 60, "min")
0xd87786ca… 06-02 18:36:47 → 0x96dc12a1…d497 3m
0xd87786ca… 06-02 18:55:35 → 0x54212c93…914d 4m
0x32d2fdae… 06-03 06:25:23 → 0xab2b6f1f…45a3 7m
0x32d2fdae… 06-03 06:36:35 → 0x8ba0aba8…c790 3m
0xe938b5f9… 06-03 09:21:47 → 0xdc804997…4e04 4m
0xe938b5f9… 06-03 09:28:35 → 0xaeca1d64…fb2c 2m
0x18dd22d0… 06-03 12:52:59 → 0xe315b8d4…0e6b 6m
0x18dd22d0… 06-03 13:02:11 → 0xdccf86d4…b604 2m
0x03eea32d… 06-03 17:40:47 → 0x01900453…5d17 2m
0x03eea32d… 06-03 17:46:11 → 0x371e628d…7a31 4m
0x3bc3d3fa… 06-04 09:45:59 → 0x42242156…3021 4m
0x3bc3d3fa… 06-04 09:52:11 → 0xbe5e23e0…b084 5m
0x3bc3d3fa… 06-04 10:13:35 → 0x144cf3db…637b 3m
unique: 13
Thirteen deposits, thirteen distinct next-withdrawals, every lag between 2 and 7 minutes, no collisions. Withdrawing that fast destroys the timing anonymity the pool depends on — the operator never let their funds sit in the anonymity set.
Three independent checks confirm the set rather than relying on timing alone. Every one of the 13 has zero normal incoming transactions — their only funding is the internal transfer from the pool contract, so they're freshly generated and single-purpose. Every one then runs the identical four-step script:
| Step | Action |
|---|---|
| 1 | Test transfer, 0.01–0.05 ETH, to 0xfc99f58a…ee9f |
| 2 | 5 ETH to the same address |
| 3 | Remainder, ~4.9 ETH, to the same address |
| 4 | Dust sweep to a collector |
And the dust sweeps close the loop. Nine go to 0xe43fed4a. The other four go to 0xcab3359f…fbb1 — which is one of the original funders, having sent 19.7763 ETH to depositing address 0xE938B5f9 on June 3. Post-mixer money returning dust to a pre-mixer wallet is the link that makes the whole set defensible.
flag{0x96dc12a1c0f3ad73b9de2e16f88785bac0b6d497|0x54212c9301610ff59a27ca44ae3be827bf0d914d|0xab2b6f1f0032e25b98eb3f68c5d70ee319be45a3|0x8ba0aba87688bad3f58e98fcd1c22eabb6c0c790|0xdc804997502cf7798618161aa8d770a7c4704e04|0xaeca1d64123c410ebe8e73cbedfe9c76babffb2c|0xe315b8d4088c580ea3d4ebe8994857337ef10e6b|0xdccf86d4cf499689d6c3b580e90d77ac6ffab604|0x019004535f0e1fc0ed399bfa7456aebcb57f5d17|0x371e628d2ceb349ddc96beeb74b45e0699d57a31|0x42242156e04db470c26e36cdd3e99fe010063021|0xbe5e23e06cd3ed3d909ff1b8f8d5693b0230b084|0x144cf3dbdd9cf1c07c7a681ef6086f07f60f637b}
On 0xfc99f58a: it receives from all 13, which makes it look like the operator's wallet. It isn't — 115 unrelated withdrawal addresses also send there. It's a service endpoint, and mistaking it for a cluster member would have poisoned the next two flags.
Flag 3 — the address that connects both halves (200 points)
Task. Some withdrawal addresses send to an address whose activity connects the withdrawals with some of the depositing addresses. Name it.
Already found while validating flag 2. 0xcab3359f621c719fc8458584b378823a48b6fbb1 has five transactions in its entire life, and they are the bridge:
| Dir | Counterparty | ETH | When |
|---|---|---|---|
| IN | 0x96dc12a1… withdrawal | 0.00711 | 06-03 06:53 |
| IN | 0x54212c93… withdrawal | 0.00446 | 06-03 06:53 |
| IN | 0x8ba0aba8… withdrawal | 0.00588 | 06-03 06:55 |
| IN | 0xab2b6f1f… withdrawal | 0.00606 | 06-03 06:56 |
| OUT | 0xE938B5f9… depositing | 19.77628 | 06-03 09:17 |
It sweeps dust off four post-mixer addresses, then two and a half hours later sends 19.776 ETH to a pre-mixer depositing address, which immediately puts 2 × 10 ETH back into Tornado. One wallet, both sides.
I checked the other collector to rule it out. 0xe43fed4a receives from nine withdrawal addresses but its only outbound goes to 0x137c65b5…, which isn't a depositing address. It collects; it doesn't connect.
flag{0xcab3359f621c719fc8458584b378823a48b6fbb1}
Flag 4 — off Ethereum entirely (225 points)
Task. The funds consolidate on another network. Trace forward to the consolidation point. Case sensitive.
The submission example is a T… address, so the destination is Tron. The route runs through 0xfc99f58a — the service endpoint I'd been careful not to treat as operator-controlled. It's a TransparentUpgradeableProxy with 199 incoming transactions and zero outgoing: a bridge.
Everything needed is in the calldata of method 0x3d21e25a. I dumped it word by word and looked for anything ASCII.
b = bytes.fromhex(tx["input"][2:])
for m in re.finditer(rb"[\x20-\x7e]{6,}", b):
print(m.start(), m.group().decode())
# base58 Tron addresses are 34 chars starting with T
for m in re.finditer(rb"T[1-9A-HJ-NP-Za-km-z]{33}", b):
print(m.group().decode())
word 1 …eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee native ETH in
word 2 …dac17f958d2ee523a2206206994597c13d831ec7 USDT out
word 6 …00000000c3 chain id 195 = Tron
word 18 0a 555344542854524f4e29 "USDT(TRON)"
word 20 22 54434c4b50796b47584a6b… "TCLKPykGXJk81QW…"
Each of the 13 withdrawal addresses bridges to its own fresh Tron address — the one-address-per-deposit discipline held all the way across the chain boundary. From there it's a single TronGrid query per address.
TWAZsqYC8TVnGHaKTzwuhErHwFeyeHheCU → TJJkwYS4… 37,293.37 USDT
TCLKPykGXJk81QWxnHxSDRgepUmtLySxS3 → TJJkwYS4… 37,341.89 USDT
TTYeTzmjqymnuFeG7HEHNxcUWAvr9Q7zJf → TJJkwYS4… 37,923.08 USDT
… 10 more, every one a single outgoing TRC-20 transfer …
13 / 13 senders → TJJkwYS4EMYL5T34eDRqyGzbs18bwbdEHC
total 488,173.75 USDT
Every Tron address makes exactly one outgoing transfer, sweeping its full balance to the same wallet. The sanity check holds too: 488,174 USDT against the 130 ETH from flag 1 implies about $3,755 per ETH, which is right for early June 2024.
flag{TJJkwYS4EMYL5T34eDRqyGzbs18bwbdEHC}
What actually broke the mixer here. Not cryptography — the ZK proofs held fine. The operator withdrew within minutes of depositing, reused one script across all 13 addresses, swept dust back to a wallet that had funded the deposits, and fanned in to a single Tron wallet at the end. Any one of those alone would be suggestive. Together they reconstruct the full mapping.
Challenge 7 — Did the stablecoin freezes land in time?
Five DPRK-linked addresses, two issuers with freeze powers, and one question: did the freezes land in time?
Everything is read at a fixed snapshot, block 25629979, whose hash I checked on three endpoints before reading anything else.
Flags 1–2 — the audit (125 + 200 pts)
Both getters return a bool, and the two issuers spell the function differently, which is the only real trap in flag 1.
BLK="0x187151b"
USDT="0xdAC17F958D2ee523a2206206994597C13D831ec7"
USDC="0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
call(){ curl -s -X POST -H 'Content-Type: application/json' --data \
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_call\",
\"params\":[{\"to\":\"$1\",\"data\":\"$2\"},\"$BLK\"]}" $3 | jq -r '.result'; }
for A in 098b716b…2f96 0d043128…ed00 53b69365…bfc1 47666fab…86e2 3130662a…cd3c; do
P="000000000000000000000000$A"
call $USDT "0x70a08231$P" $U # balanceOf
call $USDC "0x70a08231$P" $U
call $USDT "0xe47d6060$P" $U # isBlackListed — capital L
call $USDC "0xfe575a87$P" $U # isBlacklisted — no capital
done
block 25629979 hash 0x939f304cd90d3ed64df89e28fe824a9449956bc109a7b8d392cac5c69f3f809d ✓
USDT USDC USDT frozen USDC frozen
0x098b716b…2f96 Ronin 0 0 true true
0x0d043128…ed00 Harmony 0 0 false false
0x53b69365…bfc1 intermediary 0 0 true true
0x47666fab…86e2 ByBit 90 0.01 false false
0x3130662a…cd3c Stake.com 0 0 false false
The shape of that table is the actual finding. Every address that is frozen holds nothing, and the only address holding anything is frozen by neither issuer. Freezing arrived after the money left, every time.
flag{Ronin Bridge Exploiter|addBlackList|true}
flag{0|0|true|true|0|0|false|false|0|0|true|true|90|0.01|false|false|0|0|false|false}
Flag 3 — three transactions (225 pts)
Two are ordinary USDT sends from the Harmony exploiter. The third looked odd at first because the transaction's to is a Safe, not a token.
0xf32d4cc5… USDT 0x0d043128…ed00 → 0x9e91ae67…8715 4,981,000
0x618a7d83… USDT 0x0d043128…ed00 → 0x9e91ae67…8715 5,000,000
0x6e525106… USDC 0x2dccdb49…0857 → 0x0d043128…ed00 41,200,000
tx.to = 0x715cdda5…, input selector 0xc01a8c84 = confirmTransaction
That third one is the Harmony bridge hack itself. A multisig confirmation moving 41.2M USDC out of the bridge contract into the exploiter's wallet. Only one Transfer log in the whole receipt.
flag{0x9e91ae672e7f7330fc6b9bab9c259bd94cd08715|USDT|false|0x9e91ae672e7f7330fc6b9bab9c259bd94cd08715|USDT|false|0x0d043128146654c7683fbf30ac98d7b2285ded00|USDC|false}
Flag 4 — 25.5 million USDC and a 20-minute head start (275 pts)
My favourite question in the whole set, because the answer is a timeline rather than an address.
# where did the USDC go
curl -s "https://eth.blockscout.com/api?module=account&action=tokentx\
&address=$RONIN&contractaddress=$USDC&startblock=0&endblock=99999999&sort=asc"
# then for each intermediary: what did IT call? the To field is the router
curl -s "…action=txlist&address=$A&sort=asc" \
| jq -r --arg a "$A" '.result[]? | select((.from|ascii_downcase)==$a)
| "blk=\(.blockNumber) to=\(.to) sel=\(.input[0:10])"'
14442840 bridge → exploiter 25,500,000 USDC
14442948 → 0xe708f172…ce10 1,000,000 14442954 → 0x665660f6…5617 1,000,000
14442981 → 0xe708f172…ce10 10,000,000 14442976 → 0x665660f6…5617 10,000,000
14442995 → 0x665660f6…5617 3,500,000
11,000,000 + 14,500,000 = 25,500,000
0x665660f6…5617 → 0xe592427a0aece92de3edee1f18e0157c05861564 sel 0xac9650d8
0xe708f172…ce10 → 0x1111111254fb6c44bac0bed2854e76f90643097d sel 0x7c025200
"SwapRouter" (Uniswap V3) "AggregationRouterV4" (1inch)
The question warns that one of them used an aggregator that settled across many pools, and wants the router that was actually called rather than the pools underneath. That is 1inch.
The timing is the point. Theft lands at block 14442840. Both intermediaries have swapped everything to ETH by 14443006. That is roughly twenty minutes. Circle can freeze USDC, but only USDC, and by the time anyone could have reacted there was no USDC left to freeze. Both intermediaries are still unblacklisted years later, because blacklisting them now would accomplish nothing.
flag{0xed2c72ef1a552ddaec6dd1f5cddf0b59a8f37f82bdda5257d9c7c37db7bb9b08|0x665660f65e94454a64b96693a67a41d440155617|Uniswap V3|false|0xe708f17240732bbfa1baa8513f66b665fbc7ce10|1inch|false}
Flag 5 — collate everything (75 pts)
Bookkeeping. Six distinct address/asset pairs after dedupe: two from the funded seed, two from flag 3 once the duplicate USDT pair collapses, two from flag 4. Sorted by address, USDC before USDT where an address appears twice. All six re-queried at the snapshot, all six false.
flag{0x0d043128146654c7683fbf30ac98d7b2285ded00|USDC|false|0x47666fab8bd0ac7003bce3f5c3585383f09486e2|USDC|false|0x47666fab8bd0ac7003bce3f5c3585383f09486e2|USDT|false|0x665660f65e94454a64b96693a67a41d440155617|USDC|false|0x9e91ae672e7f7330fc6b9bab9c259bd94cd08715|USDT|false|0xe708f17240732bbfa1baa8513f66b665fbc7ce10|USDC|false}
Takeaway
Freeze powers only reach funds still held as the frozen asset. A swap that completes inside the response window defeats them entirely — which is exactly what happened here, twenty minutes after the theft.
Challenge 8 — $27M, two weeks, four services
One seed address, 0xe8bde816…9b25, moving roughly $27M through a DEX, two no-KYC exchanges, and a relay. Ten flags that build on each other: get the eXch attribution wrong at flag 3 and flags 4, 5, 8, 9 and 10 all collapse.
The hard part isn't Ethereum. It's flags 4 and 5, which ask you to find a specific Bitcoin transaction with no shared identifier between the chains — only an amount and a time window.
Flag 1 — which stablecoins hit the router (75 points)
Task. The seed sent stablecoins to the 0x Protocol V4 swap router. Identify the two tokens and their total raw amounts, largest first.
The seed is heavily address-poisoned, and this is a trap that costs you the flag if you filter on ticker text. Of its 13 distinct token contracts, most are impostors using Cyrillic homoglyphs:
UЅDC ← Cyrillic Ѕ (U+0405), not Latin S
ЕТН… ← Cyrillic Е, Т
EТH ← leading space + Cyrillic Т
They're sent from addresses that mimic the leading and trailing hex of the real counterparties, so an eyeball check on a truncated address fails too. The only safe filter is contract address:
REAL = {"0xdac17f958d2ee523a2206206994597c13d831ec7": "USDT",
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": "USDC"}
Second wrinkle: the seed's three transactions go to the 0x V4 Exchange Proxy at 0xdef1c0de…5eff, but the token legs don't. V4's transformERC20 pulls input tokens into the FlashWallet at 0x22f9dcf4…8c18, so matching transfers against the proxy address returns nothing.
0xec4b9a7c…3077 08-28 11:31 USDT 1477594675912
0x36a694d0…b4bb 08-28 11:36 USDC 1116738659047
0x03d38dad…6dfe 08-28 16:11 USDT 2062874299367
USDT 3540468975279 (~$3.54M)
USDC 1116738659047 (~$1.12M)
flag{USDT|3540468975279|USDC|1116738659047}
Flag 2 — how many swaps (125 points)
Three. All call 0x415565b0 (transformERC20), all status 1, no reverts padding the count, and no direct calls to the FlashWallet or the newer Settler router that might arguably belong in the tally. The seed sent 59 transactions total across its life; exactly three touch 0x V4.
flag{3}
Flag 3 — separating two exchanges (150 points)
Task. Identify all ETH transfers from the seed to eXch.cx deposit addresses in the window. Count, total wei, first and last tx hash.
The seed made 54 outgoing ETH transfers. Profiling every destination's next hop produced two consolidation hubs, which is the crux of this flag — pick the wrong one and you get 21 transfers instead of 12.
21 deposit addresses → 0xa7d6b757…1d15 no public tag
12 deposit addresses → 0xf1da1732…1123 "eXch: Hot wallet"
Etherscan's public name tag settles it. Blockscout has no label for either, and I only got the tag by loading the address page in a real browser — Cloudflare blocks curl on Etherscan entirely.
Each of the 12 is single-use: one transfer in from the seed, one transfer out to the eXch hot wallet.
08-30 19:13:59 250 0xe973d58f…503c ← first
09-05 20:16:59 210 0xc29c7533…7ba2
09-06 16:32:11 690 0x997ff2e6…a6a7
09-06 19:51:35 750 0x1bfc76ff…71c1
09-07 00:08:11 50 0x6d3c1d0d…eb04
09-08 19:23:23 784 0x09171fed…9bdd
09-09 00:36:35 1000 0x693ee0b5…62bd
09-09 02:14:47 347 0x507239f8…acc7
09-09 02:44:35 1008 0x8f50d582…c534
09-09 21:08:23 850 0x5cd121e1…3147
09-10 00:47:11 277 0x7dc05c10…a5bf
09-10 04:50:11 1900 0xdc2bfe07…29ab ← last
─────
8116 ETH
Four other destinations reach the eXch hot wallet at two hops. Those are the operator's own splitters fanning out into multiple sub-deposits, not eXch deposit addresses, so direct transfers to them don't count. One of them, 0xd22e2746, is disqualified twice over — it received from the seed on two separate occasions, and these deposit addresses are strictly single-use.
flag{12|8116000000000000000000|0x4c7f90edf99337fbbece710ffc00cef13de9a6fc8afc4784d70a48e0c917d277|0x8951d78536d2a4eac4be350d5834013b8cf901b1e3748f82165d3d2091ab8513}
Flags 4 & 5 — finding a Bitcoin transaction with no identifier
275 + 275 points
Task. The first eXch deposit was 250 ETH at 2024-08-30T19:13:59Z. eXch processes swaps in 5–45 minutes. Find the corresponding BTC output: txid, satoshis, recipient, and seconds after the deposit.
Nothing links the two chains. There's a time window and an implied amount, and that's all. The two blocks inside the window hold 5,039 transactions between them, of which about 25 have an output anywhere near the right size.
My first instinct — find eXch's Bitcoin wallet from public sources — went nowhere. Germany's BKA seized eXch in April 2025 and the reporting covers the takedown in detail, but only the Ethereum hot wallet was ever publicly tagged. No BTC address anywhere.
So I derived it. The insight is that one deposit gives you a guess; five deposits give you a fingerprint. An exchange pays out at spot minus a consistent spread, so the right wallet should produce a matching payout in every window at the same discount.
For expected amounts I read Chainlink oracles at the exact deposit block rather than using daily closing prices, which are too coarse at this precision:
ETH_USD = "0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419"
BTC_USD = "0xf4030086522a5beea4988f8ca5b36dbc97bee88c"
def price(feed, block): # latestRoundData()
w = eth_call(feed, "0xfeaf968c", block)[2:]
return int(w[64:128], 16) / 10**8 # word 1 = answer
Then for each deposit window I downloaded the full blocks (blockchain.info/rawblock returns every transaction in one request, versus 25-per-page on the paginated APIs), collected every output that wasn't change — that is, whose address is not also an input address — and intersected the payer sets across windows.
payers matching the expected amount in more than one window:
bc1qu2dq8w8lv8v3l7lr2c5tvx3yltv22r3nhkx7w0 dep250, dep1000, dep1008
One address, and it survived every extra window I threw at it:
| Deposit | Expected BTC | Actual payout | Ratio | Lag |
|---|---|---|---|---|
| 250 ETH | 10.6344 | 10.33479062 | 0.9718 | 736s |
| 277 ETH | 11.4678 | 11.09919122 | 0.9679 | 579s |
| 1000 ETH | 41.9037 | 40.69402167 | 0.9711 | 2089s |
| 1008 ETH | 42.0197 | 40.99025397 | 0.9755 | — |
A 2.4–3.2% haircut every time. That's an instant exchange's spread plus fee, and it's far too consistent to be coincidence. The mega-wallets that also showed up in the windows — 1GrwDkr33… at 12.1M BTC lifetime volume, bc1qm34lsc… paying out 999.999 BTC at a clip — are major exchanges, not a service eXch's size.
Both target transactions have the same shape: consolidate several hot-wallet UTXOs, pay one recipient, return change.
flag 4 tx 5a48761a…253a block 859123
4 inputs from bc1qu2dq8w8… (3.80 + 2.20 + 0.77 + 4.00 BTC)
vout[0] 1033479062 sat → bc1qsalmq8mkzaqp9dpnqt0vexxq2jy9u9572k9rl9
vout[1] 43519096 sat → bc1qu2dq8w8… change
1725045975 − 1725045239 = 736 s
flag 5 tx 9458b472…2e77 block 860544
6 inputs from bc1qu2dq8w8…
vout[0] 4069402167 sat → 3HNhmWp4JspsKWBZ9USW3tEabBNYavytek
vout[1] 180172311 sat → bc1qu2dq8w8… change
1725844284 − 1725842195 = 2089 s
Both recipients report tx_count: 2 — funded once with exactly this amount, then fully spent. Single-use withdrawal addresses, matching the discipline seen everywhere else in this operation. For flag 5 the window was cleaner still: scanning all four blocks for spends from the eXch wallet returned exactly one transaction.
flag{5a48761a5d65bf8f0eaf20bcb83b53d6957eb4edf3d00c49407e3306aa98253a|1033479062|bc1qsalmq8mkzaqp9dpnqt0vexxq2jy9u9572k9rl9|736}
flag{9458b472c23bc87382465304332380ad89c444e36e495f63820327f445ae2e77|4069402167|3HNhmWp4JspsKWBZ9USW3tEabBNYavytek|2089}
Flag 6 — three deposits and a sweep batch (100 points)
Task. Three smaller ETH transfers went directly to deposit addresses labelled HitBTC/Changelly. Submit the hashes and wei amounts, chronologically.
Etherscan shows no name tag on any of the seed's small-transfer destinations, so the labels have to come from one hop further out. A public Etherscan-label dump gave me the hot wallets, and checking the remaining next-hop by hand gave the one it was missing:
0xa12431d0…2be4 "HitBTC 3"
0xfdeda15e…66b3 "HitBTC: Deposit Funder"
0x0113a6b7…1143 "HitBTC: Deposit Funder 3"
0x80787af1…ee1a "HitBTC: Hot Wallet 4" ← only on Etherscan, not in the dump
The decisive evidence is the sweep. All three addresses forwarded their full balance to HitBTC Hot Wallet 4 in the same batch, at 08-28 19:12:
| Time | In | Deposit address | Swept |
|---|---|---|---|
| 08-28 12:45:35 | 1 ETH | 0x2890d1eb…6d2f | 0.99994 |
| 08-28 12:55:35 | 7 ETH | 0xe4f713eb…0dd78 | 6.99998 |
| 08-28 14:09:47 | 10 ETH | 0xd5da041c…ba33 | 9.99992 |
Exchange deposit addresses get swept together on a schedule. Three independent addresses sweeping to the same hot wallet in the same minute is one custodial cluster. Two of the three corroborate independently: 0x2890d1eb was gas-funded by HitBTC's Deposit Funder addresses, which is how an exchange bootstraps an address it controls.
flag{0x8711f3c7891af396221ef3ef2adc7944822064e5ce6eeb6ed2e1d54faf949ee5|1000000000000000000|0x62e1fc10fdf41131a892c20e8b622b8b0fe9fec36ff8cabd6c265ebe3bea142c|7000000000000000000|0x39539c2a736ca5685d02423986dd12d4329f61827b480de0361e27b5f5c9fe3d|10000000000000000000}
Flag 7 — the one I got wrong (150 points)
Task. The seed sent 12,215.39 ETH to intermediary 0x6ef70281…, which forwarded a portion to TradeOgre deposit addresses, all consolidating into 0x4648451b…. Count every deposit into that cluster, total wei, first and largest amounts in whole ETH.
Three of the intermediary's 27 destinations sweep into the TradeOgre wallet. The main one received a textbook escalating test ladder — the operator probing the exchange with a small amount, then ramping hard once it cleared:
0x6d865235…44df 5 → 10 → 20 → 30 → 50 → 75 → 100 → 125 → 150 → 175 = 740 ETH
0x9feb6b5c…8a73 5 ETH
0xbd1ab7b8…f2e8 5 ETH
I submitted 13 deposits / 753 ETH and it was rejected. The main deposit address had also received 3 ETH from a fourth party, and I'd read "count every deposit into that cluster" as literally every inbound. I even justified including it: that wallet is demonstrably the same operator, since it later pays into 0x98b0811e…, the exact address the intermediary emptied its remaining 8,120 ETH into on the same day.
But the question scopes to what the intermediary forwarded from the seed's funds, and that 3 ETH didn't come from the seed. Twelve deposits, 750 ETH.
I did confirm from the TradeOgre side that no fourth deposit address existed — querying the hot wallet's inbound over blocks 20.6M–20.95M returns 283 deposits from 230 senders, of which only four sweeps trace back to the intermediary. The count was only ever about that one outside deposit.
flag{12|750000000000000000000|5|175}
Flags 8 & 9 — the relay chain, and a loop
200 + 350 points
Following the 40.69 BTC from flag 5 forward. Both hops are single-use addresses with exactly two transactions each, so there's no ambiguity about which spend to follow, and both are clean 1-input/1-output sweeps — the entire balance minus a few hundred satoshis of fee.
3HNhmWp4JspsKWBZ9USW3tEabBNYavytek
← 4069402167 blk 860544 t=1725844284
→ 4069401369 blk 860545 t=1725844757 fee 798 held 473 s
tx 07967cb7…8e44 to bc1qpmzezqj599rky94k245q4h8a02ar9938ngf9z5
bc1qpmzezqj599rky94k245q4h8a02ar9938ngf9z5
→ 4069400669 blk 860547 t=1725846355 fee 700 held 1598 s
tx 58007f63…3753 to bc1qu2dq8w8lv8v3l7lr2c5tvx3yltv22r3nhkx7w0
The second hop lands back on the same eXch hot wallet the money came out of. The 40.69 BTC leaves eXch, passes through two throwaway relay addresses, and returns about 35 minutes later. No payment shape, no change output, just links added to a chain.
That loop is also why the chain terminates: tracing past a wallet with 30,879 transactions isn't meaningful, which is exactly why flag 9 asks for a service rather than another hop.
flag{07967cb7be54b7c74297ad6d8dfdf4f30f4829850ef1bcc6e0c6a3cd52a08e44|4069401369|bc1qpmzezqj599rky94k245q4h8a02ar9938ngf9z5|473}
flag{58007f63874e73cc747d197a9cbb51085e6e7a4d5e683bfb2a727ff5af493753|4069400669|bc1qu2dq8w8lv8v3l7lr2c5tvx3yltv22r3nhkx7w0|eXch|1598}
The one field I couldn't verify externally. eXch as the service name rests entirely on my own rate-matching from flag 4 — no explorer, dataset, or search result labels that Bitcoin address. The four on-chain fields are solid; the label is inference. It happened to be right, but it's the kind of answer I'd want to caveat in a real report rather than assert.
Flag 10 — the ledger (450 points)
Every figure recomputed from raw transaction data rather than carried forward as text, which caught nothing but is the only way to be sure of that.
| Destination | Txs | Raw | Human |
|---|---|---|---|
| eXch deposit addresses | 12 | 8116000000000000000000 | 8,116 ETH |
| Changelly/HitBTC | 3 | 18000000000000000000 | 18 ETH |
| USDT → 0x Protocol | 2 | 3540468975279 | 3,540,468.98 |
| USDC → 0x Protocol | 1 | 1116738659047 | 1,116,738.66 |
| Intermediary 0x6ef70281… | 1 | 12215390000000000000000 | 12,215.39 ETH |
Two things this recomputation has to get right. The stablecoin totals are measured at the FlashWallet, not the Exchange Proxy, or they come back zero. And all ETH figures filter on txreceipt_status == 1, so no reverted transaction inflates a total.
About 20,349 ETH and 4.66M in stablecoins out of the seed — consistent with the brief's ~$27M at early-September 2024 prices.
flag{8116000000000000000000|18000000000000000000|3540468975279|1116738659047|12215390000000000000000}
Challenge 9 — The DEX that let anyone withdraw its fees
The brief. "I'm collecting noticeably less in admin fees than I should be. It's not zero, it just keeps coming up short." One transaction, calldata only.
DEX: 0x248E97Da…2379 · Owner: 0x3fb80eD7…F38C
Step 1 — find the chain
The contracts were not on mainnet. Or Sepolia, or Holesky, or any L2 I checked. I went through about fifteen chains before finding them.
for pair in "mainnet|…" "sepolia|…" "base|…" "arbitrum|…" \
"base-sepolia|https://base-sepolia-rpc.publicnode.com" …; do
curl … eth_getCode | jq -r '.result | length'
done
# base-sepolia: 8032 everything else: 2 (i.e. "0x")
Base Sepolia, and the source was verified on Blockscout, which made this much faster than it could have been.
Step 2 — the bug
Four lines:
function _authorize(address addr) private view returns (bool) {
if (admin == address(0)) {
return true;
}
return admin == addr;
}
It reads like a sensible "not configured yet" default. It is not. onlyAdmin calls this, and withdrawAdminFees(address to) sits behind onlyAdmin, so if admin is unset then anyone can call it and send the fees wherever they like.
And admin is unset:
admin() → 0x0000…0000
adminFees0() → 0
adminFees1() → 0
That matches the symptom exactly. Not zero fees, just short. The contract keeps accruing 0.05% on every swap, and every time the withdrawal window elapses somebody who is not the owner empties it.
Step 3 — the fix
The fix uses the same quirk. Because admin is zero, the owner is currently authorised, so he can call setAdmin, and his own address passes the newAdmin != address(0) check. After it lands the branch is dead and only he can withdraw.
sel = keccak256(b"setAdmin(address)")[:4].hex() # 704b6c02
arg = "3fb80eD7451795b02A18EA6A7e31F83acbE1F38C".lower().rjust(64,'0')
print("0x" + sel + arg)
Simulated from his address before writing it down:
eth_call → 0x no revert
eth_estimateGas → 0xae1d ~44,573 gas
flag{0x704b6c020000000000000000000000003fb80ed7451795b02a18ea6a7e31f83acbe1f38c}
One thing worth flagging
The race runs both ways. Anyone else could also call setAdmin right now and lock the owner out of his own contract permanently. The window is open in both directions, so this is not a fix that can wait.
Challenge 10 — Who got over-credited by the token locker
The brief. Lockers of one particular token walked away with more than they put in, and others may still be sitting on the same advantage without having cashed out. List every over-credited address.
Locker: 0xcabDd74e…2aF5 · Token: 0x571FD783…04A9
Step 1 — read both contracts
The locker's own doc comment names the vulnerability class, which is a fair hint:
/// Share accounting relies on the token reporting its own balance honestly
/// via balanceOf. A token whose balanceOf understates the real pooled amount
/// will cause new locks to be credited too many shares.
function lock(address token, uint256 amount) external returns (uint256 sharesMinted) {
uint256 poolBefore = IERC20(token).balanceOf(address(this));
IERC20(token).transferFrom(msg.sender, address(this), amount);
if (supply == 0 || poolBefore == 0) sharesMinted = amount * SHARE_PRECISION;
else sharesMinted = (amount * supply) / poolBefore;
}
Then the token turns out not to be a token:
name: ERC1967Proxy
implementation slot 0x360894a1…382bbc → 0x8696d80b…6fb3
impl: LegitTokenV5 — "Wires up the freeze infrastructure that was reserved in V3.
Adds a FREEZER role … Includes the V4 balance fix."
"Includes the V4 balance fix" is the whole answer in six words. If V4 fixed the balance, V1 through V3 had it broken, and poolBefore sitting in the denominator means an understated balance mints too many shares.
Step 2 — replay the events
To find who benefited I pulled every lock and unlock and replayed them, tracking two numbers side by side: the pool the contract thought it had, recovered by inverting its own formula, and the pool it actually had, accumulated from the deposits.
supply = truepool = 0
for e in events:
if e['kind'] == 'lock':
amount, smint = e['a'], e['b']
# invert sharesMinted = amount * supply / poolBefore
reported = amount * supply // smint if smint != amount * PREC else 0
fair = (amount * supply) // truepool if truepool > 0 else amount * PREC
if supply > 0 and smint > fair:
over.add(e['locker'])
supply += smint; truepool += amount
else:
supply -= e['a']; truepool -= e['b']
LOCK 0xe6d8e09e… 100e9 reported=(first) truePool=0 fair
LOCK 0x68053df5… 2000e9 reported=100e9 truePool=100e9 fair
LOCK 0x0cd783e5… 100e9 reported=0 truePool=2100e9 fair
LOCK 0x9d8c9a6f… 100e9 reported=2100e9 truePool=2200e9 OVER
LOCK 0x97f9a35a… 100e9 reported=2100e9 truePool=2300e9 OVER
LOCK 0xcf5957e4… 100e9 reported=2100e9 truePool=2400e9 OVER
LOCK 0xdf29ebd1… 100e9 reported=2100e9 truePool=2500e9 OVER
The reported pool freezes at 2100e9 while the real one keeps climbing. Every lock after that point is priced against a stale number and mints too many shares.
Step 3 — check it a second way
Because I did not fully trust the inversion, I compared what each address actually withdrew against what they put in:
0x0cd783e5… deposited 100e9 withdrew 98.115e9 not over
0x9d8c9a6f… deposited 100e9 withdrew 102.788e9 OVER
0x97f9a35a… deposited 100e9 withdrew 107.682e9 OVER
0xcf5957e4… deposited 100e9 withdrew 112.810e9 OVER
0xdf29ebd1… deposited 100e9 withdrew 0, holds ≈118.2e9 OVER
Both methods agree on the same four.
The third locker is worth a mention: it locked at the exact moment the reported pool read zero, so it took the 1:1 first-depositor branch, which happened to be fair. It came out slightly down, not up.
And 0xdf29ebd1… is precisely the case the brief asked about — the one holding inflated shares without having cashed out. It is only visible if you compute entitlements rather than looking at withdrawals.
flag{0x97f9a35a800f570780f3f41d6fee7f31b0ac50e9|0x9d8c9a6fa2d3657805b66cf54a53c9a5434d2529|0xcf5957e47480cbc1241eeeff9bc5af5ca39ab8ea|0xdf29ebd105758c7c59c721cd136f4108c7c179fa}
What I would tell myself before starting
Check the block hash before you check anything else. Every scoped challenge gives you boundary hashes. Verifying them takes one call and it's the only way to know you're reading the chain the challenge means. It caught a wrong block tag in challenge 4 that would have silently corrupted three answers.
Nonce deltas turn "I found N" into "N is all there is." An EOA's nonce increments once per outgoing transaction, so the delta across a range is exactly how many it sent. If the explorer gave you fewer, the explorer is wrong. This is the cheapest completeness proof available and I used it constantly.
Two queries against the same index is one query. The 706 versus 747 mistake. Asking Blockscout from the sender's side and the recipient's side felt like corroboration and wasn't, because both read the same table. Independent means a different data source, not a different question.
Internal transfers need traces, full stop. Receipts don't contain them and indexers are incomplete. Dedupe on
(txHash, traceAddress), because one transaction can legitimately contain several transfers between the same pair.Snapshot state is not current state. Challenge 4's deployer had nonce 80 at the cutoff and 103 by the time I looked. Anything derived from "latest" would have counted contracts that didn't exist yet.
When logs are capped, ask for balances instead. Six
balanceOfcalls at chosen blocks reconstructed the entire BSC drain after log sweeping had failed repeatedly. If you can bound the window, a handful of state reads beats a sweep you can't complete.Monotonic quantities let you binary-search. No outgoing transactions means the balance only goes up, which means the first non-zero balance is the first inflow. Unbounded scan becomes 25 calls.
Read the ABI, don't assume the convention. Safe v1.3.0 leaves
RemovedOwner's address unindexed. I lost time pullingtopics[1]and getting nulls because nearly every other event indexes addresses.Comments and version names are evidence. "Includes the V4 balance fix" told me what the bug was before I'd read a single log. So did the locker's own warning about tokens that understate their balance.
Cross-check on a second provider. Beyond catching bad data, this is how I found a malformed topic filter: I'd dropped a leading zero from an address, producing a 63-character hex string. One node errored on it, another silently returned nothing. The empty result looked like a real answer.
Never match a token by its ticker. Challenge 8's seed had thirteen token contracts and eleven were fakes using Cyrillic homoglyphs —
UЅDCwith a U+0405,ЕТНwith a Cyrillic Е. They arrive from addresses that mimic the real counterparty's leading and trailing hex, so truncated addresses don't save you either. Contract address is the only identifier that means anything.One data point is a guess; five is a fingerprint. Finding a Bitcoin payout with no cross-chain identifier looked impossible from a single 250 ETH deposit — the window held 5,039 transactions and two dozen plausible amounts. Running the same test across five deposits and intersecting the payers left exactly one address, paying 2.4–3.2% below spot every single time. When one instance is ambiguous, look for the repeat.
Price your expectations at the block, not the day. Daily closing prices are far too coarse to pick between candidate amounts. Chainlink's ETH/USD and BTC/USD feeds read at the exact deposit block turned "roughly 10.6 BTC" into a number precise enough to rank against.
Scope words in the question are load-bearing. I lost challenge 8 flag 7 by reading "count every deposit into that cluster" as literally every inbound, including 3 ETH from a wallet I could show belonged to the same operator. The sentence before it scoped to what the intermediary forwarded from the seed's funds. Being right about the attribution didn't make it in scope.
Mixers fail on operator behaviour, not on cryptography. Challenge 6's ZK proofs held perfectly. The operator still lost anonymity by withdrawing 2–7 minutes after each deposit, running one identical spend script across all 13 addresses, sweeping dust back to a wallet that had funded the deposits, and fanning into a single Tron wallet at the end. The tool worked; the tradecraft didn't.
Everything above was re-derived from RPC or trace data and confirmed on at least two independent endpoints. The fund-flow relationships follow each challenge's scoring rules and don't by themselves establish common control or ownership of any address.