corCTF 2024 — msfrogofwar3
We're given a Flask server that looks like this:
Code (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
,
Code (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"!
Code (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
:
Code (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,
Code (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 looks like winning lets us push unchecked moves to self.moves
, which then get passed to engine.is_move_correct
:
Code (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.
Code (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
,
Code (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
Code (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
Therefore, by circumventing the move checking, we can control move_value
and 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}