Intro

On 18/6/2022 the Schnoodle protocol was exploited, leading to all liquidity in the UniswapV2Pair token being drained. The attacker stole ~104.04 ETH which they passed through TornadoCash. I heard about this hack through a weekly security newsletter and decided out of curiosity to have a look into how it happened. In this post I will explain how the exploit occurred.

Relevant contracts and transactions

Transactions:

Addresses:

Transaction explanation

  1. Attacker deploys the malicious contract.
  2. Malicious contract constructor calls Schnoodle.balanceOf() to get the balance of the UniswapV2Pair.
  3. Malicious contract calls Schnoodle.transferFrom() to transfer UniswapV2Pair_balance - 1 SNOOD tokens from the UniswapV2Pair address to itself (the malicious contract).
  4. Malicious contract calls UniswapV2Pair.sync() to update exchange rate prices for the asset pair SNOOD-WETH. Since there is only one SNOOD token in the balance of the UniswapV2Pair contract the exchange rate for SNOOD to WETH is extremely favorable.
  5. Malicious contract sends SNOOD tokens back to UniswapV2Pair using Schnoodle.transfer(). This does not affect the exchange rate, as UniswapV2Pair.sync() has not been called.
  6. Malicious contract calls UniswapV2Pair.getReserves() to get the balance of both WETH and SNOOD in the pair contract.
  7. Malicious contract calls UniswapV2Pair.swap() to swap SNOOD tokens for WETH. Because of the favorable exchange rate the malicious contract successfully swaps SNOOD tokens for all WETH held by the pair.

The vulnerability

After reading the transaction explanation, what immediately stands out as unusual is how the attacker contract can simply call transferFrom() to move the tokens from the liquidity pool to its own address. How is that possible? Let’s have a look at what transferFrom() looks like:

function _spendAllowance(address owner, address spender, uint256 amount) internal override {
	super._spendAllowance(owner, spender, _getStandardAmount(amount));
}

The main _spendAllowance() makes a direct call to its inherited _spendAllowance(). The only difference between these two functions is that the amount is set to _getStandardAmount(amount). There must be an issue here than is making the allowance check pass.

function _getStandardAmount(uint256 reflectedAmount) internal view returns(uint256) {
    // Condition prevents a divide-by-zero error when the total supply is zero
    return reflectedAmount == 0 ? 0 : reflectedAmount / _getReflectRate();
}

We can see that the amount to be transferred with transferFrom() is divided by _getReflectRate(). Let’s continue to follow this call trace and see how _getReflectRate() works.

function _getReflectRate() private view returns(uint256) {
    uint256 reflectedTotalSupply = super.totalSupply();
    return reflectedTotalSupply == 0 ? 0 : reflectedTotalSupply / totalSupply();
}

This token seems to have two total supplies: SchnoodleV9Base._totalSupply and ERC777Upgradeable._totalSupply (the super contract). We can see what values would have been returned at the time of the exploit with a fantastic tool called seth.

# SchnoodleV9Base._totalSupply one block before the exploit
dev@dev:~$ seth storage -B 0xe4a1ef 0xD45740aB9ec920bEdBD9BAb2E863519E59731941 201
0x000000000000000000000000000000000000000c9b9d803a4c9a753f84bb5ff0
# Convert hex to decimal
seth --to-dec 0x000000000000000000000000000000000000000c9b9d803a4c9a753f84bb5ff0
998898533585500850354004910064

# ERC777Upgradeable._totalSupply one block before the exploit
dev@dev:~$ seth storage -B 0xe4a1ef 0xD45740aB9ec920bEdBD9BAb2E863519E59731941 52
0xe99b260e38daf5a5ccae87ad3070dac1956a24c99c6a6e9148162db91b30009f
# Convert hex to decimal
dev@dev:~$ seth --to-dec 0xe99b260e38daf5a5ccae87ad3070dac1956a24c99c6a6e9148162db91b30009f
105663017664729712515977440155796518417706405740928936711345055462521410945183

But why is the ERC777 total supply so much larger than the SchnoodleV9Base total supply? We can lookt at the initialize() function and see exactly how it happens:

function initialize(uint256 initialTokens, address serviceAccount) public initializer {
    __Ownable_init();
    _totalSupply = initialTokens * 10 ** decimals();
    __ERC777PresetFixedSupply_init("Schnoodle", "SNOOD", new address[](0), MAX - (MAX % totalSupply()), serviceAccount);
}

The ERC777 total supply is set to an incredibly high value upon initialization, so it appears that this vulnerability has existing in the protocol since it was first used.

Knowing this we can easily understand how the transferFrom issue works. During the exploit transaction the function _getReflectRate() will have returned:

ERC777Upgradeable._totalSupply / SchnoodleV9Base._totalSupply

Which is…

105663017664729712515977440155796518417706405740928936711345055462521410945183 / 998898533585500850354004910064

Which equals…

105779530264657729351148362897512512282775209192

So _getReflectRate() returns 105779530264657729351148362897512512282775209192 to _getStandardAmount(). _getStandardAmount() then attempts to divide reflectedAmount by 105779530264657729351148362897512512282775209192. In the attack transaction reflectedAmount is 32308960759206669952686933217. When the integer division in SchnoodleV9Base._getStandardAmount() is done this results in:

32308960759206669952686933217 / 105779530264657729351148362897512512282775209192

This results in zero, which is then passed to super._spendAllowance() at which point anyone can spend anyone elses funds.

From this point the rest of the exploit is pretty trivial. If you can transfer anyones tokens at will it’s very simple to manipulate the Uniswap oracle pricing and then take all drain all of the Ether.

Conclusion/TLDR

The protocol has two total supplies, with one being significantly larger than the other. During an allowance check in transferFrom() the smaller total supply is divided by the larger total supply and thanks to integer division this results in zero. The allowance check is now for an amount of zero so it passes even though the amount actually being transferred can be any value. The attacker, knowing they can transfer any balances from any account uses this to manipulate the Uniswap pricing to drain the pool of its liquidity.