2026-07-12 20:22:15 +01:00

216 lines
7.3 KiB
Python

#!/usr/bin/env python3
import os
import sys
import socketserver
import random
import io
FLAG_PRICE = 1_000_000
INITIAL_COINS = 50 # tiny starting balance
MAX_TURNS = 10
MAX_LEGAL_LOAN = 1000 # max positive amount per loan
def load_flag():
# Flag is supplied via environment variable
return os.getenv("FLAG", "XMAS{dummy_flag_for_testing}")
def to_int(s: str) -> int:
return int(s.strip())
def add_coins(balance: int, delta: int) -> int:
"""
Update the balance using 32-bit unsigned wraparound.
This is the core bug: using a 32-bit "safe" ledger but letting the
user control delta directly (including large negatives).
"""
return (balance + delta) & 0xFFFFFFFF
def play_game(reader, writer):
"""
Core game logic. Works with any text reader/writer pair.
- CLI mode: stdin/stdout
- TCP mode: wrapped socket rfile/wfile
"""
flag = load_flag()
coins = INITIAL_COINS
price = FLAG_PRICE
turns = 0
def send(line: str = ""):
writer.write(line + "\n")
writer.flush()
send("=== Grinch's Lottery ===")
send("Welcome to Whoville's shadiest Christmas lottery!")
send("You start with a few coal-coins. Win enough to buy the special prize.")
send(f"The shiny present costs {price} coins.")
send("")
while turns < MAX_TURNS:
turns += 1
send(f"\n[Turn {turns}/{MAX_TURNS}] Your balance: {coins} coins")
send("Choose an action:")
send(" 1) Work a little (earn some coins)")
send(" 2) Buy lottery tickets (10 coins each)")
send(" 3) Take a shady loan from the Grinch")
send(" 4) Buy the shiny present")
send(" 5) Exit")
send("> ")
choice = reader.readline()
if not choice:
break
choice = choice.strip()
if choice == "1":
# Small honest income, no way to reach the flag fairly
earn = random.randint(1, 20)
coins = add_coins(coins, earn)
send(f"You did some odd jobs for the Whos and earned {earn} coins.")
elif choice == "2":
# Lottery tickets that cost coins and rarely pay out
send("How many tickets do you want to buy? (10 coins each)")
send("> ")
line = reader.readline()
if not line:
break
try:
n = to_int(line)
except ValueError:
send("That's not even a number. The Grinch laughs you out of town.")
break
# Make lottery 'honest' so it's not the exploit:
if n <= 0:
send("You must buy at least 1 ticket, no refunds.")
continue
cost = 10 * n
# If you can't afford it, don't touch the balance at all
if cost > coins:
send("You don't have enough coins for that many tickets.")
send("The Grinch refuses to give you credit for lottery tickets.")
continue
# Now this subtraction is safe (no wrap) because cost <= coins
coins = add_coins(coins, -cost)
send(f"You bought {n} tickets for {cost} coins.")
wins = 0
# Cap iteration count so people can't hang the server
for _ in range(min(n, 1000)):
if random.random() < 0.05:
prize = random.randint(5, 50)
coins = add_coins(coins, prize)
wins += 1
if wins:
send(f"You won on {wins} ticket(s)! Your luck slightly improves.")
else:
send("No luck this time. The Grinch grins.")
elif choice == "3":
# Shady loan service with a "safety limit"... that only checks positive amounts.
send("How much do you want from the Grinch's loan shark service?")
send("> ")
line = reader.readline()
if not line:
break
try:
delta = to_int(line)
except ValueError:
send("You mumble nonsense. The Grinch gets bored and leaves.")
break
# Safety check that *only* constrains positive loans.
if delta > MAX_LEGAL_LOAN:
send("Too risky! The Grinch's auditor caps loans at "
f"{MAX_LEGAL_LOAN} coins per request.")
send("Smaller loans only, please.")
continue
# Negative values are not checked at all. This is the intended bug:
# a large negative 'repayment' will underflow and wrap to a huge balance.
before = coins
coins = add_coins(coins, delta)
send(f"Ledger update: {before} -> {coins} (delta: {delta})")
elif choice == "4":
# Check if the player can afford the flag
if coins >= price:
send("You jingle enough coins to buy the shiny present!")
send(f"Inside you find a note: {flag}")
send("Merry XMAS and beware of greedy Grinches.")
return
else:
price += FLAG_PRICE # price goes up if you try to buy without enough coins
send(f"Not enough coins. The Grinch cackles and raises the price to {price} coins.")
elif choice == "5":
send("You leave the lottery, slightly colder but maybe wiser.")
return
else:
send("The Grinch doesn't recognize that option and kicks you out.")
return
send("The Grinch closes the stand for the night. Come back with a better plan.")
# === TCP server glue (thread-per-connection, like gunicorn workers) ===
class GrinchHandler(socketserver.StreamRequestHandler):
def handle(self):
# Simple timeout so connections don't hang forever
self.request.settimeout(60)
# Wrap the raw socket file descriptors into text-mode file objects
reader = io.TextIOWrapper(self.rfile, encoding="utf-8", newline="\n")
writer = io.TextIOWrapper(
self.wfile,
encoding="utf-8",
newline="\n",
write_through=True,
)
try:
play_game(reader, writer)
except Exception:
# Hide internals from players
try:
writer.write("An error occurred. The Grinch eats the stack trace.\n")
writer.flush()
except Exception:
pass
class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
# Basic "load balancing": one thread per client connection
allow_reuse_address = True
daemon_threads = True
def run_server(host: str = "0.0.0.0", port: int = 9000):
with ThreadedTCPServer((host, port), GrinchHandler) as server:
server.serve_forever()
if __name__ == "__main__":
# Usage:
# ./grinch_lottery.py -> CLI mode on stdin/stdout
# ./grinch_lottery.py server -> TCP server 0.0.0.0:9000
# ./grinch_lottery.py server HOST PORT
if len(sys.argv) >= 2 and sys.argv[1] == "server":
host = sys.argv[2] if len(sys.argv) >= 3 else "0.0.0.0"
port = int(sys.argv[3]) if len(sys.argv) >= 4 else 9000
run_server(host, port)
else:
# CLI mode: interact via stdin/stdout (no network)
reader = sys.stdin
writer = sys.stdout
play_game(reader, writer)