Битвы хакеров: разбор заданий PHDays CTF Quals
Positive Hack Days CTF — международные соревнования по защите информации, которые проводятся по игровому принципу Capture the Flag. Несколько команд в течение заранее отведенного времени защищают свои сети и атакуют чужие. Основная задача участников — выявлять уязвимости в системах противников и получать доступ к секретной информации (флагам), при этом обнаруживая и устраняя подобные уязвимости в своей системе.В сегодняшнем топике мы представим разбор некоторых интересных заданий, с которыми сталкивались участники прошедших соревнований.
История и география В этом году PHDays CTF состоится уже в четвертый раз. Впервые соревнования прошли во время форума Positive Hack Days в 2011 году, тогда победителями стали участники американской команды PPP, в следующем году победила российская команда Leet More, а на PHDays III чемпионами стали Eindbazen из Голландии. Каждый год в PHDays CTF принимают участие команды со всего мира — от США до Японии.Для участия в отборочных соревнованиях в этом году зарегистрировалось больше 600 команд.
Задания и атмосфера По сложившейся традиции игровые задания и инфраструктура подготавливаются в соответствии с легендой соревнований — специальной сюжетной линией, которая превращает простой набор задач PHDays CTF в увлекательное состязание, у которого есть цель. Например, в прошлом году участники спасали от гибели вымышленный мир D«Errorim. Предстоящие соревнования продолжат этот сюжет.Задания соревнований, как правило, основаны на реальных прототипах: уязвимости, заложенные в задания и сервисы CTF, можно встретить в различных системах в реальной жизни. Соревнования PHDays CTF интересны и оригинальными игровыми механиками, которые делают возможной реализацию разных и непохожих друг на друга стратегий ведения игры (подробнее на сайте PHDays).
Обычно организаторы готовят для команд необычные задания, напрямую не связанные со взломом. Например, во время PHDays 2012 дополнительные очки можно было заработать обнаружив бонусные флаги в специальном контейнере с мусором, а во время PHDays III команды должны были быстрее всех преодолеть «хакерский лабиринт» — полосу препятствий с лазерным полем, датчиками слежения, секретными дверьми, комнатой с жучками и другими интересными испытаниями.
Но основные баллы, конечно, зарабатываются исключительно в ходе решения разнообразных задач по информационной безопасности. Давайте разберем некоторые из них.
Разбор Квалификационный этап соревнования (PHDays CTF Quals) относится к типу task-based CTF, то есть команды должны решать задания и получать за них очки. Задания могут относиться к одной из следующих категорий: Forensic — компьютерно-криминалистическая экспертиза, Reverse (reverse engineering) — анализ бинарного кода, Pwn — эксплуатация уязвимостей, Admin — навыки администрирования, Network — знание сетевой инфраструктуры и протоколов, Crypto — криптография, Stegano — стеганография, PPC (professional programming and coding) — олимпиадное программирование, Web — поиск и использование веб-уязвимостей, Misc — разное. Начнем с последней категории.Неочевидные задания Участникам PHDays IV CTF Quals в рамках одного из заданий нужно было расшифровать сообщение, скрытое в mp3-файле.Как правило, если в условии задачи упоминается извлечение скрытого в некотором контейнере сообщения, используется одно из готовых решений из области стеганографии. При этом для отыскания ответа обычно нужно подобрать программу для дешифровки и запустить ее с правильными ключами. То есть «ключ к успеху» при решении конкретного задания лежит в поиске подходящего варианта, предварительно прописанного авторами.
В нашем случае все несколько иначе. Если открыть предложенный файл в текстовом редакторе, то он выглядит вот так: В начале файла располагаются метаданные в формате ID3. Сначала идет тег TRCK (track number), а потом — какие-то куски текста:
RGB7 5,183, NULL RGB6 0,42,159 RGB5 194,244,68 RGB4 47,77,6 RGB3 44,73,141 RGB2 140,207,72 RGB1 120,156,203
Эту информацию можно разбить на семь записей (от RGB7 до RGB1):
RGB7 5,183, NULLRGB6 0,42,159RGB5 194,244,68RGB4 47,77,6RGB3 44,73,141RGB2 140,207,72RGB1 120,156,203
После каждого из RGB-идентификаторов стоит три значения. Обычно это числа, но в одном случае — NULL. Легко предположить, что это массив записей, каждая из которых содержит до трех однобайтовых значений. Можно записи отсортировать, объединить, превратить десятичные коды в символы и вывести в шестнадцатеричном виде, например, при помощи вот такой программы:
>>> a = [120,156,203, 140,207,72, 44,73,141, 47,77,6, 194,244,68, 0,42,159, 5,183]>>> print ».join (map (chr, a)).encode («hex»)
В результате получим:
789ccb8ccf482c498d2f4d06c2f444002a9f05b7
Шестнадцатеричная последовательность начинается с байтов с кодами 0×78 0×9С, а это подсказывает нам, что используется алгоритм сжатия данных zlib. Если использовать zlib в режиме компрессии с параметрами по умолчанию, выходная последовательность будет начинаться именно с этих байтов.
Одним вызовом функции decompress библиотеки zlib в Python можно распаковать упакованное сообщение:
>>> import zlib >>> print zlib.decompress (».join (map (chr, a))) И тогда будет выведен текст: i_hate_ucucuga
Именно этот флаг нужно было отослать организаторам соревнований.
Неправильная криптография Данное задание относится к категории Crypto. По легенде, был перехвачен сеанс связи, и командам нужно расшифровать переданные сообщения.Прежде всего, ясно виден процесс обмена ключами и затем передача зашифрованных данных. Необходимо понять, на основании какого криптографического базиса можно построить подобное общение.Задание называется mars, можно предположить, что это означает Modified RSA.
Каждый ключ состоит из двух частей, и вторая часть в обоих случаях равна 0×010001 == 65537 — часто используемая публичная экспонента RSA (e). Значит, в сеансе связи сначала идет обмен открытыми ключами (n1/e1, n2/e2), а затем обмен зашифрованными на них сообщениями (c1, c2).
Если это действительно что-то похожее на RSA, то ci = pow (mi, ei, ni). Требуется найти m1 и m2.pow — функция модульного возведения в степень, pow (val, еxp, modulus) == valexp % modulus.
Согласно алгоритму RSA:
mi = pow (ci, di, ni), di*ei ≡ 1 mod φ (ni), ni — произведение нескольких простых чисел, φ (n) — функция Эйлера, количество натуральных чисел взаимно простых с n и меньших n. В задании n1 и n2 имеют длину 1535 бит, то есть не поддаются факторизации (разложению на простые сомножители).Воспользуемся реализацией расширенного алгоритма Евклида на Python:
def egcd (a, b): # Extended Greatest Common Divisor if a == 0: return (b, 0, 1) else: g, y, x = egcd (b % a, a) return (g, x — (b // a) * y, y) Найдем НОД (наибольший общий делитель) чисел n1 и n2: gcd = egcd (n1, n2)[0] НОД (n1, n2) имеет длину 1024 бита. Найдем другие делители чисел n1 и n2: p1 = n1 / gcd p2 = n2 / gcd p1 и p2 — простые числа длиной 512 бит, gcd — составное число длиной 1024 бита (скорее всего 512×512), и оно также слишком велико для факторизации…Рассмотрим случай, когда искомые сообщения mi могут быть представлены числами, не превосходящими pi.
Пусть ni = pi*q*r, тогда при 0 < mi < pi будет справедливо следующее выражение:
pow (mi, ei, ni) % pi == pow (mi, ei, pi)
Тогда экспонента для расшифрования d«i должна удовлетворять следующему выражению:
ei*d«i ≡ 1 mod φ (pi)
Значение d«i может быть найдено путем вычисления алгебраического дополнения:
d«i = invmod (ei, φ (pi))
Для простого pi справедливо:
φ (pi) == pi-1,
Следовательно:
d«i = invmod (ei, pi-1)
Вычисление алгебраического дополнения реализуется следующей функцией на Python:
def invmod (a, m): # Modular inversion g, x, y = egcd (a, m) if g == 1: return x % m raise Exception («modular inverse does not exist») Также понадобится функция, которая превращает число в строчку и оставляет только текст от последнего символа »\0» до конца строки: def showX (v): print (»%0256X» % v).decode («hex»).split ('\0')[-1] Вычисляем di, выполняем расшифрование: d1 = invmod (e, p1–1) d2 = invmod (e, p2–1) showX (pow (c1, d1, p1)) showX (pow (c2, d2, p2)) И получаем результат: REQUEST: GET_FLAG (SIGNATURE: 5e2d5e0323591b1c).RESPONSE: its_n0t_ab0ut_p4dd1ngФлаг — строка «its_n0t_ab0ut_p4dd1ng».
Криптографическое задание secc Дано: архив source.tar.gz, содержащий файлы ecc.py и task.py, в которых содержится схема верификации ключа, реализованная с помощью эллиптической криптографии. Известно, что подключившись по адресу 195.133.87.171 на порт 5555, можно установить соединение с каким-то сервером: nc 195.133.87.171 5555password: secch4l*
Поскольку даны исходники, то стоит начать с их анализа. Можно их даже запустить.Так как у меня не было модуля libnum, пришлось написать его самому. В нем достаточно реализовать уже упомянутую ранее функцию модульной инверсии и используемый ею расширенный алгоритма Евклида:
def egcd (a, b): # Extended Greatest Common Divisor if a == 0: return (b, 0, 1) else: g, y, x = egcd (b % a, a) return (g, x — (b // a) * y, y)
def invmod (a, m): # Modular inversion g, x, y = egcd (a % m, m) if g!= 1: raise Exception («modular inverse does not exist») else: return x % m Итак, функция main из task.py: def main (): print «Auth:» auth = raw_input () if hashlib.sha1(auth).hexdigest () != »375d5c01ca1b8c3863024d10aac7713472eb5033»: # secch4l* print «nope» return prefix = os.urandom (8) print «Proof of work, please» print «Prefix is (hexed) », prefix.encode («hex») test = raw_input ().decode («hex») if not test.startswith (prefix) or len (test) > 16: print «nope» return h = hashlib.sha1(test).hexdigest () if not h.startswith (»000000»): print «nope» return
goflag () Читается строка, SHA-1 от которой должно быть равно заданному значению («secch4l*»).Потом генерируется случайный 8-байтовый префикс, отправляемый клиенту. Байты кодируются как hex-строка. В ответ клиент должен прислать строку не длиннее 16 байт, при этом чтобы она начиналась с заданного префикса, а первые 3 байт значения SHA-1 от этой строки должны быть нулевыми. Если все этапы пройдены успешно — вызывается функция goflag ().Следующий фрагмент кода соединяется с сервером, отправляет пароль, получает префикс, вычисляет и отправляет ответ:
def readLn (sock): a = [] while True: c = sock.recv (1) if '\n' == c: return ».join (a) a.append©
HOST = »195.133.87.171» PORT = 5555 sock = socket.socket (socket.AF_INET, socket.SOCK_STREAM) sock.connect ((HOST, PORT)) print readLn (sock) # Auth: sock.send («secch4l*\n») print readLn (sock) # Proof of work, please s = readLn (sock) print s # Prefix is (hexed) 0b3997e62b9ffbf4 prefix = s.split ()[-1].decode («hex») for i in xrange (0×7FFFFFFF): s = »%s%X» % (prefix, i) if hashlib.sha1(s).digest ()[:3] == '\0\0\0': break sock.send (s + '\n') После выполнения этого кода на стороне клиента сервер выполняет функцию goflag (), выводящей примерно следующий текст: EC PASSWORD CHECKR = 572115218124168948525078362547166172445820217705568707355669424304224832114SHARED SECRET = R ^ PASSWORDENCRYPTED MESSAGE: 7a93846a011e0d0382e94f32d705239e6298169dcec20da5d6
Что же происходит в функции goflag из task.py:
def goflag (): print «EC PASSWORD CHECK» r = random.randint (31337, 1 << 250) R = p256.power(G, r) print "R =", R print "SHARED SECRET = R ^ PASSWORD" S = p256.power(R, PASSWORD) key = p256.derive(S) cipher = encrypt(FLAG, key) print "ENCRYPTED MESSAGE:", cipher.encode("hex") Используется асимметричная криптография на эллиптических кривых. Выбрана кривая P-256, рекомендованная NIST. Реализация операций над точками кривой не содержит очевидных уязвимостей.Мы знаем значение R, но без знания значения PASSWORD (которое читается сервером из файла password.txt) мы не можем вычислить S. Знание S позволило бы нам легко вычислить key. Так может быть шифрование реализовано с ошибкой?
Функция encrypt из task.py:
def encrypt (msg, key): iv = os.urandom (8) stream = hashlib.sha256(iv + key).digest () stream = hashlib.sha256(stream + iv + key).digest () cipher = iv + xor (msg, stream) return cipher По коду видно, что зашифрованному сообщению предшествует случайный 8-байтовый вектор инициализации iv, а шифрование выполняется как XOR флага c гаммой, сгенерированной как выход двух вычислений SHA-256. Без знания значения key получить гамму нереально. Но как key получается в программе? Функция derive из task.py:
def derive (self, p): return hashlib.sha256(str ((p[0] << 10) / p[1])).digest() Оказывается, значение точки S (состоящее из двух координат — x и y) используется как вход SHA-256. Фактически на вход хеша подается значение str(int(x*1024/y)). Так как x и y имеют близкие значения (это большие целые числа), то результат арифметических действий должен близок к 1024 (хотя может и превышать его в разы).Таким образом, из-за особенностей реализации функции derive, значение key может принимать совсем небольшое количество состояний. Можно просто перебрать их все, попытаться на каждом ключе расшифровать сообщение, и если оно состоит только из печатных символов — мы достигли успеха.
import hashlib, ecc enc = »7a93846a011e0d0382e94f32d705239e6298169dcec20da5d6».decode («hex») iv = enc[:8] def decrypt (key): stream = hashlib.sha256(iv + key).digest () stream = hashlib.sha256(stream + iv + key).digest () return ecc.xor (enc[8:], stream) for i in xrange (0×7FFFFFFF): s = decrypt (hashlib.sha256(str (i)).digest ()) for c in bytearray (s): if c < 32 or c >= 128: break else: print s # ecc_is_too_s3cure break Таким образом, флагом является строчка «ecc_is_too_s3cure».Реверс-инжиниринг. Shadelt900 Обратная разработка — еще одна популярная категория заданий. Помимо CTF, в конкурсной программе PHDays присутствует конкурс Best Reverser.Задание Shadelt900, так же как и три предыдущих, было частью программы PHDays IV CTF Quals, прошедших в январе 2014 года. Команды должны были расшифровать изображение под названием 'derrorim_enc.bmp'. Было известно средство, примененное для его зашифрования, — оно как раз и называется Shadelt9000.exe, но декриптор обнаружить не удалось. Вот это изображение:
При ближайшем рассмотрении файла Shadelt9000.exe становится ясно, что приложение использует OpenGL. Также есть копирайт inflate 1.2.8 Copyright 1995–2013 Mark Adler, указывающий на то, что в программе используется популярная библиотека компрессии zlib.
Если в дизассемблере посмотреть, откуда идут обращения к функциям zlib, можно довольно быстро найти вот такой кусок кода:
По адресам 0×47F660 и 0×47F7B8 расположены массивы данных, упакованные zlib. Распакуем их:
from zlib import decompress as unZ base = 0×47C000 — 0×7AE00 # data section base ab=open («ShadeIt9000.exe», «rb»).read () open (»1.txt», «w»).write (unZ (ab[0×47F660-base:],-15)) open (»2.txt», «w»).write (unZ (ab[0×47F7B8-base:],-15)) После распаковки файл 1.txt содержит пиксельный шейдер: #version 330 uniform sampler2D u_texture; uniform sampler2D u_gamma; varying vec4 texCoord0; varying vec3 v_param; uint func (vec3 co){ return uint (fract (sin (dot (co, vec3(17.1684, 94.3498, 124.9547))) * 68431.4621) * 255.); } uvec3 rol (uvec3 value, int shift) { return (value << shift) | (value >> (8 — shift)); } const uvec3 m = uvec3(0xff); void main () { uvec3 t = uvec3(texture2D (u_texture, vec2(texCoord0)).rgb * 0xff) & m; uvec3 g = uvec3(texture2D (u_gamma, vec2(texCoord0)).rgb * 0xff) & m; int s = int (mod (func (v_param), 8)); t = rol (t, s); vec3 c = vec3((t ^ g) & m) / 0xff; gl_FragColor = vec4(c, 1.); } Файл 2.txt содержит вершинный шейдер:
attribute vec3 a_param; varying vec4 texCoord0; varying vec3 v_param; void main (void) { gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; texCoord0 = gl_MultiTexCoord0; v_param = a_param; } Главная информация о пиксельном шейдере выделена красным:
В переменной t оказывается текущий элемент обрабатываемой текстуры (входного файла), а в переменной g — текущий элемент гаммы (сгенерированной псевдослучайным образом).В переменной s мы видим некоторое значение, используемое позже для циклического сдвига s.Выходное значение фактически вычисляется как
(rol (t, s) ^ g)
Причем если запускать программу несколько раз с одним и тем же входным файлом, то для каждого элемента значение g будет меняться от запуска к запуску, а t и s будут оставаться одними и теми же.
Найдем, как генерируется гамма:
unsigned char *pbGamma = malloc (cbGamma); srand (time (0)); for (i = 0; i < cbGamma; i++) { pbGamma[i] = rand(); } Видно, что она зависит от текущего времени.Из исходного архива можно узнать, что файл derrorim_enc.bmp создан 21.01.2014 в 18:37:52.Получаем значение, которое в тот момент вернула бы функция time():
>>> import time >>> print hex (int (time.mktime ((2014,1,21, 18,37,52, 0,0,0)))) 0×52de8640
Теперь копируем файл ShadeIt9000.exe в ShadeIt9000_f.exe и исправляем его.
По смещению 00015557 надо байты
E8 A5 31 01 00
заменить на
B8 40 86 DE 52
Это эквивалентно замене
call _time на mov eax,52de8640h.
Таким образом мы получили версию ShadeIt9000_f, которая будет всегда шифровать с той же гаммой, какая была в момент зашифрования интересующего нас файла.Теперь нужно подготовить значения, которые помогут расшифровать изображение:
import os bmp=open («derrorim_enc.bmp», «rb»).read () hdr = bmp[:0×36] abData = bytearray (bmp[0×36:]) cbBody = len (bmp) — len (hdr) open (»00.bmp», «wb»).write (hdr + '\0'*cbBody) open («XX.bmp», «wb»).write (hdr + '\2'*cbBody) os.system («ShadeIt9000_f.exe 00.bmp») os.system («ShadeIt9000_f.exe XX.bmp») В файле 00_enc.bmp окажется результат зашифрования картинки, состоящий из нулевых байтов. Это и будет гамма в чистом виде.В файле XX_enc.bmp окажется результат зашифрования картинки, состоящий из байтов со значением 2. Это поможет нам узнать, на сколько битов циклически сдвигался каждый байт.
Наконец, расшифровыванием Shadelt9000:
def rol (v, i): return (((v<>(8-i)) & 0xFF)) def ror (v, i): return (((v>>i) & 0xFF) | ((v<<(8-i)) & 0xFF)) dRot = {rol(1,i):i for i in xrange(8)} bmp=open("derrorim_enc.bmp", "rb").read() hdr = bmp[:0x36] abData = bytearray(bmp[0x36:]) abGamma = bytearray(open("00_enc.bmp", "rb").read()[0x36:]) abRot = bytearray(open("XX_enc.bmp", "rb").read()[0x36:]) for i,b in enumerate(abGamma): abRot[i] = dRot[abRot[i] ^ b] for i,b in enumerate(abGamma): abData[i] = ror(abData[i] ^ b, abRot[i]) open("derrorim.bmp", "wb").write(hdr + str(abData)) получаем:
Выше был описан верный, но не самый эффективный путь решения задания. Есть способ короче.
Сразу за вершинным шейдером по адресам 0×47F848 и 0×47F9A0 лежит упакованный zlib-код пиксельного и вершинного шейдера для выполнения обратного преобразования. Возможно, он был случайно забыт разработчиком задания. А может и оставлен намеренно.
Коды вершинного шейдера для зашифрования и расшифрования идентичны, так что трогать их не имеет смысла. А что будет, если подменить пиксельный шейдер?
Копируем ShadeIt9000_f.exe в ShadeIt9000_d.exe и исправляем его:
00015775: 60 F6 ==> 48 F8
Затем запускаем ShadeIt9000_d.exe derrorim_enc.bmp. И получаем на выходе расшифрованный файл derrorim_enc_enc.bmp, который (за исключением мелких артефактов) совпадает с тем, который мы расшифровали скриптом на Python.
На сегодня все! Всем спасибо за внимание, будем рады ответить на вопросы в комментариях.
Напоминаем, что финал PHDays IV CTF состоится 21 и 22 мая во время проведения форума Positive Hack Days. Следить за ходом соревнований можно будет не только непосредственно на площадке, но и с помощью мобильных приложений. Следите за новостями!
Читайте также:
P.S. Архив всех заданий PHDays CTF и CTF Quals можно найти на сайте PHDays. Так что, если есть желание испытать себя — вперед! P.P. S. Подробный разбор представленных в топике заданий проходил на специальном вебинаре, ведущим которого был Дмитрий Скляров. Запись вебинара доступна по ссылке: http://my.webinar.ru/record/290241/.