KumoSec ChainVault
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…
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.