A very helpful sign

I was trying to exploit a function in Thena protocol that allows users to merge two NFTs into one. It’s a functionality I haven’t seen before, so I thought it could be a soft spot worth investigating.

I threw at it every trick I had in the bag, without much success. At this point I’m used to it, most of the time my ideas just don’t work. I spent a big chunk of time on it and at last decided to move on.

I moved my attention to another uncommon, but related, function. One that allows splitting a single NFT into multiple ones.

That’s when I stumbled upon a very helpful sign:

// reset supply, _deposit_for increase it
supply = supply - value;

I paused for a moment. Splitting and merging are two complementary operations, but the merge function I tried to exploit for the past several hours wasn’t resetting the supply. How did I miss that? Maybe it actually shouldn’t, but the discrepancy was worth re-investigating.

The bug

In Thena users can lock an amount of tokens for a period of time to get a veNFT, which represents voting power in the protocol. The merge() function allows users to merge two different veNFT into one, it looks like this:

function merge(uint _from, uint _to) external {

    ...SNIPPET...

    LockedBalance memory _locked0 = locked[_from];
    LockedBalance memory _locked1 = locked[_to];
    uint value0 = uint(int256(_locked0.amount));
    uint end = _locked0.end >= _locked1.end ? _locked0.end : _locked1.end;

    ...SNIPPET...

    _burn(_from);
    _deposit_for(_to, value0, end, _locked1, DepositType.MERGE_TYPE);
}

It takes two veNFT as input, _from and _to, and then does two things:

  1. [L12] Burns _from, by calling _burn(_from)
  2. [L13] Adds the amount that was locked in _from to _to, by calling _deposit_for(...):
function _deposit_for(uint _tokenId,
    uint _value,
    uint unlock_time,
    LockedBalance memory locked_balance,
    DepositType deposit_type
) internal {

    ...SNIPPET...

    uint supply_before = supply;
    supply = supply_before + _value;

    ...SNIPPET...

}

which takes _value, the amount of tokens locked in _from, and adds it to supply, a variable responsible for keeping track of the current amount of tokens locked in the system.

However, merging two veNFT should not increase the amount of tokens locked in the system, since the tokens are already locked in.

The 3 exploits

Cool, now we have a bug. What’s the maximum amount of damage we can cause by abusing it?

What we can do is artificially inflate the supply variable by merging veNFTs over and over again. After some research it turned out the supply variable is used for two critical purposes:

  • [A] Calculating the amount of weekly emissions
  • [B] Calculating the share of weekly emissions destined to veNFT holders

[A] Calculating the amount of weekly emissions

Here the supply variable is accessed as _ve.supply(), [L3]:

function weekly_emission() public view returns (uint) {
    uint256 calculate_emission = (weekly * EMISSION) / PRECISION;
    uint256 circulating_emission = (_thena.totalSupply() - _ve.supply() * TAIL_EMISSION) / PRECISION;

    return Math.max(calculate_emission, circulating_emission);
}

It returns the highest value, at [L5], between calculate_emission, which we can’t control, and circulating_emission which interests us because of the operation:

_thena.totalSupply() - _ve.supply() * TAIL_EMISSION

Knowing we can increase _ve.supply() at will, we have the choice between:

  1. Having the operation return a lower value than expected: we can lower the amount of weekly emissions (in certain states)
  2. Having the operation revert by underflow: we increase _ve.supply() until _ve.supply() * TAIL_EMISSION is bigger than _thena.totalSupply() to DOS the emissions of weekly rewards

[B] Calculating the share of weekly emissions destined to veNFT holders

The share is calculated as follows, where the supply variable is called as _ve.supply(), [L2]:

function calculate_rebate(uint _weeklyMint) public view returns (uint) {
    uint lockedShare = _ve.supply() * PRECISION  / _thena.totalSupply();
    if(lockedShare >= REBASEMAX){
        return _weeklyMint * REBASEMAX / PRECISION;
    } else {
        return _weeklyMint * lockedShare / PRECISION;
    }
}

the function calculates the percentage of tokens locked in the system at [L2], knowing we can increase _ve.supply() at will we can:

  1. Trick the function into believing the percentage of tokens locked is higher than expected: we can increase the share of weekly emissions given to veNFT holders up to a maximum of REBASEMAX

Disclosure

The bug leads to an attacker being able to:

  • lower the amount of weekly emissions
  • DOS the emissions of weekly rewards
  • increase the share of weekly emissions given to veNFT holders

On 11/01/22, in collaboration with trust__90, we disclosed the bug to Thena via immunefi. It was classified as high severity and rewarded a bounty of $20k.