DFX Finance Attack Overview

Article realeased on Nov 16, 2022


We take a look at the recent DFX Finance Attack, which lead to around $4M in losses.
We will analyze this incident step by step, showing how we approached it.
We will also go over the relevant best practices to avoid the issues that caused this attack.
We first go over some details about the attack that’s not related to the main vulnerability.
where the initial capital came from
what kind of transactions the attacker used to “set up” the attack
how the attacker moved the hacked funds.
Then, we will look at the main vulnerability.
If you want to check this only, you can skip to “The Main Attack Vector”.

Attack Analysis

To find the attack vector, let’s go through the exploit locally and see what actually happens.
The tools we used here are
etherscan, obviously a helpful tool
foundry, to run the transactions and see what happens
https://tx.eth.samczsun.com/, a very good tool to run transactions
https://library.dedaub.com/, to decompile certain contracts when needed.
The exploiter’s address is 0x14c19962E4A899F29B3dD9FF52eBFb5e4cb9A067.
The first transaction related to the address is this one.
Here, we see the account is funded by 0x8D0347eCb9344a6ce5BbaFA45eEceE68e53A008D.
Looking at that address’s internal transactions, we see the exploiter is funded via Tornado Cash.
Next, the attacker creates a contract 0x6cFa86a352339E766FF1cA119c8C40824f41F22D.
The attacker then calls the function App() onto this contract. We run this in Foundry and see this
address exploit = 0x6cFa86a352339E766FF1cA119c8C40824f41F22D; address exploiter = 0x14c19962E4A899F29B3dD9FF52eBFb5e4cb9A067; function testExploit() public { cheats.startPrank(exploiter); exploit.call(hex"b76564bd"); cheats.stopPrank(); }
Foundry Script
Foundry Result. This can be found in etherscan’s logs as well.
OK, a bunch of approvals. Looking up the relevant addresses, we can summarize as follows.
Approved ERC20
Approved Address
0x46161158b1947D9149E066d6d31AF1283b2d377C (DFX-XIDR-USDC)
0x46161158b1947D9149E066d6d31AF1283b2d377C (DFX-XIDR-USDC)
0x63cB0F59B7E67c7d4Cb96214ca456597D85c587d (DFX-GYEN-USDC)
0x63cB0F59B7E67c7d4Cb96214ca456597D85c587d (DFX-GYEN-USDC)
0xcF3c8f51DE189C8d5382713B716B133e485b99b7 (DFX-TRYb-USDC)
0xcF3c8f51DE189C8d5382713B716B133e485b99b7 (DFX-TRYb-USDC)
0x764a5A29f982D3513e76fe07dF2034821fBdba72 (DFX-NZDS-USDC)
0x764a5A29f982D3513e76fe07dF2034821fBdba72 (DFX-NZDS-USDC)
0x9A6C7aE10eB82A0d7dC3C296eCbc2E2bDC53E80B (DFX-XSGD-USDC)
0x9A6C7aE10eB82A0d7dC3C296eCbc2E2bDC53E80B (DFX-XSGD-USDC)
0x477658494C3541ba272a7120176D77674a0183Ba (DFX-EUROC-USDC)
0x477658494C3541ba272a7120176D77674a0183Ba (DFX-EUROC-USDC)
0xF3d7AA346965656E7c65FB4135531e0C2270AF83 (DFX-CADC-USDC)
0xF3d7AA346965656E7c65FB4135531e0C2270AF83 (DFX-CADC-USDC)
So after all, it’s just approving all the tokens to relevant DFX Finance pools.
We can now make an educated guess that the attacker will go through all these pools and exploit them. Therefore, we can simply look at one or two exploit transactions to see what happened.
The next transaction is the big one, and we can suspect that this is the exploit tx.
We see that the transaction targets the DFX-XIDR-USDC pool.
To check, let’s see the token balances after the transaction was processed.
Since we can see from etherscan that the relevant tokens are USDC and XIDR, let’s look at them.
IERC20 USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); IERC20 XIDR = IERC20(0xebF2096E01455108bAdCbAF86cE30b6e5A72aa52); function testBalance() public { emit log_named_uint("USDC", USDC.balanceOf(exploit)); emit log_named_uint("XIDR", XIDR.balanceOf(exploit)); }
Foundry Script
Running this on block number 15941673 and 15941674, we see that the balances increased.
USDC: 99398834232 XIDR: 2271274940334125
Plain Text
This is already at least $200K in profits, so this is an exploit transaction indeed.
Meanwhile, if we look at this exploit transaction, we see that UniswapV3 flash loans are used
We see that USDC and XIDR are flash loaned from Uniswap V3, so initial capital is required.
So before we go deeper inside how the attack actually works, we can consider the attacker’s strategy regards the need for initial capital. From the fact that the attacker flashloaned both USDC and XIDR from Uniswap V3 to attack the DFX’s USDC-XIDR pool, we can guess that the attacker requires both tokens to pull off the attack. Since the attacker now gained some capital via flashloan-assisted attack, the attacker does not need to use them anymore. Indeed, if the attacker needs some other token (for example, EUROC) the attacker simply swap some USDC.
We can see that this is the case by noting that
the attacker does not use Uniswap V3 flash loans the first exploit transaction
the attacker, when dealing with a new pool, performs a swap at the beginning
For example, this transaction is the first exploit tx that deals with GYEN.
We see that the exploiter swaps some USDC for GYEN by looking at the call trace.
We also see some housekeeping done by the exploiter, after the very first exploit tx.
These five transactions
grab all 2271274940334125 of the stolen XIDR
first transaction is a test transfer, grabbing 1000
the second transaction transfers all the remaining stolen XIDR
approve the XIDR for swap on Uniswap V3 (third transaction)
swap some of the XIDR for 11.16 ETH on Uniswap V3 (fourth transaction)
send the remaining XIDR to the attack contract (fifth transaction)
so the attacker now doesn’t have to worry about gas costs anymore.
We also see that the attacker took out the stolen money, then either
swapped to ETH and sent to Tornado Cash (most assets)
remaining in the attack contract (TRYb)
remaining in the exploiter’s address (CADC)
bridged to Polygon (MATIC, 1 WETH, 820042 NZDS)
the attacker swapped 1 ETH to MATIC and bridged to Polygon
This fund retrieval part starts at block 15941949.
Now let’s actually take a look at the attack.

The Main Attack Vector

As we mentioned before,
all exploit transactions are very similar and use the same vulnerability
the only differences are usage of Uniswap V3 flashloans / initial swap for tokens
Therefore, we take a look at the exploit transaction that doesn’t use Uniswap V3 flashloans or starts with a swap. That transaction would be the second tx for USDC-XIDR.
Looking at the call trace again and see the calls for the pool contract, we see that
there was a flashloan call to the pool contract
in the flashloan call back, the deposit function is called
after the flashloan, the withdraw function is called, sending assets to the exploiter contract
We can already get an idea on how this attack happened. One scenario is that the flashloan mechanism simply looks at its ERC20 balances to check if the contract that took the flashloan has returned its assets. In this case, the attacker can deposit the flashloaned assets to the pool. The pool contract will think that we have returned the assets as the ERC20 balances are back to normal, but in reality we deposited the assets, so we can withdraw them later as we want.
To check if this is the case, let’s take a look at the pool contract.
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); } /// @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); } /// @notice withdrawas amount of curve tokens from the the pool equally from the numeraire assets of the pool with no slippage /// @param _curvesToBurn the full amount you want to withdraw from the pool which will be withdrawn from evenly amongst the /// numeraire assets of the pool /// @return withdrawals_ the amonts of numeraire assets withdrawn from the pool function withdraw(uint256 _curvesToBurn, uint256 _deadline) external deadline(_deadline) nonReentrant noDelegateCall isNotEmergency returns (uint256[] memory withdrawals_) { if (whitelistingStage) { whitelistedDeposited[msg.sender] = whitelistedDeposited[msg.sender].sub(_curvesToBurn); } return ProportionalLiquidity.proportionalWithdraw(curve, _curvesToBurn); }
Indeed, we see that this is the case. There is no reentrancy protection for flash(), and the flashloan check is done simply using the ERC20 balances. We also see why the attacker required some initial capital - it’s because the attacker had to “pay” (deposit) the flash loan fee as well.
We note that this attack vector was in the pool contracts, which was not in the audit scope of our security audit of DFX Finance which was done on May 2022.
Our audit was for DFX Finance’s algorithmic stablecoin, which can be checked in this repository.

Preventing Such Attack Vectors


First, appropriately preventing reentrancy is always a good practice.
UniswapV2 pairs support flashloans, but the function is reentrancy protected.
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock { require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT'); (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: 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, 'UniswapV2: 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) IUniswapV2Callee(to).uniswapV2Call(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, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT'); { // scope for reserve{0,1}Adjusted, avoids stack too deep errors uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3)); uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3)); require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K'); } _update(balance0, balance1, _reserve0, _reserve1); emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to); } uint private unlocked = 1; modifier lock() { require(unlocked == 1, 'UniswapV2: LOCKED'); unlocked = 0; _; unlocked = 1; }
If you want professional help at preventing such attacks, ask us for an audit.
HAECHI AUDIT is a flagship service of HAECHI LABS, the leader of the global blockchain industry. We bring together the best Web2 and Web3 experts. Security Researchers with expertise in cryptography, leaders of the global best hacker team, and blockchain/smart contract experts are responsible for securing your Web3 service.
We have secured over $60b worth of crypto assets across 400+ global crypto projects — L1/L2 projects, defi protocols, P2E games, and bridges — notably 1inch, SushiSwap, Badger DAO, SuperRare, Klaytn and Chainsafe. HAECHI AUDIT is the only blockchain technology company selected for the Samsung Electronics Startup Incubation Program in recognition of our expertise. We have also received technology grants from the Ethereum Foundation and Ethereum Community Fund.
Secure your smart contracts with HAECHI AUDIT.
Official website: https://audit.haechi.io/