//Prisoner’s Dilemma
>TL;DR
- The challenge is a TCP service that runs a Solana ProgramTest harness for 10 rounds.
- Player 1 secretly chooses Split (0) or Steal (1) and commits to
commitment = hash(choice || player_one_pubkey), but only the first 8 bytes are kept (rest zeros). - Our solver program reads the game account, recomputes the two possible commitments, deduces Player 1’s choice, and CPI-calls the challenge program’s
play(choice). - We automate the server’s “send program + send instruction” protocol with a small Python client. For the remote, the service is TLS-wrapped (
ncat --ssl), so the client supports-tls.
Flag (remote): nite{y0u_n3v#r_h4d3_4_Ch4nc3}
>1) What the server expects (protocol + flow)
The server is a TCP listener and uses a helper framework to:
- Ask the client for our solver program id (
program pubkey:) - Ask the client for our solver .so size + the raw bytes (
program len:) - Print some pubkeys (
program:,player_one:,player_two:) - For each game:
- Create a new game PDA/account, store Player 1’s commitment, and print
game_account: ... - Ask the client to provide the accounts + instruction data for calling our solver (
num accounts:thenix len:)
- Create a new game PDA/account, store Player 1’s commitment, and print
You can see the exact logic in the server at:
Important detail: after reading our instruction, the server patches account 0 (the game account) to the fresh game pubkey for that round.
>2) Understanding the commitment (the core exploit)
Inside the server harness, Player 1’s commitment is created like this:
- payload:
choice (1 byte) || player_one_pubkey (32 bytes) - compute Solana hash over payload
- commitment stored in the game account is:
commitment[0..8] = hash(payload)[0..8]
commitment[8..32] = 0
So Player 1 has only 2 possibilities each round:
commitment(choice=0)(Split)commitment(choice=1)(Steal)
Our solver can read player_one + commitment from the on-chain game account and just compare.
>3) Building the solver: architecture
There are two pieces:
- On-chain solver program (
solver.so)
- Exposes one instruction:
predict. - Reads the game state.
- Computes which commitment matches.
- CPI calls the challenge program
solchal::play(choice).
- Off-chain client (
prisoners_client.py)
- Speaks the server’s line-based protocol.
- Uploads
solver.so. - Provides the required account metas.
- Sends the instruction data (Anchor discriminator for
predict). - Supports
-tlsto talk to the remotencat --sslservice.
>4) The first bug we hit (and why)
My first attempt manually parsed the game account bytes using hardcoded offsets. It looked correct, but in practice the server logs showed:
Program log: AnchorError thrown in src/lib.rs:23. Error Code: UnknownCommitment.
(When we ran the server with logging redirected, we captured this exact failure.)
Root cause
Manual byte slicing is brittle in Anchor/Borsh land (especially around enums/options and how accounts are validated/owned). The fix was to let Anchor deserialize the account for us and type the game account as:
Account<'info, solchal::Game>
This does two good things:
- Gives correct field values (
player_one,commitment) without offsets. - Ensures the passed account is actually owned by the expected program.
After this change, UnknownCommitment disappeared and the solver won 10/10.
>5) Final solver program (FULL CODE)
File: handout/challenge/solver/src/lib.rs
use anchor_lang::prelude::*;
use solana_program::hash::hash;
use solchal::{program::Solchal as SolchalProgram, Game as SolchalGame};
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod solver {
use super::*;
pub fn predict(ctx: Context<Predict>) -> Result<()> {
let game_account = &ctx.accounts.game;
let player_one = game_account.player_one;
let commitment = game_account.commitment;
let expected_split = commitment_for_choice(0, &player_one);
let expected_steal = commitment_for_choice(1, &player_one);
let choice = if commitment == expected_split {
0u8
} else if commitment == expected_steal {
1u8
} else {
return err!(SolverError::UnknownCommitment);
};
let cpi_program = ctx.accounts.solchal_program.to_account_info();
let cpi_accounts = solchal::cpi::accounts::Play {
game: ctx.accounts.game.to_account_info(),
player_two: ctx.accounts.player_two.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
};
solchal::cpi::play(CpiContext::new(cpi_program, cpi_accounts), choice)
}
}
fn commitment_for_choice(choice: u8, player_one: &Pubkey) -> [u8; 32] {
let mut payload = Vec::with_capacity(1 + 32);
payload.push(choice);
payload.extend_from_slice(&player_one.to_bytes());
let hash_bytes = hash(&payload).to_bytes();
let mut output = [0u8; 32];
output[..8].copy_from_slice(&hash_bytes[..8]);
output
}
#[derive(Accounts)]
pub struct Predict<'info> {
#[account(mut)]
pub game: Account<'info, SolchalGame>,
#[account(mut)]
pub player_two: Signer<'info>,
pub solchal_program: Program<'info, SolchalProgram>,
pub system_program: Program<'info, System>,
}
#[error_code]
pub enum SolverError {
#[msg("Commitment does not match any expected choice")]
UnknownCommitment,
}
>6) Instruction data: Anchor discriminator
Anchor instructions use an 8-byte discriminator:
discriminator = sha256("global:<ix_name>")[:8]
For predict, this becomes:
python - <<'PY'
import hashlib
print(hashlib.sha256(b"global:predict").digest()[:8].hex())
PY
# fe7270f425312080
So the instruction data we send is exactly the 8 bytes:
fe7270f425312080
>7) Client automation (FULL CODE)
File: prisoners_client.py
This script:
- uploads the compiled
solver.so - supplies accounts in the required format
- optionally wraps the socket with TLS (
-tls) for the remote service
#!/usr/bin/env python3
"""Interact with the Prisoner's Dilemma challenge server."""
from __future__ import annotations
import argparse
import pathlib
import socket
import ssl
import sys
from typing import Optional
SOLVER_PROGRAM_ID = "Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS"
CHALLENGE_PROGRAM_ID = "EHXFQw6oZ7xTHiBJwnMq3ApnEi13ErgZXa3cVZxHDE84"
SYSTEM_PROGRAM_ID = "11111111111111111111111111111111"
DUMMY_GAME_ACCOUNT = SYSTEM_PROGRAM_ID # placeholder overwritten by server
# Anchor discriminant for solver::predict (Sha256("global:predict")[:8])
PREDICT_IX_DATA = bytes.fromhex("fe7270f425312080")
def read_solver_binary(path: pathlib.Path) -> bytes:
data = path.read_bytes()
if len(data) > 10_000_000:
raise ValueError("solver binary is unexpectedly large")
return data
def send_line(sock: socket.socket, line: str) -> None:
sock.sendall(line.encode("ascii") + b"\\n")
def send_accounts(sock: socket.socket, player_two: str, challenge_program: str) -> None:
lines = [
f"w {DUMMY_GAME_ACCOUNT}",
f"sw {player_two}",
f"r {challenge_program}",
f"r {SYSTEM_PROGRAM_ID}",
]
send_line(sock, str(len(lines)))
for entry in lines:
send_line(sock, entry)
def send_instruction(sock: socket.socket) -> None:
send_line(sock, str(len(PREDICT_IX_DATA)))
sock.sendall(PREDICT_IX_DATA)
def run_client(host: str, port: int, so_path: pathlib.Path, use_tls: bool) -> int:
solver_so = read_solver_binary(so_path)
with socket.create_connection((host, port)) as raw_sock:
sock: socket.socket
if use_tls:
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
sock = context.wrap_socket(raw_sock, server_hostname=host)
else:
sock = raw_sock
fp = sock.makefile("rb")
player_two: Optional[str] = None
challenge_program = CHALLENGE_PROGRAM_ID
waiting_for_accounts = False
while True:
line = fp.readline()
if not line:
break
decoded = line.decode(errors="replace").strip()
print(decoded)
if decoded.startswith("program pubkey"):
send_line(sock, SOLVER_PROGRAM_ID)
elif decoded.startswith("program len"):
send_line(sock, str(len(solver_so)))
sock.sendall(solver_so)
elif decoded.startswith("program:"):
challenge_program = decoded.split(":", 1)[1].strip()
elif decoded.startswith("player_two:"):
player_two = decoded.split(":", 1)[1].strip()
elif decoded.startswith("num accounts"):
if player_two is None:
raise RuntimeError("player_two pubkey unknown when accounts requested")
send_accounts(sock, player_two, challenge_program)
waiting_for_accounts = True
elif decoded.startswith("ix len") and waiting_for_accounts:
send_instruction(sock)
waiting_for_accounts = False
elif decoded.startswith("Congratulations!!"):
# Flag follows; keep printing until socket closes
continue
return 0
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("host", nargs="?", default="127.0.0.1")
parser.add_argument("port", nargs="?", type=int, default=5002)
parser.add_argument(
"--solver",
type=pathlib.Path,
default=pathlib.Path("handout/challenge/solver/target/deploy/solver.so"),
)
parser.add_argument(
"--tls",
action="store_true",
help="wrap the TCP connection in TLS (useful for remote nc --ssl endpoints)",
)
args = parser.parse_args(argv)
return run_client(args.host, args.port, args.solver, args.tls)
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
>8) Build steps
Build the solver program (.so)
From the repo root, I built the solver using the provided Solana/Agave toolchain:
cd handout/challenge/solver
PATH=/tmp/agave-v3.0.12/solana-release/bin:$PATH cargo build-sbf
This outputs:
handout/challenge/solver/target/deploy/solver.so
Note: In my environment the build required fixing a permissions issue so Cargo could update Cargo.lock:
bashchmod u+w handout/challenge/solver/Cargo.lock
>9) Local success (proof)
Local server
cd handout/challenge/server
PATH=/tmp/agave-v3.0.12/solana-release/bin:$PATH cargo run --release
Local client run
/home/noigel/Desktop/next_hunt/ByteDoubleCross/.venv/bin/python prisoners_client.py
Observed local output (excerpt, 10/10):
Game 1 of 10
...
Win! (P1 choice: 0, Net change: 1999995000)
...
Game 10 of 10
...
Win! (P1 choice: 0, Net change: 1999995000)
Congratulations!!
Flag: nite{fake_flag}
>10) Remote success (the real solve)
The remote endpoint is TLS-wrapped:
ncat --ssl prisoners.chals.nitectf25.live 1337
So I ran the client with --tls:
/home/noigel/Desktop/next_hunt/ByteDoubleCross/.venv/bin/python \\
prisoners_client.py prisoners.chals.nitectf25.live 1337 --tls
Observed remote output (full 10/10 + flag):
program pubkey:
program len:
program: EHXFQw6oZ7xTHiBJwnMq3ApnEi13ErgZXa3cVZxHDE84
player_one: CMkAr4rhwLnvqqAt8bHwApTCx8mV3K8s2RVrxzXQCAxw
player_two: 3bP6eCpw6LRoWS1uCgAgirpmqNBxtFufX3yr3Srr5aWo
program: EHXFQw6oZ7xTHiBJwnMq3ApnEi13ErgZXa3cVZxHDE84
player_one: CMkAr4rhwLnvqqAt8bHwApTCx8mV3K8s2RVrxzXQCAxw
player_two: 3bP6eCpw6LRoWS1uCgAgirpmqNBxtFufX3yr3Srr5aWo
Game 1 of 10
game_account: H6jMg9R5JRTNANLWDmtCcCLBTfDi1Vud4fBwjtgpLhak
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 2 of 10
game_account: 7AaZ3fEdVuaXSxKKxtgKDByR9TxAdD6biuni2MBDQNML
num accounts:
ix len:
Win! (P1 choice: 0, Net change: 1999995000)
Game 3 of 10
game_account: BbniGidU7gbgSXhpzwdERwpNRtyvWKPsZdDh4aQCv73T
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 4 of 10
game_account: HQnA22tYxSAktEv5hKXUvscBPkZvddx2HLaWc1yT8brg
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 5 of 10
game_account: 9fRGeNfRcoXejwSCbo3FypTFj6B8wFYNahgwXo8k7mTu
num accounts:
ix len:
Win! (P1 choice: 0, Net change: 1999995000)
Game 6 of 10
game_account: ENaeg5hkdNmXG9oDpVVGEgTaVwo4uXFmLfF6cDTP8x8d
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 7 of 10
game_account: 6JKQf8XEPuD3u8Yfq8dtkhQosZTixkWqEVButwhjsTMX
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 8 of 10
game_account: 4C611D7MGuoGkYXGuCHuCU3NkecqtoSH5AbnSa6p4wVL
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 9 of 10
game_account: EbQsjrQdLDssWjazSs2AyNLvvnyG1TToFYvbpp99Foci
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 10 of 10
game_account: 24e5Miphyg8Q9YKnsRYmtCQMFuYwt31qL8TmbjeQw4jz
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Congratulations!!
Flag: nite{y0u_n3v#r_h4d3_4_Ch4nc3}
>11) Why this is deterministic
Every round, Player 1’s choice is committed using a public rule and stored directly in the game account. There is no randomness or cryptographic hiding beyond “which of two hashes is it”, and the domain is size 2.
So the winning strategy is always:
- Read
player_oneandcommitmentfrom the game. - Compute the two commitments.
- Pick the matching choice.
That guarantees 10/10 wins.
>Quick run commands
Local:
# terminal 1
cd handout/challenge/server
PATH=/tmp/agave-v3.0.12/solana-release/bin:$PATH cargo run --release
# terminal 2
/home/noigel/Desktop/next_hunt/ByteDoubleCross/.venv/bin/python prisoners_client.py
Remote:
/home/noigel/Desktop/next_hunt/ByteDoubleCross/.venv/bin/python \\
prisoners_client.py prisoners.chals.nitectf25.live 1337 --tls
Local Flag:
```bash
env -C /home/noigel/Desktop/next_hunt/ByteDoubleCross /home/noigel/Desktop/next_hunt/ByteDoubleCross/.venv/bin/python prisoners_client.py
program pubkey:
program len:
program: EHXFQw6oZ7xTHiBJwnMq3ApnEi13ErgZXa3cVZxHDE84
player_one: 3fJ7xmbtah4ExcGktFz1f2EEktDHArTsHw1P2qpZdXdW
player_two: EXuuCcbhqKEMQ6GE5aQMQE678B9y9ehU44qtpGNdj74w
program: EHXFQw6oZ7xTHiBJwnMq3ApnEi13ErgZXa3cVZxHDE84
player_one: 3fJ7xmbtah4ExcGktFz1f2EEktDHArTsHw1P2qpZdXdW
player_two: EXuuCcbhqKEMQ6GE5aQMQE678B9y9ehU44qtpGNdj74w
Game 1 of 10
game_account: Fy5pVnejjF7fxFxmVHNU9DuWt31e9yWnYcC2K4sdMqHV
num accounts:
ix len:
Win! (P1 choice: 0, Net change: 1999995000)
Game 2 of 10
game_account: 9sHiupDv44u6yaMhsm7kLmetzMo2o2GCnA5nGb56vhEC
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 3 of 10
game_account: FkJahiJrnZUT6ZSyXmH95C75dWwMZv338jPscxMjaM5F
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 4 of 10
game_account: RDb7R5oech2pE9HNhJwT4pJEpYRMvMwjtx7eyjzdo6B
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 5 of 10
game_account: JABpBBGeuFASr3XaBoPXmzxd99uLWLgh4yGD6MvxpTod
num accounts:
ix len:
Win! (P1 choice: 0, Net change: 1999995000)
Game 6 of 10
game_account: AA31WvpcQhmJ7HNKpGtS6rCMZtRH1ZkesVHDSoaabh19
num accounts:
ix len:
Win! (P1 choice: 0, Net change: 1999995000)
Game 7 of 10
game_account: 4WKW5KwytYRoaqq7DzYJBUp3wuXFZAJUHx1MwTYBb5WW
num accounts:
ix len:
Win! (P1 choice: 0, Net change: 1999995000)
Game 8 of 10
game_account: 539gs31zf7eSrgqCaf86KJm1Gm9ANvPAEe61skyfX5Ft
num accounts:
ix len:
Win! (P1 choice: 0, Net change: 1999995000)
Game 9 of 10
game_account: 6nTnjBc5tAVV29BJy7a7G25T8DQ2XmQTSUipxsADZrxz
num accounts:
ix len:
Win! (P1 choice: 0, Net change: 1999995000)
Game 10 of 10
game_account: ErtbjedZNfmBgpwfLGhTCPZxxKiJrmrveccidBjB8zBa
num accounts:
ix len:
Win! (P1 choice: 0, Net change: 1999995000)
Congratulations!!
Flag: nite{fake_flag}
Remote Flag:
env -C /home/noigel/Desktop/next_hunt/ByteDoubleCross /home/noigel/Desktop/next_hunt/ByteDoubleCross/.venv/bin/python prisoners_client.py pris
oners.chals.nitectf25.live 1337 --tls
program pubkey:
program len:
program: EHXFQw6oZ7xTHiBJwnMq3ApnEi13ErgZXa3cVZxHDE84
player_one: CMkAr4rhwLnvqqAt8bHwApTCx8mV3K8s2RVrxzXQCAxw
player_two: 3bP6eCpw6LRoWS1uCgAgirpmqNBxtFufX3yr3Srr5aWo
program: EHXFQw6oZ7xTHiBJwnMq3ApnEi13ErgZXa3cVZxHDE84
player_one: CMkAr4rhwLnvqqAt8bHwApTCx8mV3K8s2RVrxzXQCAxw
player_two: 3bP6eCpw6LRoWS1uCgAgirpmqNBxtFufX3yr3Srr5aWo
Game 1 of 10
game_account: H6jMg9R5JRTNANLWDmtCcCLBTfDi1Vud4fBwjtgpLhak
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 2 of 10
game_account: 7AaZ3fEdVuaXSxKKxtgKDByR9TxAdD6biuni2MBDQNML
num accounts:
ix len:
Win! (P1 choice: 0, Net change: 1999995000)
Game 3 of 10
game_account: BbniGidU7gbgSXhpzwdERwpNRtyvWKPsZdDh4aQCv73T
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 4 of 10
game_account: HQnA22tYxSAktEv5hKXUvscBPkZvddx2HLaWc1yT8brg
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 5 of 10
game_account: 9fRGeNfRcoXejwSCbo3FypTFj6B8wFYNahgwXo8k7mTu
num accounts:
ix len:
Win! (P1 choice: 0, Net change: 1999995000)
Game 6 of 10
game_account: ENaeg5hkdNmXG9oDpVVGEgTaVwo4uXFmLfF6cDTP8x8d
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 7 of 10
game_account: 6JKQf8XEPuD3u8Yfq8dtkhQosZTixkWqEVButwhjsTMX
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 8 of 10
game_account: 4C611D7MGuoGkYXGuCHuCU3NkecqtoSH5AbnSa6p4wVL
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 9 of 10
game_account: EbQsjrQdLDssWjazSs2AyNLvvnyG1TToFYvbpp99Foci
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Game 10 of 10
game_account: 24e5Miphyg8Q9YKnsRYmtCQMFuYwt31qL8TmbjeQw4jz
num accounts:
ix len:
Win! (P1 choice: 1, Net change: -5000)
Congratulations!!
Flag: nite{y0u_n3v#r_h4d3_4_Ch4nc3}