Web Exploitation / Lunar CTF 2026
KumoSec ChainVault
Analyze an EVM audit registry and vault contract to reconstruct the access path exposed by their interaction.
Summary
Luna CTF hosted an EVM playground ("KumoSec ChainVault") behind a team-token API at http://103.49.131.90 with a JSON-RPC node at /chain/rpc. Two contracts exist: an AuditorRegistry access-gate and a ChainVault holding bounty ETH. The intended-looking chain — become an auditor, then drain the vault — is broken both by careless ownership (a well-known ganache-cli dev key) and by a checks-effects-interactions bug in settleClaim() that lets unregistered payouts repeat.
Files
.chain.py— the complete standalone solver (pure stdlib: pure-python Keccak, RLP, secp256k1, EIP-155 signing).drain.py,check.py,reg_author.py— recon helpers: vault info, wallet,owner(), chain ID probes.solve.py,solve1.py— hand-rolled JSON-RPC transactions built witheth_sendTransaction.
Analysis
API surface
drain.py shows the team-token endpoints, all authenticated by an X-CTF-Team-Token header (LNR-D0903CBFD6 in the scripts):
/api/v1/vault/info— returns the deployedchain_vaultandauditor_registryaddresses./api/v1/vault/wallet— a funded EOA with its private key./api/v1/vault/flag— 400 until the vault'sisSolved()flag flips.
Escalating to auditor
The registry's payout-altering calls (registerAuditor(address), selector 0xa22cb465) are owner-only, and the registry owner turns out to be the deployer EOA 0x90F8bf6...c9C1. .chain.py matches that address against the standard ganache-cli deterministic key list; on a hit it just signs registerAuditor from the owner account and adds our wallet. A fallback path brute-forces the unauthenticated migrateLegacyAuditor(address,bytes32) against a dictionary of guessable legacyMigrationTokens seeds (keccak of "MIGRATION", "LEGACY_TOKEN", small integers, and similar phrases), verified first with an eth_call to legacyMigrationTokens(bytes32).
Draining the vault
Per .chain.py's vulnerability notes and solve1.py:
ChainVaulthas no owner withdrawal; the only payout issettleClaim()(selector0x6e8e8181), which payspendingClaims[msg.sender]and zeroes the mapping after the ETH transfer — a CEI violation and classic reentrancy sink.pendingClaimsis capped at 1 ETH per finding, but nothing stops submitting a finding and settling once per bounty: enumerategetBountyCount()andgetBountyIdAt(uint256), decode thebounties(bytes32)struct (client at word 1, amount at word 2, settled flag at word 3), then callsubmitAuditFinding(bytes32,string)followed bysettleClaim()for every unset bounty.- The script repeats sweep passes because a bounty larger than 1 ETH can only be fully drained via reentrancy.
- Draining drops
address(this).balancetoemergencyThreshold(0), which makes_checkEmergencyCondition()setisSolved = true, the condition/api/v1/vault/flagchecks.
check.py and reg_author.py are the recon stubs that established the owner's identity and chain ID (eth_chainId) before the full chain was scripted.
Exploit
The transaction primitives (solve.py/solve1.py) abuse the node's unlocked eth_sendTransaction with explicit gas 300,000 (0x493E0), whereas the final solver .chain.py signs raw EIP-155 transactions itself — worth noting because the playground later may refuse eth_sendTransaction. It waits on eth_getTransactionReceipt and aborts on reverted status before continuing the chain.
Flag
Flag: not recovered in the writeup. The scripts stop at the flag endpoint query (drain.py); no returned flag string appears in any file.