
На AlexCTF задача «unVM_me» решилась за минуту: декомпиляция .pyc через PyCDC вернула читаемый Python с цепочкой из 13 MD5-хешей — оставалось сбрутить 5-символьные блоки и собрать флаг ALEXCTF{...}. На следующем CTF тот же подход уже не прокатил: автор скомпилировал код под Python 3.9, и ни один открытый декомпилятор не выдал корректный результат. Команды, умевшие только запускать uncompyle6, потеряли полчаса и сдались. Те, кто открыл dis.dis() и прочитал байткод руками — достали флаг за двадцать минут.
Вот про второй подход и поговорим: от определения версии Python по магическому числу до побайтового восстановления алгоритма проверки флага из опкодов.
Прежде чем запускать декомпилятор, стоит разобраться, что лежит внутри .pyc. Файл состоит из заголовка фиксированного размера и сериализованного объекта кода. Подробнее — в нашем руководстве по бинарный анализ уязвимостей.
Заголовок начинается с магического числа — двух байт (плюс \r\n), уникальных для каждой минорной версии CPython. По ним интерпретатор решает, совместим ли байткод с текущей виртуальной машиной. При несовпадении — RuntimeError: Bad magic number. На CTF это первый контрольный вопрос: какая версия Python использовалась для компиляции?
Проверить magic number установленной версии можно через importlib.util.MAGIC_NUMBER. Полная таблица живёт в исходниках CPython, файл Lib/importlib/_bootstrap_external.py. Структура заголовка менялась между версиями:
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 — запустить декомпилятор. В большинстве задач начального уровня 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 с тайм-лимитом — потеря очков.
dis, marshal, struct — стандартная библиотека, ставить ничего не нужноЧтобы загрузить 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() рекурсивно разберёт и их.
Для восстановления логики проверки флага хватает десятка инструкций. 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.
Разберём реальный паттерн 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 zip → CALL_FUNCTION 2 → GET_ITER → FOR_ITER → UNPACK_SEQUENCE 2. Тело цикла — цепочка арифметических опкодов:
LOAD_GLOBAL ord → CALL_FUNCTION 1 → LOAD_CONST 89 → BINARY_SUBTRACT → LOAD_CONST 255 → BINARY_AND → LOAD_CONST 115 → BINARY_XOR → LOAD_CONST 50 → BINARY_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.
На задачах уровня 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-канал.
Перед уходом в ручной анализ — чеклист по порядку:
strings для определения Python-версии → pyinstxtractor → извлечённый .pycpycdc — если вывод корректен, задача решена за секундыuncompyle6 (для Python до 3.8)marshal.loads() + dis.dis() + ручное восстановление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 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...