D2
Администратор
- Регистрация
- 19 Фев 2025
- Сообщения
- 4,380
- Реакции
- 0
Реверс‑инженеру далеко не всегда удается без труда исследовать написанные на Python приложения. Разработчики умеют хранить свои тайны, используя обфускацию, шифрование, кастомный маршалинг и собственные интерпретаторы. Но для любого замка отыщется отмычка: сегодня мы разберемся, как реверсить такие программы, научимся восстанавливать исходный код и узнаем, как создать для них собственный дизассемблер.
Мы уже писали о распространенных способах защиты исходного кода Python от реверс‑инжиниринга и принципах их обхода. В сегодняшней статье я продолжу эту тему и расскажу об особенностях реверса кастомной нестандартной реализации Python-интерпретатора.
В качестве примера возьмем некое приложение для нейросетевой обработки видео с нехорошей особенностью: приложение проверяет лицензию на удаленном сервере при выполнении всех более‑менее полезных функций. Если проверка завершается успешно, оно продолжает работать офлайн, что дает нам надежду на возможность запускать программу без подключения к интернету.
То, что это классическое Python-приложение, видно с первого взгляда, даже без запуска Detect It Easy.
Поскольку рядом с исполняемым модулем лежат питоновские библиотеки, а подкаталоги заполнены файлами с расширением .pyc, никаких сомнений в использовании Python не остается. Что это за файлы и с чем их едят, я подробно рассказывал в статье «Змеиная анатомия. Вскрываем и потрошим PyInstaller». В двух словах поясню для тех, кто не читал эту публикацию: упомянутые бинарные файлы содержат прекомпилированный байт‑код, подаваемый непосредственно на вход интерпретатора для ускорения обработки. В этой же статье приведены и известные декомпиляторы и дизассемблеры файлов подобного формата (uncompyle6, pycdc, pydasm...).
На этой оптимистичной ноте можно было бы и закончить статью, но нет. Несмотря на валидный заголовок .pyc-файла с сигнатурой версии 3.10 (выделено красным), ни один из стандартных декомпиляторов или дизассемблеров не ест такие файлы, выдавая ошибку типа CreateObject: Got unsupported type 0x5D. При просмотре файла в HEX-редакторе мы видим, что он весь, собственно, и состоит из явно зашифрованного объекта нестандартного типа (выделено желтым, 0xDD & 0x7F = 0x5D).
Налицо явно кастомная нестандартная питоновская реализация, благо, как я уже говорил, исходный код Python в сети присутствует, и нет ничего сложного подогнать его под свои нужды. По счастью, шифрование здесь используется явно не шибко взрослое. На скриншоте прослеживается повторяющийся через каждые 16 байт шаблон, который, похоже, наложен операцией XOR на исходный код и местами проглядывает на месте нулевых последовательностей.
Поискав в нативных библиотеках явный фрагмент такого шаблона (например, 25 02 39 04), мы тут же натыкаемся в файле python311.dll на шаблон (выделено красным) и код, расшифровывающий данные сразу после чтения из файла.
Чуть поигравшись с отладчиком x64dbg, мы обнаруживаем и место вызова этого кода:
Попробуем для начала добиться какой‑то более‑менее понятной декомпиляции .pyc-файлов. Для этого напишем простенький расшифровщик, расксоривающий объект 0x5D полученной маской. Благодаря этому исходный файл удается привести в гораздо более читаемый вид.
К сожалению, этот код, хоть и сильно похож на настоящий, все равно не работает. На этот раз вылезает ошибка CreateObject: Got unsupported type 0x0. У меня возникло сильное подозрение на нестандартный маршалинг объектов внутри .pyc-файла.
По счастью, среди множества файлов пакета присутствуют и стандартные библиотеки (например, base64.pyc), которые имеются в нормально откомпилированном виде аналогичной версии.
Сразу видно, что побайтовое сравнение этих файлов — плохая идея, разница в коде на первый взгляд просто чудовищная. Начать с того, что даже по размеру стандартным образом откомпилированный base64.pyc почти в два раза меньше расшифрованного. Тем не менее с ходу заметно, что изначальная гипотеза о нестандартном маршалинге оказалась верной: в расшифрованном файле как минимум отсутствует 32-битное поле kwOnlyArgCount (выделено красным).
Слегка поборов первичный ужас, приступаем к решению проблемы. Берем за основу упомянутый в моей статье пакет pycdc и, дизассемблируя им исходный и расшифрованный base64.pyc, пытаемся найти разницу.
За загрузку объекта code здесь отвечает метод void PycCode::load(PycData* stream, PycModule* mod) модуля pyc_code.cpp. Сразу убираем следующую конструкцию:
Код: Скопировать в буфер обмена
Это дает нам значительный прогресс в исследовании объекта. Не буду приводить всю рутину тестов, проб и ошибок, в ходе которых выясняется, что все не так уж и страшно. Кроме описанного выше поля, кастомный формат отличается, по сути, только положением последовательностей freeVars и cellVars. Поправив их, мы получаем версию pycdas.exe, дизассемблирующую байт‑код без ошибок. Но тут нас снова ждет облом, поскольку восстановленный код выглядит примерно так:
Код: Скопировать в буфер обмена
То есть классы, строки, методы и прочие элементы восстанавливаются вроде бы правильно, однако сам байт‑код не раскодируется, хотя даже аргументы команд выглядят похожими на правильные.
Видимо, и тут нам программисты подложили свинью — помимо кастомного маршалинга, нам подсунули еще и кастомную систему команд, отличающуюся от стандартной 3.10 и даже 3.11.
Но и это прискорбное открытие нас не останавливает, ведь в предыдущей статье мы уже научились искать интерпретатор байт‑кода. Вот и здесь мы быстро находим его, например внутри функции _PyEval_EvalFrameDefault.
Правда, это нам не сильно помогает. К сожалению, мнемоники новых команд в интерпретатор не встроены, поэтому для воссоздания полнофункционального дизассемблера нам предстоит проверить 181 обработчик команд, сверив их с прежними таблицами опкодов из файлов python_3_10.cpp и python_3_11.cpp. Затем придется создать новую таблицу, причем даже количество команд тут не совпадает. Задача довольно муторная, хотя и исполнимая. Имеет смысл довести ее до конца в случае, если нам требуется полностью восстановить исходники программы. Если же нужно просто найти и поправить определенное место в коде, все гораздо проще: достаточно нащупать команды NOP и условные‑безусловные переходы. Тем более что NOP у нас на предыдущем скриншоте идет первой командой (опкод 0x53). Для патча теперь надо только отыскать нужное место (через исправленный дизассемблер), отредактировать его и заново перексорить по шаблону.
Задача написать полный дизассемблер или декомпилятор, конечно, сильно избыточна для разового патча конкретного специфического приложения, но сегодняшний пример может послужить основой для допиливания существующего декомпилятора по мере выхода новых версий Python. А выходят они регулярно.
Автор @МВК
Источник xakep.ru
Мы уже писали о распространенных способах защиты исходного кода Python от реверс‑инжиниринга и принципах их обхода. В сегодняшней статье я продолжу эту тему и расскажу об особенностях реверса кастомной нестандартной реализации Python-интерпретатора.
В качестве примера возьмем некое приложение для нейросетевой обработки видео с нехорошей особенностью: приложение проверяет лицензию на удаленном сервере при выполнении всех более‑менее полезных функций. Если проверка завершается успешно, оно продолжает работать офлайн, что дает нам надежду на возможность запускать программу без подключения к интернету.
То, что это классическое Python-приложение, видно с первого взгляда, даже без запуска Detect It Easy.

Поскольку рядом с исполняемым модулем лежат питоновские библиотеки, а подкаталоги заполнены файлами с расширением .pyc, никаких сомнений в использовании Python не остается. Что это за файлы и с чем их едят, я подробно рассказывал в статье «Змеиная анатомия. Вскрываем и потрошим PyInstaller». В двух словах поясню для тех, кто не читал эту публикацию: упомянутые бинарные файлы содержат прекомпилированный байт‑код, подаваемый непосредственно на вход интерпретатора для ускорения обработки. В этой же статье приведены и известные декомпиляторы и дизассемблеры файлов подобного формата (uncompyle6, pycdc, pydasm...).
На этой оптимистичной ноте можно было бы и закончить статью, но нет. Несмотря на валидный заголовок .pyc-файла с сигнатурой версии 3.10 (выделено красным), ни один из стандартных декомпиляторов или дизассемблеров не ест такие файлы, выдавая ошибку типа CreateObject: Got unsupported type 0x5D. При просмотре файла в HEX-редакторе мы видим, что он весь, собственно, и состоит из явно зашифрованного объекта нестандартного типа (выделено желтым, 0xDD & 0x7F = 0x5D).
Налицо явно кастомная нестандартная питоновская реализация, благо, как я уже говорил, исходный код Python в сети присутствует, и нет ничего сложного подогнать его под свои нужды. По счастью, шифрование здесь используется явно не шибко взрослое. На скриншоте прослеживается повторяющийся через каждые 16 байт шаблон, который, похоже, наложен операцией XOR на исходный код и местами проглядывает на месте нулевых последовательностей.
Поискав в нативных библиотеках явный фрагмент такого шаблона (например, 25 02 39 04), мы тут же натыкаемся в файле python311.dll на шаблон (выделено красным) и код, расшифровывающий данные сразу после чтения из файла.

Чуть поигравшись с отладчиком x64dbg, мы обнаруживаем и место вызова этого кода:
Py_Main -> PyRun_SimpleFileObject -> PyMarshal_ReadLastObjectFromFile
, начиная с которого в отладчике можно следить за расшифровкой и интерпретацией байт‑кода.Попробуем для начала добиться какой‑то более‑менее понятной декомпиляции .pyc-файлов. Для этого напишем простенький расшифровщик, расксоривающий объект 0x5D полученной маской. Благодаря этому исходный файл удается привести в гораздо более читаемый вид.

К сожалению, этот код, хоть и сильно похож на настоящий, все равно не работает. На этот раз вылезает ошибка CreateObject: Got unsupported type 0x0. У меня возникло сильное подозрение на нестандартный маршалинг объектов внутри .pyc-файла.
По счастью, среди множества файлов пакета присутствуют и стандартные библиотеки (например, base64.pyc), которые имеются в нормально откомпилированном виде аналогичной версии.

Сразу видно, что побайтовое сравнение этих файлов — плохая идея, разница в коде на первый взгляд просто чудовищная. Начать с того, что даже по размеру стандартным образом откомпилированный base64.pyc почти в два раза меньше расшифрованного. Тем не менее с ходу заметно, что изначальная гипотеза о нестандартном маршалинге оказалась верной: в расшифрованном файле как минимум отсутствует 32-битное поле kwOnlyArgCount (выделено красным).
Слегка поборов первичный ужас, приступаем к решению проблемы. Берем за основу упомянутый в моей статье пакет pycdc и, дизассемблируя им исходный и расшифрованный base64.pyc, пытаемся найти разницу.
За загрузку объекта code здесь отвечает метод void PycCode::load(PycData* stream, PycModule* mod) модуля pyc_code.cpp. Сразу убираем следующую конструкцию:
Код: Скопировать в буфер обмена
Код:
if (mod->majorVer() >= 3)
m_kwOnlyArgCount = stream->get32();
else
m_kwOnlyArgCount = 0;
Код: Скопировать в буфер обмена
Код:
...
[Names]
'os'
'system'
'name'
'print'
'advanced_entry_message'
'simple_entry_message'
[Var Names]
'mode'
[Free Vars]
[Cell Vars]
[Constants]
None
'nt'
'cls'
'clear'
[Disassembly]
0 <INVALID>
2 LOAD_GLOBAL 1: system
4 <INVALID>
6 <INVALID>
8 <INVALID>
10 <INVALID>
12 <INVALID>
14 LOAD_ATTR 1: system
16 <INVALID>
18 <INVALID>
20 <INVALID>
22 <INVALID>
24 LOAD_GLOBAL 0: os
26 <INVALID>
28 <INVALID>
30 <INVALID>
32 <INVALID>
34 <INVALID>
36 LOAD_ATTR 2: name
...
Видимо, и тут нам программисты подложили свинью — помимо кастомного маршалинга, нам подсунули еще и кастомную систему команд, отличающуюся от стандартной 3.10 и даже 3.11.
Но и это прискорбное открытие нас не останавливает, ведь в предыдущей статье мы уже научились искать интерпретатор байт‑кода. Вот и здесь мы быстро находим его, например внутри функции _PyEval_EvalFrameDefault.

Правда, это нам не сильно помогает. К сожалению, мнемоники новых команд в интерпретатор не встроены, поэтому для воссоздания полнофункционального дизассемблера нам предстоит проверить 181 обработчик команд, сверив их с прежними таблицами опкодов из файлов python_3_10.cpp и python_3_11.cpp. Затем придется создать новую таблицу, причем даже количество команд тут не совпадает. Задача довольно муторная, хотя и исполнимая. Имеет смысл довести ее до конца в случае, если нам требуется полностью восстановить исходники программы. Если же нужно просто найти и поправить определенное место в коде, все гораздо проще: достаточно нащупать команды NOP и условные‑безусловные переходы. Тем более что NOP у нас на предыдущем скриншоте идет первой командой (опкод 0x53). Для патча теперь надо только отыскать нужное место (через исправленный дизассемблер), отредактировать его и заново перексорить по шаблону.
Задача написать полный дизассемблер или декомпилятор, конечно, сильно избыточна для разового патча конкретного специфического приложения, но сегодняшний пример может послужить основой для допиливания существующего декомпилятора по мере выхода новых версий Python. А выходят они регулярно.
Автор @МВК
Источник xakep.ru