← Back to writeups

corCTF 2024 — msfrogofwar3

image

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"!

image

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:

image

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}