← Back to home

corCTF 2024 — msfrogofwar3

image

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

image

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:

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}