Найти в Дзене

Вскрываем и потрошим PyInstaller

В качестве примера возьмем некое графическое приложение, для регистрации которого нужно ввести правильный серийник в ответ на предложенный программой код оборудования. При неправильном вводе приложение отвечает ругательным сообщением «No valid license code». Detect It Easy уверенно подсказывает, что это наш пациент. Исследование приложения мы начинаем по стандартной схеме. Поиск сопутствующих текстовых строк в exe-файле не дает результата: исполняемый код и данные явно упакованы или закриптованы. Загрузка приложения в IDA косвенно это подтверждает, exe-файл представляет собой загрузчик для обширного самораспаковывающегося файлового пакета. Попробуем загрузить программу в наш любимый отладчик x64dbg. По счастью, приложение совершенно не сопротивляется этому, нормально загружается и прерывается по первому требованию. Слегка попрыгав по коду трассировщиком, сразу натыкаемся на участок, сильно смахивающий на интерпретатор шитого пи‑кода: 00007FF9AE401274 | 49:8BC7 | mov

В качестве примера возьмем некое графическое приложение, для регистрации которого нужно ввести правильный серийник в ответ на предложенный программой код оборудования. При неправильном вводе приложение отвечает ругательным сообщением «No valid license code». Detect It Easy уверенно подсказывает, что это наш пациент.

-2

Исследование приложения мы начинаем по стандартной схеме. Поиск сопутствующих текстовых строк в exe-файле не дает результата: исполняемый код и данные явно упакованы или закриптованы. Загрузка приложения в IDA косвенно это подтверждает, exe-файл представляет собой загрузчик для обширного самораспаковывающегося файлового пакета.

-3

Попробуем загрузить программу в наш любимый отладчик x64dbg. По счастью, приложение совершенно не сопротивляется этому, нормально загружается и прерывается по первому требованию. Слегка попрыгав по коду трассировщиком, сразу натыкаемся на участок, сильно смахивающий на интерпретатор шитого пи‑кода:

00007FF9AE401274 | 49:8BC7 | mov rax,r15
00007FF9AE401277 | 49:2BC1 | sub rax,r9
00007FF9AE40127A | 48:D1F8 | sar rax,1
00007FF9AE40127D | 03C0 | add eax,eax
00007FF9AE40127F | 41:8945 68 | mov dword ptr ds:[r13+68],eax
00007FF9AE401283 | 837A 44 00 | cmp dword ptr ds:[rdx+44],0
00007FF9AE401287 | 0F85 85A71200 | jne python39.7FF9AE52BA12
00007FF9AE40128D | 41:0FB73F | movzx edi,word ptr ds:[r15] ; edi <- байт-код текущей команды
00007FF9AE401291 | 4D:8BF4 | mov r14,r12
00007FF9AE401294 | 40:0FB6F7 | movzx esi,dil
00007FF9AE401298 | C1EF 08 | shr edi,8
00007FF9AE40129B | 49:83C7 02 | add r15,2
00007FF9AE40129F | 4C:8965 C8 | mov qword ptr ss:[rbp-38],r12
00007FF9AE4012A3 | 4C:897D B0 | mov qword ptr ss:[rbp-50],r15
00007FF9AE4012A7 | 66:0F1F8400 00000000 | nop word ptr ds:[rax+rax],ax
00007FF9AE4012B0 | 8D46 FF | lea eax,qword ptr ds:[rsi-1]
00007FF9AE4012B3 | 3D A4000000 | cmp eax,A4
00007FF9AE4012B8 | 0F87 85E21200 | ja python39.7FF9AE52F543
00007FF9AE4012BE | 48:98 | cdqe
00007FF9AE4012C0 | 41:8B8C83 D8C80600 | mov ecx,dword ptr ds:[r11+rax*4+6C8D8] ; В rcx <- относительный адрес обработчика текущей команды
00007FF9AE4012C8 | 49:03CB | add rcx,r11
00007FF9AE4012CB | FFE1 | jmp rcx ; Переход на обработчик текущей команды
00007FF9AE4012CD | 48:63D7 | movsxd rdx,edi
00007FF9AE4012D0 | 49:8B84D5 68010000 | mov rax,qword ptr ds:[r13+rdx*8+168]
00007FF9AE4012D8 | 48:85C0 | test rax,rax
00007FF9AE4012DB | 0F84 B3E01200 | je python39.7FF9AE52F394
00007FF9AE4012E1 | 48:FF00 | inc qword ptr ds:[rax]
00007FF9AE4012E4 | 48:8B55 90 | mov rdx,qword ptr ss:[rbp-70]
00007FF9AE4012E8 | 49:890424 | mov qword ptr ds:[r12],rax
00007FF9AE4012EC | 49:83C4 08 | add r12,8

Как видим, таблица обработчиков команд находится по адресу 6C8D8, а указатель на PC текущей команды - в регистре R15.

На этом месте отложим пока отладчик в сторону и вспомним теорию. Но сначала, чтобы не забыть, зафиксируем один интересный момент: большинство динамических библиотек, на которые имеются ссылки на вкладке «Отладочные модули», физически находятся в подпапке \_MEI100722 системной папки для временных файлов. Судя по всему, это и есть каталог (или один из каталогов), в который сборка распаковывается на время работы приложения.

Чтобы лучше понимать вопрос, давай для начала вспомним, что это за зверь такой - Python. Думаю, не ошибусь, если предположу, что многие знают его как язык для написания простеньких сценариев, вроде JavaScript, отличающийся несколько экстравагантной концепцией выделения блоков кода отступами. Проект создан и развивался в лучших традициях черного английского юмора (как известно, само название - это отсылка к сатирическому британскому телешоу). В ходе этой эволюции узкоспециализированный скриптовый язык получил множество разнообразных библиотек, как в свое время это произошло с фортраном.

Как известно, спрос рождает предложение, поэтому, чтобы разработчикам было легче создавать полноценные коммерческие приложения в рамках привычной концепции Python, были придуманы компиляторы самых разнообразных реализаций. Кто‑то попытался сделать нативный компилятор, другие прикрутили к Python JIT (компиляцию времени исполнения, я рассказывал про эту концепцию в своих предыдущих статьях).

Соответственно, были созданы проекты Jython (трансляция в байт‑код JVM) и IronPython (трансляция в .NET IL). Но, к сожалению, как ты мог убедиться из приведенного выше фрагмента кода интерпретатора, эталонная реализация лишена полезных свойств - перед нами обычная интерпретация py-кода, не отличающаяся высокой оптимизацией.

Подробнее про различные методы компиляции питоновского кода в исполняемые приложения можно почитать, например, на «Хабре». В этой статье упомянута сборка приложения с помощью исследуемого нами PyInstaller и разборка его на составляющие файлы проекта с использованием PyInstaller Extractor.

Хотя лично я для извлечения файлов из проекта посоветовал бы более продвинутый инструмент - pydumpck. Разумеется, он тоже не всемогущ и ему присущи определенные недостатки. К примеру, у меня он нормально запускается только на версии питона 3.9, но вообще, надо сказать, проблема совместимости кода даже между соседними подверсиями - обычная и даже не самая главная проблема этого языка. В общем, достаточно лирики, вернемся к суровым техническим подробностям эталонной реализации.

Минимальной единицей скомпилированного питоновского байт‑кода является файл .pyc (есть еще файлы .pyo, скомпилированные с оптимизацией, но их мы трогать не будем). Этот файл генерируется из текстового скриптового кода вызовом метода py_compile.compile или просто при вызове директивы import во время исполнения скрипта, чтобы не компилировать импортируемый модуль лишний раз. Подобным образом разработчики попытались компенсировать отсутствующий в эталонной реализации JIT. Этот файл содержит в себе байт‑код скомпилированного модуля, константы, ссылки и так далее. Формат его зависит от версии Python, официально не документирован, однако хорошо описан в интернете, например на сайте Nedbatchelder. В этой же статье приведен и текст простейшего дизассемблера pyc, написанного на питоне:

import dis, marshal, struct, sys, time, types
def show_file(fname):
f = open(fname, "rb")
magic = f.read(4)
moddate = f.read(4)
modtime = time.asctime(time.localtime(struct.unpack('L', moddate)[0]))
print "magic %s" % (magic.encode('hex'))
print "moddate %s (%s)" % (moddate.encode('hex'), modtime)
code = marshal.load(f)
show_code(code)
def show_code(code, indent=''):
print "%scode" % indent
indent += ' '
print "%sargcount %d" % (indent, code.co_argcount)
print "%snlocals %d" % (indent, code.co_nlocals)
print "%sstacksize %d" % (indent, code.co_stacksize)
print "%sflags %04x" % (indent, code.co_flags)
show_hex("code", code.co_code, indent=indent)
dis.disassemble(code)
print "%sconsts" % indent
for const in code.co_consts:
if type(const) == types.CodeType:
show_code(const, indent+' ')
else:
print " %s%r" % (indent, const)
print "%snames %r" % (indent, code.co_names)
print "%svarnames %r" % (indent, code.co_varnames)
print "%sfreevars %r" % (indent, code.co_freevars)
print "%scellvars %r" % (indent, code.co_cellvars)
print "%sfilename %r" % (indent, code.co_filename)
print "%sname %r" % (indent, code.co_name)
print "%sfirstlineno %d" % (indent, code.co_firstlineno)
show_hex("lnotab", code.co_lnotab, indent=indent)
def show_hex(label, h, indent):
h = h.encode('hex')
if len(h) < 60:
print "%s%s %s" % (indent, label, h)
else:
print "%s%s" % (indent, label)
for i in range(0, len(h), 60):
print "%s %s" % (indent, h[i:i+60])
show_file(sys.argv[1])

Как видишь, дизассемблирование байт‑кода pyc-файлов особой проблемы не представляет, можно использовать, к примеру, самый распространенный питоновский дизассемблер pydasm. Есть даже специализированная подключаемая питоновская библиотека dis, созданная исключительно для дизассемблирования. Система команд тоже хоть и зависима от версии и официально не документирована, но проста и общедоступна. Ее описание также можно найти в сети.

А вот с декомпиляцией pyc в исходный питоновский код дело обстоит грустно. Несмотря на кажущееся обилие декомпиляторов (самые распространенные — это python-decompile3 и pycdc), на текущий момент в паблике нет абсолютно корректных декомпиляторов для версий старше 3.8, не говоря уже про обфускацию. Поэтому спешу огорчить искателей волшебной кнопки — декомпилированный код даже версии 3.9 требует сильного допиливания напильником.

Продолжаем увлекательную экскурсию по файлам питоновского пакета. Когда в отладчике мы бегло просматривали список импортируемых библиотек, распакованных во временный каталог системы, то обратили внимание на модули с расширением pyd. Это бинарные нативные библиотеки, подключаемые к питоновскому интерпретатору. Как видишь, это классические динамические библиотеки Windows формата DLL с единственной экспортируемой функцией. В этой статье мы пока не будем заострять внимание на защитах, интегрированных в подобные нативные расширения, пример создания которых
уже описывался в различных публикациях.

Не будем сильно углубляться в тему, рассмотрим только еще один ключевой формат файлов с неприличным расширением .pyz. Это питоновский архив, позволяющий собрать несколько модулей, классов и прочих составляющих проекта в одну сборку. Эдакий «архив в архиве» внутри исполняемой сборки PyInstaller.

Все это очень осложняет поиск по вхождению строки нужного модуля в распакованной сборке PyInstaller. По счастью, это чудо враждебной техники легко распаковывается на собственные составляющие при помощи упомянутой выше утилиты pydumpck и собирается обратно (в случае патча, например) встроенной утилитой
zipapp.

Ну а теперь, вооружившись полученными знаниями, попробуем применить их на практике для лечения нашего приложения. Для начала аккуратно распакуем его при помощи pydumpck (благо у нас используется версия Python 3.9). Надо отметить, это практически универсальный инструмент - распаковывает и исполняемый exe-модуль PyInstaller, и содержащиеся в нем питоновские архивы pyz, и даже декомпилирует распакованные pyc-файлы в исходный код.

Для декомпиляции в него встроено два плагина - упоминавшиеся uncompyle6 и pycdc. По умолчанию используется pycdc, и он в целом справляется с поставленной задачей, а вот плагин uncompyle6 у меня так и не получилось заставить работать на версии 3.9.

Теперь берем WinHex и ищем текстовую строку no valid license code по полученному множеству файлов. Нам повезло: у нас простейший случай, и искомая строка обнаруживается сразу и в pyc, и автоматически декомпилированном из него py-файле. Открываем восстановленный декомпилятором py-файл. К сожалению, мы убеждаемся, что версия 3.9 для декомпилятора pycdc неродная, он явно страдает несварением кода: этот самый код восстановлен не полностью. Повсюду видны предупреждения о неизвестных инструкциях и ошибках декомпиляции, повторно компилировать такой исходник нельзя. Тем не менее нам повезло - искомое место с проверкой и выдачей строки в файле присутствует:

...
def validate_serial(self):
if not utils.validate_serial():
self.logger.log('no valid license code for the popup code: ' + utils.get_hardware_code() + '\n')
return False

Попробуем теперь дизассемблировать этот файл при помощи pycdasm. Поначалу он ругается на отсутствие сигнатуры, но это не страшно, достаточно руками приклеить ее (в нашем случае это 8 байт 610D0D0A000000000000000000000000) в начало файла, например при помощи WinHex.

Находим соответствующее место в дизассемблированном коде:

...
[Disassembly]
0 LOAD_GLOBAL 0: utils
2 LOAD_METHOD 1: validate_serial
4 CALL_METHOD 0
6 POP_JUMP_IF_TRUE 36
8 LOAD_FAST 0: self
10 LOAD_ATTR 2: logger
12 LOAD_METHOD 3: log
14 LOAD_CONST 1: 'no valid license code for the popup code: '
16 LOAD_GLOBAL 0: utils
18 LOAD_METHOD 4: get_hardware_code
20 CALL_METHOD 0
22 BINARY_ADD
24 LOAD_CONST 2: '\n'
26 BINARY_ADD
28 CALL_METHOD 1
30 POP_TOP
32 LOAD_CONST 3: False
34 RETURN_VALUE
36 LOAD_CONST 4: True

За проверку условия и условный переход по нему отвечает инструкция POP_JUMP_IF_TRUE. Конечно, можно было бы поправить ее на безусловный переход, однако тогда придется как‑то сбалансировать стек, потому что нет такой команды, которая одновременно снимала бы со стека значение и осуществляла переход. Вдобавок не факт, что серийник не проверяется где‑то в другом месте. Правильнее будет найти и поправить метод utils.validate_serial(). Для этого находим модуль utils и декомпилируем его:


def validate_serial():
stored_serial = get_data_file().strip()
hardware_code = get_hardware_code()
if hardware_code == '' or stored_serial != get_serial(hardware_code):
return False

Или в виде байт‑кода:

[Disassembly]
0 LOAD_GLOBAL 0: get_data_file
2 CALL_FUNCTION 0
4 LOAD_METHOD 1: strip
6 CALL_METHOD 0
8 STORE_FAST 0: stored_serial
10 LOAD_GLOBAL 2: strip
12 CALL_FUNCTION 0
14 STORE_FAST 1: hardware_code
16 LOAD_FAST 1: hardware_code
18 LOAD_CONST 1: ''
20 COMPARE_OP 2 (==)
22 POP_JUMP_IF_TRUE 36
24 LOAD_FAST 0: stored_serial
26 LOAD_GLOBAL 3: NULL + strip
28 LOAD_FAST 1: hardware_code
30 CALL_FUNCTION 1
32 COMPARE_OP 3 (!=)
34 POP_JUMP_IF_FALSE 40
36 LOAD_CONST 2: False
38 RETURN_VALUE
40 LOAD_CONST 3: True
42 RETURN_VALUE

Как видишь, тут достаточно заменить команду по смещению 36 LOAD_CONST 2:False (64 02) командой LOAD_CONST 3: True (64 03), и метод validate_serial() всегда будет возвращать True. Чтобы убедиться в этом, для начала поправим байт‑код прямо в отладчике.

-4

Жмем кнопку Activate — бинго, программа принимает любой код! Теперь нам остается только поправить байт‑код в соответствующем pyc-модуле и аккуратно пересобрать экзешник с помощью PyInstaller.

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

Понравился материал? - С вас лайк и подписка!)
Вся представленная информация носит исключительно ознакомительный характер и не призывает приступать к действиям!