AAVE and Compound Forking: Empty Pool Attacks

Author(s): Tim Savon
Security researcher(s) at MixBytes
Free licenses in DeFi allow the ecosystem to develop more rapidly. At the same time, when creating a fork of a major protocol, it is important to consider known risks and limitations. Otherwise, there is a danger of repeating the same mistakes.

The Compound and AAVE protocols have existed for many years and have proven their security and reliability. The versions Compound V2 and AAVE V2 are available under BSD/AGPL licenses, which is why some other protocols are built on their codebases. Conceptually, Compound and AAVE are similar, with the primary difference being the technical implementation of interest-bearing tokens.

  • In the case of Compound, tokens are exchanged for underlying tokens at an ever-increasing rate.
  • In the case of AAVE, interest accrual happens through rebasing.

Despite the different codebases, both protocols face the same issue when used as a base for a fork. If an empty pool appears in the protocol, a malicious actor can carry out an attack similar to an inflation attack, but slightly more complex. The conceptual difference is that it is not the first depositor who suffers but the protocol as a whole.

All inflation attacks revolve around rounding errors. In EVM, integer mathematics is used, where rounding defaults to the lower value. For example, 199 / 100 = 1. Accordingly, when calculating the cost of a share, it will always result in a slightly smaller value than the total balance divided by the number of shares. If the cost of a share is small, the rounding error is dust with an almost negligible value. However, if the share price is artificially increased, the rounding error becomes significant.

Attacks on both protocols can be summarized in the following steps (for consistency, we will use the term share for both protocols):

  1. Ensure that the total supply of the interest-bearing token corresponds to 1–2 shares.
  2. Somehow artificially inflate the price of one share. Here, the attack will differ between the protocols.
  3. Use the 1–2 shares of collateral to borrow funds from another pool. The price of the share in both protocols is inflated at the expense of the attacker's funds. Therefore, at this stage, the protocols do not suffer losses yet.
  4. Burn collateral shares, but in a tricky way. Due to the rounding error, it is possible to withdraw more underlying tokens than correspond to the burned number of shares. Drain the protocol.

Let's examine where exactly the vulnerabilities of both protocols lie. We will focus directly on the code and also try to identify ways to overcome this issue.
Compound Forks (Hundred Finance, Midas Capital, Onyx…)
Let's start with Compound — this case is simpler because the inflation attack here occurs in a more classic way.
A cToken conceptually resembles a vault.

  • cToken.totalSupply() represents the total number of shares.
  • exchange_rate is the amount of underlying_token that can be exchanged for one unit of cToken, meaning it represents the share price.

The protocol uses a precision multiplier of 1e18 for exchange rate calculations. However, this does not prevent rounding errors, which are key to this hack. For simplicity, we will omit the precision multiplier everywhere except in step 5.

The empty pool used for the attack will be referred to as The Pool (this corresponds to the cToken contract).

At the beginning of the transaction, the attacker takes a large flash loan from an external protocol. Then, they perform the following steps:


1. Deposit a relatively small initial_deposit into The Pool.
The number of minted shares of cToken for the attacker is determined by the initial exchange rate initial_rate. It is defined in the code as CToken.initialExchangeRateMantissa and set during The Pool's initialization.
As a result, the hacker receives initial_rate * initial_deposit cToken shares.


2. Withdraw initial_rate * initial_deposit - 2 shares.
After this, only 2 shares remain in the attacker's balance.
This sequence of steps (1–2) is necessary because initial_rate is quite high, and even a 1 wei deposit of underlying_token results in a balance of more than 2 shares.


3. Transfer the remaining part of the flash loan funds to the cToken contract balance.
The exchange rate of cToken is determined by the contract's balance of underlying_token:
CToken.exchangeRateStoredInternal():
​​​uint totalCash = getCashPrior();
​​​uint cashPlusBorrowsMinusReserves = totalCash + totalBorrows - totalReserves;
​​​uint exchangeRate = cashPlusBorrowsMinusReserves * expScale / _totalSupply;
function getCashPrior() virtual override internal view returns (uint) {
    EIP20Interface token = EIP20Interface(underlying);
    return token.balanceOf(address(this));
}
The larger the underlying_token balance of the cToken contract (while keeping totalSupply constant), the higher the share price.
Thus, the attacker inflates the share price by directly transferring funds to the contract.

Since the attack is performed on an empty pool, all transferred funds belong to the attacker's shares. This means they lose no money in this step.


4. Borrow tokens from another pool so that 1 inflated share as collateral is enough to keep the position healthy.
After step 3, 1 share is now worth half of the direct deposit's funds (the remaining part of the flash loan). This allows the attacker to borrow the entire underlying_token balance of another pool in the protocol.
Notably, up to this step, the hacker has caused no economic loss to the protocol.
In fact, they deposited more than twice the amount they borrowed at this step.


5. Call CErc20.redeemUnderlying(uint redeemAmount) on The Pool. The attacker passes redeemAmount = underlying_token.balanceOf(cToken) - 1.
The function CErc20.redeemUnderlying() takes as input the number of underlying_token the user wishes to receive. The number of shares burned is then calculated as follows:
redeemTokens = div_(redeemAmountIn, exchangeRate);
As mentioned at the beginning of the Compound case description, all calculations with exchange_rate use high-precision math. In the redeemTokens calculation, the exchangeRate is already passed multiplied by 1e18.

Since there are only 2 shares in the pool, the exchange rate = underlying_token.balanceOf(cToken) / 2 (* 1e18). Thus,
redeemTokens = (underlying_token.balanceOf(cToken) - 1) * 1e18 / (underlying_token.balanceOf(cToken) / 2 * 1e18) = 1.
As we see, high-precision math here did not prevent the rounding error.

The protocol only checks the number of burned shares to determine whether the withdrawal is allowed:
uint allowed = comptroller.redeemAllowed(address(this), redeemer, redeemTokens);
if (allowed != 0) {
    revert RedeemComptrollerRejection(allowed);
}
Since in step 4 the attacker borrowed an amount covered by only 1 inflated share and 1 share still exists, the protocol thinks there are still enough remaining shares. So this redemption is allowed.

At this point, the attack can be considered complete. The attacker has emptied one of the protocol's pools and has also returned all the funds invested in The Pool.

To repeat the attack on another pool, the hacker resets The Pool to its original empty state by liquidating from a different address.


At the end of the attack, the hacker returns the flash loan with a premium.
AAVE Forks (HopeLend, Radiant Finance…)
Attacks on AAVE forks are more sophisticated due to the different structure of interest-bearing tokens. In AAVE, aToken is a rebasable token, meaning that the balances of all holders increase together with the accrual of interest. To determine the balance at any given moment, a storage variable scaledBalance is multiplied by the current liquidityIndex. The contract's balance of the underlying token does not directly affect balances or the total supply of aToken. So direct transfers won't help inflate the share price.

The liquidityIndex for a new token is initially set to 1 (more precisely, it is equal to RAY == 1e27, a constant used for precision in calculations). liquidityIndex increases with every interest accrual.

For the first deposits into the aToken, the scaledBalance received by users is equal to the deposited collateral balance. Then, as liquidityIndex grows, the amount of scaledBalance issued per unit of the underlying token decreases.

AToken.balanceOf():
  function balanceOf(address user)
    public
    view
    override(IncentivizedERC20, IERC20)
    returns (uint256)
  {
    return super.balanceOf(user).rayMul(_pool.getReserveNormalizedIncome(_underlyingAsset));
  }
super.balanceOf(user) is scaledBalance.

_pool.getReserveNormalizedIncome(_underlyingAsset) calculates the current liquidityIndex.

This is definitely not a regular vault. But let's see how liquidityIndex increases with interest accrual.

Fees earned from flash loans increase liquidityIndex. Here is the relevant fragment of LendingPool.flashloan():
_reserves[vars.currentAsset].cumulateToLiquidityIndex(
    IERC20(vars.currentATokenAddress).totalSupply(),
    vars.currentPremium
);
  function cumulateToLiquidityIndex(
    DataTypes.ReserveData storage reserve,
    uint256 totalLiquidity,
    uint256 amount
  ) internal {
    uint256 amountToLiquidityRatio = amount.wadToRay().rayDiv(totalLiquidity.wadToRay());

    uint256 result = amountToLiquidityRatio.add(WadRayMath.ray());

    result = result.rayMul(reserve.liquidityIndex);
    require(result <= type(uint128).max, Errors.RL_LIQUIDITY_INDEX_OVERFLOW);

    reserve.liquidityIndex = uint128(result);
  }
Thus, liquidityIndex reflects the profit earned per unit of scaledBalance. This means scaledBalance can be considered vault shares, and aToken.scaledTotalSupply() * liquidityIndex represents the total vault balance.

Therefore, if liquidityIndex is somehow increased while keeping the number of shares constant, the share price inflates.

The attack was executed on an empty pool, which made it possible to manipulate its parameters. At the beginning of the transaction, the attacker took a flash loan worth several million dollars from an external protocol. The subsequent steps can be divided into two phases.
Phase 1: Increasing liquidityIndex – Inflation
  1. The attacker deposited the flash-loaned funds into the empty pool.
  2. In the targeted protocol, the attacker took a second flash loan for the entire deposited amount of underlying tokens. The aToken balance became empty. However, aToken.totalSupply() and liquidityIndex remained unchanged.
  3. The attacker transferred the underlying tokens directly to the aToken balance. This was not an inflation attack yet; it was necessary to execute the next step.
  4. The attacker withdrew all but 1 aToken using withdraw(). At this point, aToken.totalSupply() became 1, while liquidityIndex remained at 1 RAY.
  5. The attacker repaid the flash loan taken from the targeted protocol, including the premium. Since the loan was for several million dollars, the premium accrued was substantial. Given that aToken.totalSupply() == 1, all the premium was allocated to a single share, drastically increasing liquidityIndex and inflating the share price.
  6. To further inflate the share price, the attacker repeatedly took and immediately repaid flash loans for the entire underlying token balance of aToken. Each time, the accrued interest increased liquidityIndex, further raising the share price.

At some point, the share price became so high that the attacker was able to use it as collateral to borrow all the funds from another protocol's pool.

At this point, the protocol had still not suffered any losses because, in step 3, the hacker had directly transferred the first flash loan to the aToken contract balance.
Phase 2: Extracting the Last Tokens
At this stage, rounding errors came into play.

The function LendingPool.withdraw() is used for collateral withdrawals. To calculate the number of aTokens burned and the underlying tokens transferred to the user, withdraw() calls AToken.burn():
IAToken(aToken).burn(msg.sender, to, amountToWithdraw, reserve.liquidityIndex);
Inside burn(), the burning calculation and transfer take place:
    uint256 amountScaled = amount.rayDiv(index);
    require(amountScaled != 0, Errors.CT_INVALID_BURN_AMOUNT);

    _burn(user, amountScaled);

    IERC20(_underlyingAsset).safeTransfer(receiverOfUnderlying, amount);
To increase precision, the protocol uses RAY-based math. Both the numerator and denominator, as well as the result, are represented as RAY numbers (values multiplied by 1e27). Let's look at WadRayMath.rayDiv() and the function's comment:
  /**
   * @dev Divides two ray, rounding half up to the nearest ray
   * @param a Ray
   * @param b Ray
   * @return The result of a/b, in ray
   **/
  function rayDiv(uint256 a, uint256 b) internal pure returns (uint256) {
    require(b != 0, Errors.MATH_DIVISION_BY_ZERO);
    uint256 halfB = b / 2;

    require(a <= (type(uint256).max - halfB) / RAY, Errors.MATH_MULTIPLICATION_OVERFLOW);

    return (a * RAY + halfB) / b;
  }
However, in AToken.burn(), the numerator (amount) is simply the number of underlying tokens being withdrawn, not a RAY number. The same applies to the result — it is the number of burned shares. Thus, rayDiv() doesn't increase precision here. It only simplifies the operation since liquidityIndex is stored as a RAY number.

As a result, rounding errors occur when burning shares. If the calculation requires burning 1.49 aTokens, only 1 token is actually burned. However, the transferred amount still corresponds to 1.49 shares since _underlyingAsset.transfer() uses the original amount.

At the final stage of the attack, the attacker repeatedly deposited 1–2 shares as collateral and then withdrew 1.49 shares, gradually draining the balance. The extracted funds were then used to repay the initial external flash loan, completing the attack.

Here is your proofread article with all mistakes corrected while keeping the formatting intact:
Recommendations
When forking any protocol, it is crucial to analyze past security incidents affecting other forks and their mitigations. How can a protocol based on Compound V2 or AAVE V2 protect itself against empty-pool attacks?

One essential measure is to prevent pools from having low or zero liquidity. This can be addressed by depositing a minimum amount into a new pool immediately after deployment (within the deployment transaction). If this is done in separate transactions, a malicious user could insert themselves between the transactions and drain the entire protocol.

This case demonstrates that not only should the code be audited by a professional audit company, but also the deployment scripts, DAO proposals, and other aspects of the DeFi protocol's operation.

It is also important to note that rounding should always favor the protocol. What may seem like an insignificant loss could, under specific conditions, lead to massive losses and total protocol failure.
  • Who is MixBytes?
    MixBytes is a team of expert blockchain auditors and security researchers specializing in providing comprehensive smart contract audits and technical advisory services for EVM-compatible and Substrate-based projects. Join us on X to stay up-to-date with the latest industry trends and insights.
  • Disclaimer
    The information contained in this Website is for educational and informational purposes only and shall not be understood or construed as financial or investment advice.
Other posts