Skip to content

Instantly share code, notes, and snippets.

@ky28059
Last active August 12, 2024 04:04
Show Gist options
  • Save ky28059/2f279fff831a5be3d6b5fbb8a6d86057 to your computer and use it in GitHub Desktop.
Save ky28059/2f279fff831a5be3d6b5fbb8a6d86057 to your computer and use it in GitHub Desktop.

corCTF 2024 — msfrogofwar3

image

We're given a Flask server that looks like this:

from flask import Flask, request, render_template
from flask_socketio import SocketIO, emit
from stockfish import Stockfish
import random

import chess
from stockfish import Stockfish

games = {}

toxic_msges = [
    "?",
    "rip bozo",
    "so bad lmfaoo",
    "ez",
    "skill issue",
    "mad cuz bad",
    "hold this L",
    "L + ratio + you fell off",
    "i bet your main category is stego",
    "have you tried alt+f4?",
    "🤡🤡🤡"
]

win_msges = [
    "lmaooooooooo ur so bad",
    "was that it?",
    "zzzzzzzzzzzzzzzzzzzzzz",
    "hopefully the next game wont be so quick",
    "nice try - jk that was horrible",
    "this aint checkers man"
]

TURN_LIMIT = 15
STOCKFISH_DEPTH = 21
FLAG = "corctf{this_is_a_fake_flag}"

class GameWrapper:
    def __init__(self, emit):
        self.emit = emit
        self.board = chess.Board(chess.STARTING_FEN)
        self.moves = []
        self.player_turn = True

    def get_player_state(self):
        legal_moves = [f"{m}" for m in self.board.legal_moves] if self.player_turn and self.board.fullmove_number < TURN_LIMIT else []

        status = "running"
        if self.board.fullmove_number >= TURN_LIMIT:
            status = "turn limit"

        if outcome := self.board.outcome():
            if outcome.winner is None:
                status = "draw"
            else:
                status = "win" if outcome.winner == chess.WHITE else "lose"

        return {
            "pos": self.board.fen(),
            "moves": legal_moves,
            "your_turn": self.player_turn,
            "status": status,
            "turn_counter": f"{self.board.fullmove_number} / {TURN_LIMIT} turns"
        }

    def play_move(self, uci):
        if not self.player_turn:
            return
        if self.board.fullmove_number >= TURN_LIMIT:
            return
        
        self.player_turn = False

        outcome = self.board.outcome()
        if outcome is None:
            try:
                move = chess.Move.from_uci(uci)
                if move:
                    if move not in self.board.legal_moves:
                        self.player_turn = True
                        self.emit('state', self.get_player_state())
                        self.emit("chat", {"name": "System", "msg": "Illegal move"})
                        return
                    self.board.push_uci(uci)
            except:
                self.player_turn = True
                self.emit('state', self.get_player_state())
                self.emit("chat", {"name": "System", "msg": "Invalid move format"})
                return
        elif outcome.winner != chess.WHITE:
            self.emit("chat", {"name": "🐸", "msg": "you lost, bozo"})
            return

        self.moves.append(uci)

        # stockfish has a habit of crashing
        # The following section is used to try to resolve this
        opponent_move, attempts = None, 0
        while not opponent_move and attempts <= 10:
            try:
                attempts += 1
                engine = Stockfish("./stockfish/stockfish-ubuntu-x86-64-avx2", parameters={"Threads": 4}, depth=STOCKFISH_DEPTH)
                for m in self.moves:
                    if engine.is_move_correct(m):
                        engine.make_moves_from_current_position([m])
                opponent_move = engine.get_best_move_time(3_000)
            except:
                pass

        if opponent_move != None:
            self.moves.append(opponent_move)
            opponent_move = chess.Move.from_uci(opponent_move)
            if self.board.is_capture(opponent_move):
                self.emit("chat", {"name": "🐸", "msg": random.choice(toxic_msges)})
            self.board.push(opponent_move)
            self.player_turn = True
            self.emit("state", self.get_player_state())

            if (outcome := self.board.outcome()) is not None:
                if outcome.termination == chess.Termination.CHECKMATE:
                    if outcome.winner == chess.BLACK:
                        self.emit("chat", {"name": "🐸", "msg": "Nice try... but not good enough 🐸"})
                    else:
                        self.emit("chat", {"name": "🐸", "msg": "how??????"})
                        self.emit("chat", {"name": "System", "msg": FLAG})
                else: # statemate, insufficient material, etc
                    self.emit("chat", {"name": "🐸", "msg": "That was close... but still not good enough 🐸"})
        else:
            self.emit("chat", {"name": "System", "msg": "An error occurred, please restart"})

app = Flask(__name__, static_url_path='', static_folder='static')
socketio = SocketIO(app, cors_allowed_origins='*')

@app.after_request
def add_header(response):
    response.headers['Cache-Control'] = 'max-age=604800'
    return response

@app.route('/')
def index_route():
    return render_template('index.html')

@socketio.on('connect')
def on_connect(_):
    games[request.sid] = GameWrapper(emit)
    emit('state', games[request.sid].get_player_state())

@socketio.on('disconnect')
def on_disconnect():
    if request.sid in games:
        del games[request.sid]

@socketio.on('move')
def onmsg_move(move):
    try:
        games[request.sid].play_move(move)
    except:
        emit("chat", {"name": "System", "msg": "An error occurred, please restart"})

@socketio.on('state')
def onmsg_state():
    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,

    @classmethod
    def from_uci(cls, uci: str) -> Move:
        """
        Parses a UCI string.

        :raises: :exc:`InvalidMoveError` if the UCI string is invalid.
        """
        if uci == "0000":
            return cls.null()
        elif len(uci) == 4 and "@" == uci[1]:
            try:
                drop = PIECE_SYMBOLS.index(uci[0].lower())
                square = SQUARE_NAMES.index(uci[2:])
            except ValueError:
                raise InvalidMoveError(f"invalid uci: {uci!r}")
            return cls(square, square, drop=drop)
        elif 4 <= len(uci) <= 5:
            try:
                from_square = SQUARE_NAMES.index(uci[0:2])
                to_square = SQUARE_NAMES.index(uci[2:4])
                promotion = PIECE_SYMBOLS.index(uci[4]) if len(uci) == 5 else None
            except ValueError:
                raise InvalidMoveError(f"invalid uci: {uci!r}")
            if from_square == to_square:
                raise InvalidMoveError(f"invalid uci (use 0000 for null moves): {uci!r}")
            return cls(from_square, to_square, promotion=promotion)
        else:
            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

socket.emit('move', '0000')
socket.emit('move', 'f7f6')
socket.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:

#!/bin/sh
docker build . -t msfrogofwar3
docker 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,

        outcome = self.board.outcome()
        if outcome is None:
            try:
                move = chess.Move.from_uci(uci)
                if move:
                    if move not in self.board.legal_moves:
                        self.player_turn = True
                        self.emit('state', self.get_player_state())
                        self.emit("chat", {"name": "System", "msg": "Illegal move"})
                        return
                    self.board.push_uci(uci)
            except:
                self.player_turn = True
                self.emit('state', self.get_player_state())
                self.emit("chat", {"name": "System", "msg": "Invalid move format"})
                return
        elif outcome.winner != chess.WHITE:
            self.emit("chat", {"name": "🐸", "msg": "you lost, bozo"})
            return

        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:

        while not opponent_move and attempts <= 10:
            try:
                attempts += 1
                engine = Stockfish("./stockfish/stockfish-ubuntu-x86-64-avx2", parameters={"Threads": 4}, depth=STOCKFISH_DEPTH)
                for m in self.moves:
                    if engine.is_move_correct(m):
                        engine.make_moves_from_current_position([m])
                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.

        self._stockfish = subprocess.Popen(
            self._path,
            universal_newlines=True,
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
        )

Reading the stockfish library source code for is_move_correct,

    def is_move_correct(self, move_value: str) -> bool:
        """Checks new move.

        Args:
            move_value:
              New move value in algebraic notation.

        Returns:
            True, if new move is correct, else False.
        """
        old_self_info = self.info
        self._put(f"go depth 1 searchmoves {move_value}")
        is_move_correct = self._get_best_move_from_sf_popen_process() is not None
        self.info = old_self_info
        return is_move_correct
    def _put(self, command: str) -> None:
        if not self._stockfish.stdin:
            raise BrokenPipeError()
        if self._stockfish.poll() is None and not self._has_quit_command_been_sent:
            self._stockfish.stdin.write(f"{command}\n")
            self._stockfish.stdin.flush()
            if command == "quit":
                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

setoption 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.

corctf{“Whatever you do, don’t reveal all your techniques in a CTF challenge, you fool, you moron.” - Sun Tzu, The Art of War}
@VinhPham2106
Copy link

Gotta love the deep dive to get first blood!

@CaptainNapkins
Copy link

sick write up my guy

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment