Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
BlockchainHard

Prisoner’S Dilemma

CTF writeup for Prisoner’S Dilemma from niteCTF

//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:

  1. Ask the client for our solver program id (program pubkey:)
  2. Ask the client for our solver .so size + the raw bytes (program len:)
  3. Print some pubkeys (program:, player_one:, player_two:)
  4. 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: then ix len:)

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:

  1. 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).
  1. 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 -tls to talk to the remote ncat --ssl service.

>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

rust
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:

bash
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
python
#!/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:

bash
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:

bash
chmod u+w handout/challenge/solver/Cargo.lock

>9) Local success (proof)

Local server

bash
cd handout/challenge/server
PATH=/tmp/agave-v3.0.12/solana-release/bin:$PATH cargo run --release

Local client run

bash
/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:

bash
/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:

  1. Read player_one and commitment from the game.
  2. Compute the two commitments.
  3. Pick the matching choice.

That guarantees 10/10 wins.


>Quick run commands

Local:

bash
# 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:

bash
/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:

bash
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}