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.
// SPDX-License-Identifier: UNLICENSED
pragma solidity^0.8.13;interfaceIGasSaver{functionowner()externalviewreturns(address);}contractChallenge{addresspublicgas_saver;addresspublicplayer;constructor(address_player){addressvitalik=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045;gas_saver=address(newGasSaver(vitalik));player=_player;require(player!=vitalik,"You are Vitalik, this game is too easy for you");}functionisSolved()publicviewreturns(bool){returnIGasSaver(gas_saver).owner()==player;}}contractGasSaver{constructor(addressowner){bytesmemorybytecode=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
functionsetOwner(address_owner)publicpayable{require(msg.sender==STORAGE[CHAINID()]);STORAGE[CHAINID()]=_owner;}functionowner()publicpayable{returnSTORAGE[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__(bytes4function_selector,uint256varg1)publicpayable{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();}elseif(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.
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.
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.
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.
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.
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:
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.
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.
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 pooladdress 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:
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.
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:
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:
tx.origin will still be us (the original caller)
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.
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.
// 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";contractExploit{Tokenpublictoken0;Tokenpublictoken1;IUniswapV3Poolpublicpool;addresspublicgasSaver;constructor(addressgasSaver_){gasSaver=gasSaver_;}functioninit()external{// Deploy tokens
addresstokenA=address(newToken(gasSaver));addresstokenB=address(newToken(gasSaver));// Sort tokens
(addresstoken0_,addresstoken1_)=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
IUniswapV3Factoryfactory=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
uint128maxLiquidityPerTick=1917580707794349315672650982405402;pool.mint(address(this),-800_000,800_100,maxLiquidityPerTick/2,"");// Toggle token0 spoof balance for swap purposes
token0.toggleSpoofBalance(true);}functionuniswapV3MintCallback(uint256amount0Owed,uint256amount1Owed,bytescalldatadata)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=0x00000000000000000000000028ef9f0e8e8edc22d62da8d432bb7a8291c1baa5token0=0x0000000000000000000000004403bdab9c3001fdc08cdcfc51d76a6622747b0atoken1=0x0000000000000000000000004b75b37ba3a1226f227546eeeb3bdb9987eccafcpayload='0xd3'payload+='44'# one-time MSTORE indexpayload+='00'# paddingpayload+=hex(token0)[2:]# token 0 addresspayload+=hex(token1)[2:]payload+='01f4'# feepayload+='049e'# Jump locationpayload+=hex(pool)[2:]# target address callpayload+='000000000000000000000000'+player[2:]# swap amount, which is player addressprint(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() :).