Главная / Блог / Reverse engineering Python-байткода в CTF: от .pyc-файла до флага через dis и uncompyle6

12 мин.00

Reverse engineering Python-байткода в CTF: от .pyc-файла до флага через dis и uncompyle6

Reverse engineering Python-байткода в CTF: от .pyc-файла до флага через dis и uncompyle6

Reverse engineering Python-байткода в CTF: от .pyc-файла до флага через dis и uncompyle6

На AlexCTF задача «unVM_me» решилась за минуту: декомпиляция .pyc через PyCDC вернула читаемый Python с цепочкой из 13 MD5-хешей — оставалось сбрутить 5-символьные блоки и собрать флаг ALEXCTF{...}. На следующем CTF тот же подход уже не прокатил: автор скомпилировал код под Python 3.9, и ни один открытый декомпилятор не выдал корректный результат. Команды, умевшие только запускать uncompyle6, потеряли полчаса и сдались. Те, кто открыл dis.dis() и прочитал байткод руками — достали флаг за двадцать минут.

Вот про второй подход и поговорим: от определения версии Python по магическому числу до побайтового восстановления алгоритма проверки флага из опкодов.

Структура .pyc-файла: магическое число pyc файл и объект кода

Прежде чем запускать декомпилятор, стоит разобраться, что лежит внутри .pyc. Файл состоит из заголовка фиксированного размера и сериализованного объекта кода. Подробнее — в нашем руководстве по бинарный анализ уязвимостей.

Заголовок начинается с магического числа — двух байт (плюс \r\n), уникальных для каждой минорной версии CPython. По ним интерпретатор решает, совместим ли байткод с текущей виртуальной машиной. При несовпадении — RuntimeError: Bad magic number. На CTF это первый контрольный вопрос: какая версия Python использовалась для компиляции?

Проверить magic number установленной версии можно через importlib.util.MAGIC_NUMBER. Полная таблица живёт в исходниках CPython, файл Lib/importlib/_bootstrap_external.py. Структура заголовка менялась между версиями:

  • Python 2.x: magic (4 байта) + timestamp (4 байта) = 8 байт заголовка
  • Python 3.3–3.6: magic + timestamp + source size = 12 байт
  • Python 3.7+: magic + flags (4 байта) + timestamp + source size = 16 байт (если flags & 0x1 == 0, иначе — hash-based validation)

После заголовка — объект кода, сериализованный модулем marshal. Этот python __code__ object содержит всё для исполнения:

co_code — сырой байткод (последовательность опкодов с аргументами). co_consts — кортеж констант: строковые литералы, числа, вложенные code objects. Часто флаг или его части лежат прямо здесь. co_names — глобальные имена и атрибуты. Набор вроде ('decode', 'len', 'False', 'all', 'zip', 'ord') сразу подсказывает паттерн «побайтовое сравнение через XOR» — до чтения единого опкода. co_varnames — имена локальных переменных. ('s', 'good', 'cs', 'cg') — и вы видите: аргумент s, эталон good, переменные для посимвольного сравнения. co_argcount — количество аргументов функции.

Конкретный пример: в CTF-задаче Gynvael's Mission 11 (reverse engineering Python-байткода CPython 2.7) одного взгляда на co_consts и co_names хватило, чтобы предположить алгоритм проверки задолго до анализа опкодов. Метаданные code object — карта территории, а опкоды — детализация маршрута.

Декомпиляция pyc файлов: uncompyle6, pycdc и когда автоматика бессильна

Первый рефлекс при встрече с .pyc — запустить декомпилятор. В большинстве задач начального уровня CTF reverse engineering Python это решает проблему мгновенно. Но у инструментов есть принципиальные ограничения, и знание этих ограничений экономит часы на соревновании.

Инструмент Поддержка версий Статус проекта Когда использовать Когда бесполезен
uncompyle6 Python 2.6–3.8 Новые версии Python не добавляются Задачи на Python 2.x и 3.x до 3.8 Python 3.9+ — ParseError
pycdc (Decompyle++) Заявлена поддержка всех версий CPython Активная разработка, регулярные коммиты Широкий диапазон версий, первый выбор Нестандартные опкоды, тяжёлая обфускация
decompyle3 Python 3.7–3.8 Узкая ниша Точное совпадение целевой версии Всё за пределами 3.7–3.8

Запуск uncompyle6 — одна команда: uncompyle6 -o . secret.pyc. При успешной декомпиляции получаете .py с полной логикой. Именно так решилась задача AlexCTF «unVM_me»: декомпилированный код показал список MD5-хешей и цикл проверки по блокам — оставалось только сбрутить короткие подстроки.

Для pycdc процедура чуть длиннее: клонирование репозитория и сборка через git clone https://github.com/zrax/pycdc.git && cd pycdc && cmake . && make. Зато pycdc справляется с версиями, где uncompyle6 уже бессилен.

Декомпиляция ломается в трёх предсказуемых сценариях:

Новая версия Python. Каждый минорный релиз CPython может вводить новые опкоды или менять нумерацию существующих. Декомпилятор, не знающий свежий опкод, выдаёт неполный вывод или падает. Типичный кейс: Python 3.9 при выходе не поддерживался ни одним открытым декомпилятором — и авторы CTF этим пользовались.

Намеренная обфускация байткода. Автор задачи модифицирует .pyc: подменяет magic bytes, вставляет мёртвый код, перетасовывает таблицу опкодов. По данным с Reverse Engineering Stack Exchange, некоторые коммерческие проекты используют модифицированные интерпретаторы с нестандартной нумерацией — тот же приём встречается в продвинутых CTF заданиях reverse Python.

PyInstaller-упаковка. Если задание — бинарник, а не голый .pyc, внутри может быть PyInstaller-бандл. Сначала вытаскиваем .pyc через pyinstxtractor (python3 pyinstxtractor.py binary.exe), потом декомпилируем. Заголовок извлечённого .pyc бывает побит — magic bytes придётся восстанавливать по версии Python, определённой через strings binary | grep -i python.

Короче: декомпилятор — первая попытка. Если за две минуты результата нет — переход к ручному анализу. Дальнейшие поиски «правильного инструмента» на CTF с тайм-лимитом — потеря очков.

Python bytecode disassembly через dis модуль: ручной анализ pyc файлов

Требования к окружению

  • Python, совпадающий с версией .pyc (минимум — мажорная + минорная версия). Для определения: magic bytes → таблица в исходниках CPython
  • Модули dis, marshal, struct — стандартная библиотека, ставить ничего не нужно
  • ОС: любая (Linux / macOS / Windows). RAM: 256 МБ за глаза. Сетевое подключение не требуется
  • Рекомендация: держите docker-образы с Python 2.7, 3.8, 3.11 наготове — переключение занимает секунды

Чтобы загрузить code object из .pyc и получить дизассемблированный вывод, используем связку marshal python bytecode + dis:

import dis, marshal, struct

with open('challenge.pyc', 'rb') as f:
    magic = f.read(4)
    f.read(12)  # остаток заголовка Python 3.7+: flags+ts+size
    code = marshal.loads(f.read())
print("co_consts:", code.co_consts)
print("co_names:", code.co_names)
dis.dis(code)

Смещение f.read(12) корректно для Python 3.7+. Для 2.x — f.read(4), для 3.3–3.6 — f.read(8). Неправильное смещение выдаст ValueError от marshal.loads() — пересчитайте размер заголовка.

Вызов dis.dis(code) печатает листинг: смещение в байтах, имя опкода, числовой аргумент, и в скобках — человекочитаемое значение (для LOAD_CONST — сама константа, для COMPARE_OP — тип сравнения). Если в co_consts есть вложенные code objects (тип types.CodeType), dis.dis() рекурсивно разберёт и их.

Ключевые опкоды для python opcode анализа

Для восстановления логики проверки флага хватает десятка инструкций. VM CPython — стековая: каждый опкод снимает значения с вершины стека и кладёт результат обратно.

Загрузка и сохранение. LOAD_CONST кладёт константу из co_consts, LOAD_FAST — локальную переменную (по индексу в co_varnames), LOAD_GLOBAL — глобальное имя из co_names. STORE_FAST записывает верхнее значение стека в переменную.

Вызовы. CALL_FUNCTION N вызывает функцию, забирая N аргументов со стека. В Python 3.11+ этот опкод переименован в CALL, принцип тот же.

Сравнения и ветвления. COMPARE_OP сравнивает два верхних значения стека (тип: ==, !=, <, > — в скобках). POP_JUMP_IF_FALSE / POP_JUMP_IF_TRUE — условные переходы, реализующие if/else. Аргумент — целевое смещение перехода в байткоде.

Арифметика. BINARY_XOR, BINARY_AND, BINARY_SUBTRACT, BINARY_ADD — побитовые и арифметические операции над двумя верхними элементами стека. В CTF заданиях reverse Python именно эти опкоды формируют алгоритм трансформации: XOR с ключом, маскирование через AND, сдвиг через SUBTRACT.

Циклы. GET_ITER + FOR_ITER — цикл for. Аргумент FOR_ITER — смещение выхода из цикла. LIST_APPEND внутри тела — list comprehension. Комбинация GET_ITER + FOR_ITER + UNPACK_SEQUENCE 2 — перебор через zip.

Восстановление исходного кода Python: разбор CTF задачи по опкодам

Разберём реальный паттерн CTF задачи (по данным из Gynvael's Mission 11, CPython 2.7). Функция check_password(s) получает строку и проверяет её через побайтовую трансформацию. Начало дизассемблированного листинга (между строками 21 и 33 опущены три опкода, аналогично вычисляющие len(good)):

  0 LOAD_CONST     1    # '4e5d4e92865a4e...' (hex-строка)
  3 LOAD_ATTR      0    # .decode
  6 LOAD_CONST     2    # 'hex'
  9 CALL_FUNCTION  1    # decode('hex') -> bytes
 12 STORE_FAST     1    # good = результат
 15 LOAD_GLOBAL    1    # len
 18 LOAD_FAST      0    # s (аргумент функции)
 21 CALL_FUNCTION  1    # len(s)
 33 COMPARE_OP     3    # != : сравниваем len(s) и len(good)
 36 POP_JUMP_IF_FALSE 43 # если равны — пропустить return False

Первый блок читается так: hex-строка декодируется в байты и сохраняется как good. Затем длина аргумента s сравнивается с длиной good — при несовпадении возвращается False. Ничего сложного, если знаешь, что STORE_FAST 1 — это запись в переменную с индексом 1 из co_varnames.

Дальше в листинге — цикл через zip(s, good), опознаваемый по последовательности LOAD_GLOBAL zipCALL_FUNCTION 2GET_ITERFOR_ITERUNPACK_SEQUENCE 2. Тело цикла — цепочка арифметических опкодов:

LOAD_GLOBAL ordCALL_FUNCTION 1LOAD_CONST 89BINARY_SUBTRACTLOAD_CONST 255BINARY_ANDLOAD_CONST 115BINARY_XORLOAD_CONST 50BINARY_XOR → затем второй ord(cg)COMPARE_OP ==.

Формула: (ord(cs) - 89) & 255 ^ 115 ^ 50 == ord(cg). Восстановленная функция:

def check_password(s):
    good = '4e5d4e92865a4e495a86494b5a5d4952'.decode('hex')
    if len(s) != len(good):
        return False
    return all(
        (ord(cs) - 89 & 255) ^ 115 ^ 50 == ord(cg)
        for cs, cg in zip(s, good))

Флаг извлекается обратной операцией: для каждого байта cg из good вычисляем chr(((ord(cg) ^ 50 ^ 115) + 89) & 255). В Python 2 (а задача была под 2.7 — видно по co_flags = 67 и использованию str.decode('hex')) строка декодируется через .decode('hex'), в Python 3 аналог — bytes.fromhex().

Этот паттерн — побайтовая трансформация через XOR и арифметику — встречается в CTF reverse engineering Python-задачах постоянно. Меняются конкретные числа и порядок операций, но структура байткода остаётся узнаваемой: цикл zip, ord на символе, цепочка BINARY_*, финальный COMPARE_OP ==. Через 5–10 решённых задач начинаешь опознавать этот паттерн визуально, ещё до полного построчного разбора.

Отдельная подсказка: co_names содержал ('decode', 'len', 'False', 'all', 'zip', 'ord'). Шесть имён — и по одной этой строке можно предсказать алгоритм: all + zip + ord = побайтовое сравнение с трансформацией. False в co_names вместо ключевого слова — маркер Python 2.

Обфускация python bytecode в CTF заданиях: приёмы и контрмеры

На задачах уровня medium и hard авторы намеренно ломают стандартный pipeline декомпиляции pyc файлов. Разберём типичные приёмы и как их обходить.

Подмена magic bytes. Автор меняет первые байты .pyc, чтобы ни Python, ни декомпилятор не определили версию. Контрмера: смотрим hex-дамп — стандартные номера опкодов конкретной версии выдают её. На практике: hexedit challenge.pyc, заменяем первые 4 байта на magic нужной версии из importlib.util.MAGIC_NUMBER. Один из самых частых «хаков» в CTF, который решается за минуту, если знаешь о его существовании.

Вложенные code objects и exec/eval. Основная логика спрятана в code object внутри co_consts, исполняемом через exec(). Декомпилятор видит вызов exec, но не разворачивает содержимое. Контрмера: после marshal.loads() рекурсивно обходим code.co_consts — объекты типа types.CodeType разбираются тем же dis.dis(). Одна строка: [dis.dis(c) for c in code.co_consts if hasattr(c, 'co_code')].

Многослойная упаковка (Python + нативный код). В CTF-задаче, описанной на fluix.one, .pyc был склеен с .so-библиотекой. Python-часть содержала фейковый пароль и функцию derive_key, но реальная проверка жила в C-коде, загружаемом через ctypes.CDLL. Функция lib() из .so подменяла derive_key через exec(), а настоящий алгоритм (real_derive_key) требовал препарирования нативного кода через Ghidra или radare2. Это техника Obfuscated Files or Information (T1027, MITRE ATT&CK), адаптированная для CTF. Тут python bytecode reverse engineering — только первый слой, а дальше классический реверс бинарника.

Base64/hex-кодирование констант. Строки в co_consts закодированы — визуально выглядят как случайный набор символов. В той же задаче CIPHERTEXT содержал base64-данные: часть — зашифрованный через Fernet флаг, часть — код для exec, извлекающий .so из тела бинарника. Соответствует технике Encrypted/Encoded File (T1027.013, MITRE ATT&CK). Контрмера: каждую строковую константу проверяем на base64/hex-паттерны и декодируем руками.

PyInstaller-бандлы. Исполняемый файл содержит архив с .pyc-файлами. Инструмент pyinstxtractor вытаскивает содержимое. Если magic bytes извлечённых .pyc побиты, смотрите файл struct внутри архива — он содержит magic нужной версии. При анализе реального малварного семпла с VirusTotal (Python-рансомвар, упакованный PyInstaller) весь цикл от pyinstxtractor до читаемого исходника через pycdc выявил генерацию ключа шифрования, список целевых расширений файлов и отправку ключа в Telegram-канал.

Алгоритм выбора подхода к CTF reverse python задачам

Перед уходом в ручной анализ — чеклист по порядку:

  1. Определить формат: .pyc или бинарник. Бинарник → strings для определения Python-версии → pyinstxtractor → извлечённый .pyc
  2. Определить версию Python по magic bytes
  3. Запустить pycdc — если вывод корректен, задача решена за секунды
  4. При ошибке pycdc — попробовать uncompyle6 (для Python до 3.8)
  5. Оба декомпилятора не справились — marshal.loads() + dis.dis() + ручное восстановление
  6. Байткод обфусцирован (нестандартные опкоды) — hex-дамп co_code, сопоставление с таблицей опкодов целевой версии

Время растёт на каждом шаге: автодекомпиляция — секунды, ручной dis — десятки минут, анализ обфусцированных опкодов — часы. На CTF с тайм-лимитом правильный порядок — разница между решённой задачей и потерянными очками.

Навыки python bytecode disassembly работают и за пределами соревнований. В терминах MITRE ATT&CK анализ обфусцированных .pyc — это Deobfuscate/Decode Files or Information (T1140), а Python как средство доставки малвари — техника Python (T1059.006, Execution). Один и тот же алгоритм — от определения версии до восстановления логики — применяется и на CTF, и при разборе Python-малвари в incident response.

За последние полтора года я вижу устойчивый тренд: авторы CTF-задач целенаправленно ломают декомпиляторы. Автоматическая декомпиляция превращает reverse в «запусти uncompyle6, прочитай код» — и весь образовательный смысл категории пропадает. Ручное чтение байткода отделяет участника, способного решить hard, от того, кто зависит от тулинга.

И парадокс: половина проигранного времени — не чтение опкодов, а борьба с окружением. Не та версия Python, не собирается pycdc, magic bytes побиты — команда теряет 20 минут на инфраструктуру вместо анализа. Docker-образ с тремя версиями Python и собранным pycdc, подготовленный до старта CTF, сокращает solve time задач rev вдвое. Writeups этот момент обычно пропускают — а он решает чаще, чем знание экзотических опкодов. Если хочешь не просто writeup, а пройти всю атаку самому — на WAPT есть лаба на каждый такой кейс.

🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».

Поделиться

0 комментариев

Пожалуйста, войдите, чтобы оставить комментарий.

Загрузка комментариев...