Jan 18, 2024

Rounding Bugs: An Analysis

Rounding-related hacks are having a moment in the spotlight. We explore these exploits, correct some popular misunderstandings, and provide mitigations.

Heading image of Rounding Bugs: An Analysis

Introduction

Recently, there's been a series of attacks exploiting share rounding against lending protocols. Rounding attacks are already known to developers on fast, cheap chains with high-value tokens. These attacks are novel in that they also work against low-value tokens on expensive chains. Most people haven't considered what happens when shares are worth a lot.

Much of the previous discourse has mischaracterized the rootcause of these hacks. For example, the presence of flashloans is largely irrelevant. At a high level, these attacks only require two key steps:

  1. Inflate share value (token to share conversion rate)
  2. Exploit rounding bug

In this blog post, we explore these attacks in detail and provide potential mitigations.

Model

Before we dive in, there's some helpful background information we'll share first.

A common form of accounting is the share and token model. When a user deposits a token, they receive back shares. Shares can accrue value, whether through interest or protocol fees.

When users want to withdraw their tokens, they burn shares and receive the corresponding amount of tokens back. This is nice in theory. Unfortunately, in the real world, we have fixed precision. You can't have 1.01 shares, it needs to be either one or two. Which way should we round?

This question is more complex than it may appear. Let's walk through an example.

Say we initialize shares and tokens in a one-to-one ratio. After an initial deposit of 1000 tokens, the pool state is 1000:1000 (1000 tokens to 1000 shares).

After accruing fees, the pool gains one token for a new ratio of 1001:1000.

How many tokens should we get back when withdrawing 999 shares? The real answer is 1001/1000*999 = 999.999. Unfortunately, we can only send the user 1000 or 999 tokens. For now, let's assume we round down against the user.

If we give the user 999 tokens, the new pool state is 2:1. The value of a share doubled! What happens if we deposit 1 more token? We'll get back zero shares, further inflating the ratio to 3:1.

Small decisions like rounding direction can have a big impact on share valuation. Generally, share valuation isn't a strict security boundary.

The above is a bit of a simplification. In practice, there are several protocol-specific design decisions:

  1. Can you deposit and receive back zero shares? If not, you'll need to spend more effort to exploit the rounding error
  2. When you withdraw, are you withdrawing shares or tokens?
  3. Can you directly manipulate pool state by sending tokens? Hopefully not.

Decisions

Let's assume that we're able to inflate the value of a share. How can we actually exploit this?

Radiant Capital

Radiant Capital was hacked on Jan 2nd for about $4.5M. This was the original example of exploiting rounding on otherwise inconsequential shares.

The exploit is relatively straightforward and has already been covered previously.

At a high level, this exploit is exactly what you'd expect. If shares were worth $1000 each, and the user tried to withdraw $1999, they only needed to burn one share. Free money.

Wise Lending

Wise Lending was hacked on January 13th for just under $460,000.

Again, share prices were inflated artificially high. However, the rounding direction seemed to be correct. This was a new variant.

This is the code responsible for checking if a withdrawal is valid. As a hint, a critical invariant for lending protocols is that there's no way to atomically self-bankrupt.

uint256 withdrawValue = WISE_ORACLE.getTokensInETH(
    _poolToken,
    _amount
)
    * WISE_LENDING.lendingPoolData(_poolToken).collateralFactor
    / PRECISION_FACTOR_E18;

bool state = borrowPercentageCap
    * (overallETHCollateralsWeighted(_nftId) - withdrawValue)
    / PRECISION_FACTOR_E18
    < borrowAmount;

if (state == true) {
    revert ResultsInBadDebt();
}

The critical observation is that this code operates on token amounts, while the internal accounting necessarily operates on shares.

Consider: you have one share worth $1000 and (correctly) can borrow $500. If you tried to withdraw $1, the code would round up to withdraw your one share worth $1000, causing you to be immediately liquidatable!

And indeed, Wise Lending rounds up the share value.

function _calculateShares(
    uint256 _product,
    uint256 _pseudo,
    bool _maxSharePrice
)
    private
    pure
    returns (uint256)
{
    return _maxSharePrice == true
        ? _product % _pseudo == 0
            ? _product / _pseudo
            : _product / _pseudo + 1
        : _product / _pseudo;
}

Regardless of which way the share rounding occurs, this is a bug. The correct way would be to do calculations in units of shares and force users to withdraw in increments of shares (and then round down the tokens ultimately received in the end).

This is a really tricky invariant to reason about!

Root Cause

Even though this sort of exploit seems pervasive, it requires quite a lot of factors to be exploitable.

Most importantly, the share value needs to be inflatable. Usually, this requires an integer representation for both shares and tokens. The conversion rate also needs to be expressed in terms of the shares and tokens as opposed to being stored separately.

totalDepositShares * _amount / pseudoTotalPool

The second critical requirement is a generally empty pool. Inflating the share value means that all other shares also rise in value. If there are shares that are not controlled by the attacker, this would mean giving other users free money, almost definitely stopping inflation attacks.

Finally, there must be improper rounding or accounting. This last requirement is generally easiest to satisfy. Share rounding is a new attack vector, and people haven't thought carefully about proper treatment of dust. Have you analyzed every integer division?

Mitigations

The easiest way to prevent this attack is to prevent share values from being manipulated. An unexpectedly high share value can lead to denial of service scenarios and is probably worth mitigating by itself.

The best way is to ensure that the pool has some amount of deposits on deployment, whether operationally or programmatically. As @danielvf notes, protocols like Uniswap burn a portion of the initial deposit for this very reason.

if (_totalSupply == 0) {
    liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
   _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
} else {

Alternatively, storing the conversion rate separately can also suffice. A key factor is that depositing additional tokens or burning shares affects the conversion rate. If the conversion rate is hardcoded and updated only during interest accrual, there's nothing to manipulate.

accrualIndices.borrowed = accrualIndices.borrowed * borrowInterestFactor / precision;
accrualIndices.deposited = accrualIndices.deposited * depositInterestFactor / precision;

We also want to note some general takeaways:

Invariant testing is overhyped, but is quite applicable here. Instead of attempting to reason about effects after a state change, apply the state changes and check the invariant.

From a protocol design perspective, users are withdrawing shares, not tokens. This is an important distinction. Your accounting logic should reason in terms of shares when possible.

And finally, correct rounding behavior should still be accounted for, even if it doesn't seem impactful.

Conclusion

Rounding forces protocol developers to think carefully about dust. It's not always enough to round against the user. While initially this seems like a novel, scary attack vector, much of the impact can be mitigated operationally.

As a final exercise to the reader: what is the correct rounding behavior during liquidations?