Декомпиляция pyc в Python: что видно из байткода
pyc не содержит исходного текста, но хранит имена, константы и структуру. Разбираем, что восстановит декомпилятор, что потеряно навсегда и где проходит этическая граница.
Редакция Питоники
Урок 2 разобрал pyc по байтам, а урок 7 показал, как marshal упаковывает кодовый объект. Остался вопрос, ради которого pyc и достают с полки: сколько исходника можно получить обратно, если файл .py потерян или его вообще никто не давал. Ответ двойственный. Почти всё, что нужно для понимания программы, в pyc есть — но почти всё, что делает код читаемым для человека, туда не попало.
Что лежит в pyc вместо исходника
pyc — это заголовок с magic-числом и marshal-блок кодового объекта. Кодовый объект — контейнер с полями: имена, константы, инструкции. Проверим на живом компилировании, что в этих полях попадает и что не попадает. Исходник — строка total = price * count, как будто это единственная строка файла app.py:
src = 'total = price * count'
code = compile(src, 'app.py', 'exec')
print('константы-строки:', [c for c in code.co_consts if isinstance(c, str)])
print('имена в коде:', code.co_names)
print('исходный текст внутри?', src in str(code.co_consts))
константы-строки: []
имена в коде: ('price', 'count', 'total')
исходный текст внутри? FalseТри имени — price, count и total — пережили компиляцию целиком, а сам текст строки исчез: поиск по константам возвращает False. Умножение и порядок вычислений живут не в тексте, а в инструкциях виртуальной машины — их показывает dis из урока 4. Для этой строки дизассемблер отдаёт примерно такую картину:
1 0 LOAD_NAME 0 (price)
2 LOAD_NAME 1 (count)
4 BINARY_OP 5 (*)
6 STORE_NAME 2 (total)
Читаются инструкции прямолинейно: загрузить price, загрузить count, применить умножение, положить результат в total. Заметьте: и total тоже среди имён — записывающие инструкции индексируются в тот же список. А вот чего в байткоде нет совсем: комментариев. Компилятор их не пропускает в кодовый объект — при компиляции они теряются навсегда, никакой декомпилятор их не вернёт.
Инвентаризация функции: стартовая точка декомпилятора
Для функции кодовый объект щедрее: он хранит и имя самой функции, и её аргументы, и docstring. Возьмём небольшую функцию и соберём полную опись её байткода:
def checkout(price, count):
'''Считает стоимость покупки.'''
total = price * count
print('К оплате:', total)
return total
code = checkout.__code__
print('имя функции:', code.co_name)
print('аргументы:', code.co_varnames[:code.co_argcount])
print('строки-константы:', [c for c in code.co_consts if isinstance(c, str)])
print('имена из тела:', code.co_names)
имя функции: checkout
аргументы: ('price', 'count')
строки-константы: ['Считает стоимость покупки.', 'К оплате:']
имена из тела: ('print',)Это и есть то, с чего начинает любой декомпилятор: имя, сигнатура, docstring, строковые сообщения, список внешних имён. co_varnames хранит все локальные имена по порядку — аргументы идут первыми, поэтому срез до co_argcount отрезает именно их. Строки достаются фильтром по типу: среди констант живут и docstring, и текст для печати. Docstring — отдельный выигрыш для понимания: описание функции сохранилось при компиляции, потому что язык считает его частью функции.
Декомпилятор делает ровно то же самое, но автоматически — и добавляет шаг, который вручную делать долго: читает инструкции и собирает из них выражения. Мы можем повторить первый его ход руками — напечатать будущую сигнатуру:
def checkout(price, count):
'''Считает стоимость покупки.'''
total = price * count
print('К оплате:', total)
return total
code = checkout.__code__
args = code.co_varnames[:code.co_argcount]
print('сигнатура:', 'def ' + code.co_name + '(' + ', '.join(args) + '):')
print('строки для восстановления:', [c for c in code.co_consts if isinstance(c, str)])
сигнатура: def checkout(price, count): строки для восстановления: ['Считает стоимость покупки.', 'К оплате:']
Одна строка кода объекта — уже готовая первая строка исходника. Дальше декомпилятор по инструкциям понимает, что price и count перемножаются, что результат сохраняется в total, что print вызывается с текстом и total — и собирает тело функции заново. Вот пример того, как выглядит восстановленный код в итоге:
# Восстановлено из pyc: комментарии оригинала потеряны.
def checkout(price, count):
total = price * count
print('К оплате:', total)
return total
Логика на месте, имена на месте, даже docstring декомпилятор вернул бы внутрь функции. Не хватает только того, чего в pyc никогда не было: комментариев и исходного форматирования — восстановленный файл получает форматирование декомпилятора, а не автора.
Импорты и вложенные код-объекты
Функции в байткоде — это не текст, а вложенные кодовые объекты: каждый лежит в co_consts родителя. Декомпилятор просто обходит это дерево в глубину. Проверим на модуле с импортом и одной функцией — скомпилируем его из строки, как будто это целый файл loader.py:
src = 'import json\n\ndef load(text):\n return json.loads(text)'
code = compile(src, 'loader.py', 'exec')
print('имена верхнего уровня:', code.co_names)
nested = [c for c in code.co_consts if hasattr(c, 'co_name')]
print('функции внутри:', [c.co_name for c in nested])
print('аргументы load:', nested[0].co_varnames)
имена верхнего уровня: ('json', 'load')
функции внутри: ['load']
аргументы load: ('text',)Модуль ничего не скрывает: импорт json сразу виден по именам верхнего уровня — по нему декомпилятор восстановит строку import, а аналитик по нему увидит зависимости программы. Вложенный кодовый объект load лежит в константах модуля, и у него свои аргументы и свои имена. Так программа любой глубины раскрывается как матрёшка: модуль — функции — вложенные функции, у каждой свой кодовый объект со всеми полями.
Маршал-круг: pyc читается и без исходника
В настоящем pyc поля, которые мы только что читали, упакованы marshal-ом. Урок 7 показывал marshal как сериализацию кодовых объектов — здесь уместен обратный ход: десериализовать байты и убедиться, что все поля вернулись на место. Круг компиляция — marshal — размаршаливание — это ровно то, что делает декомпилятор, когда открывает настоящий файл:
import marshal
src = 'total = price * count'
code = compile(src, 'app.py', 'exec')
data = marshal.dumps(code)
restored = marshal.loads(data)
print('маршал-блок получен:', len(data) > 0)
print('байткод совпал после круга?', restored.co_code == code.co_code)
print('имена совпали?', restored.co_names == code.co_names)
маршал-блок получен: True байткод совпал после круга? True имена совпали? True
Размаршаленный объект неотличим от исходного: те же байты инструкций, те же имена. Для декомпилятора этого достаточно — исходный файл ему не нужен ни на одном шаге. Именно поэтому pyc, случайно попавший наружу, раскрывается так же легко, как тот, что лежит в __pycache__ у вас на диске.
Декомпиляторы: uncompyle6, decompyle3, pycdc
Инструментов восстановления несколько, и все они жёстко привязаны к версиям: байткод меняется от релиза к релизу, а декомпилятор — это по сути обратный компилятор для конкретного формата. Команда выглядит одинаково просто — на вход pyc, на выходе Python-файл:
pip install uncompyle6
uncompyle6 report.pyc > report_restored.py
# Кросс-версионный вариант, который старается и с новыми Python:
pycdc report.pyc > report_restored.py
| Инструмент | Версии Python | Состояние |
|---|---|---|
| uncompyle6 | от 1.0 до 3.8 | Проверенный ветеран; новые версии не поддерживает |
| decompyle3 | 3.7 - 3.8 | Продолжение той же идеи для современной тройки |
| pycdc | Старые и новые, частично | Декомпилятор на C; догоняет новые версии, восстановление неполное |
Чем новее Python, тем труднее работа: формат инструкций усложнялся, и на pyc свежих версий уверенно работает разве что pcdc, да и то не восстанавливает всё. Декомпиляция — это вечная гонка за релизами: поддержку новой версии сначала никто не спешит писать, а потом байткод снова меняется. Старый pyc, напротив, восстанавливается прекрасно — именно поэтому магия версии из урока 5 ещё и защита от неаккуратного чтения: несовпадающую версию декомпилятор просто не возьмёт.
| Видно из байткода | Потеряно навсегда |
|---|---|
| Имена переменных, функций, импортов | Комментарии автора |
| Строки-константы, docstrings, числа | Исходное форматирование и пустые строки |
| Структура: циклы, ветвления, вызовы | Написание литералов: 1 + 2 и 3 дают одинаковый байткод |
| Имя файла исходника (co_filename) | История правок и прежние версии кода |
Таблица честнее любых обещаний: декомпиляция возвращает каркас программы и все строки — то, что нужно, чтобы понять и продолжить код. Но Constant folding срабатывает ещё при компиляции: Python складывает 1 + 2 в тройку, и от исходного написания не остаётся следа. По восстановленному файлу нельзя понять, как код выглядел — можно понять только, что он делает.
Этика: технически можно — не значит можно
- быстрая опись pyc без декомпилятора: marshal.loads из урока 7 — и кодовый объект с полями в руках;
- имена и импорты — co_names верхнего уровня: зависимости программы видны сразу;
- docstrings и сообщения — фильтр строк по co_consts: понять смысл функции можно без восстановления;
- структура — dis из урока 4: циклы, ветвления и вызовы читаются прямо по инструкциям.
pyc хранит имена, константы и структуру кода, но комментарии и форматирование теряются при компиляции навсегда. Поэтому декомпиляция — это не волшебное возвращение исходника, а его честный каркас: всё, что нужно машине, вернулось, всё, что писалось для людей, — нет. Дальше по разделу мы посмотрим, где этот же байткод исполняют другие движки: следующий урок про реализации Python начинается с CPython — эталона, который всё это компилирует.
Сначала предскажи ответ в голове — это главный навык программиста.
src = 'x = 1'
code = compile(src, 'app.py', 'exec')
print('исходник внутри?', src in str(code.co_consts))
print('имена:', code.co_names)
def add(a, b):
return a + b
code = add.__code__
print(code.co_name, code.co_argcount)
import marshal
code = compile('n = 42', 'm.py', 'exec')
restored = marshal.loads(marshal.dumps(code))
print(restored.co_names == code.co_names)
1. Что из перечисленного НЕЛЬЗЯ восстановить из pyc ни одним декомпилятором?
2. В каком атрибуте кодового объекта хранятся имена, использованные в коде?
3. Почему uncompyle6 не возьмёт pyc, собранный Python 3.12?
4. Что найдёт декомпилятор в co_consts обычной функции?
5. Кто-то распространяет программу только в виде pyc, чтобы скрыть код. Насколько это защита?
6. Как соотносятся байткод для 1 + 2 и байткод для 3 в выражении?
7. Декомпиляция чужой проприетарной программы из pyc — это...
8. Зачем декомпилятору marshal.loads, если есть сам pyc?
Дана функция discount с docstring и сообщением для печати. Соберите опись: напечатайте кортеж аргументов и список строк-констант кодового объекта.
Можно ли восстановить исходник из pyc полностью?
Почти: декомпилятор для совпадающей версии Python вернёт имена, строки, docstrings и структуру кода. Комментарии, исходное форматирование и история правок потеряны при компиляции навсегда — вернётся каркас программы, а не её копия.
Урок 2: устройство .pycКакой декомпилятор работает с Python 3.12 и новее?
Для версий до 3.8 подходят uncompyle6 и decompyle3. Для свежих версий работает pcdc, и его восстановление частичное: чем новее Python, тем сильнее изменился байткод и тем хуже результат декомпиляции.
Защищает ли распространение pyc мой код от копирования?
Нет: pyc хранит имена, константы и структуру, и декомпилятор для совпадающей версии восстановит почти всё. Юридическая защита — условия лицензии; обфускация лишь замедляет разбор.
Законна ли декомпиляция чужой программы?
Только если правообладатель это разрешает: открытая лицензия с правом изучения, явное разрешение или ваш собственный код. Техническая возможность разобрать pyc не отменяет авторские права на программу.
Как посмотреть, что внутри pyc, без декомпилятора?
Прочитать marshal-блок и получить кодовый объект: co_names покажет имена и импорты, co_consts — строки и docstrings, dis — инструкции. Этой описи обычно достаточно, чтобы понять, что делает модуль.
Урок 4: dis — дизассемблерПонравился урок? Сошлитесь на него
«pyc хранит имена, константы и структуру кода, но комментарии и форматирование теряются при компиляции навсегда.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что дальше
Осталось в разделе: 20 из 20. Прогресс хранится в вашем браузере.
Что читать дальше
Компиляция · Урок 2
pyc-файл: устройство скомпилированного файла Python
pyc — это кодовый объект, упакованный в байты: 16 байт заголовка с magic-числом, временем и размером исходника, дальше marshal-блок с байткодом. Разбираем файл до последнего байта.
Компиляция · Урок 7
marshal: сериализация кодовых объектов Python
marshal — внутренний формат CPython, которым байткод упаковывается в pyc. Сериализуем кодовый объект, прочитаем его обратно и сравним marshal с json и pickle.
Компиляция · Урок 16
Реализации Python: CPython и альтернативные среды
Python — это язык, а CPython — его главная реализация. Разбираем, кто ещё исполняет Python-код, как узнать свою среду через sys.implementation и что гарантировано любому коду на любой реализации.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Pandas · Урок 2
Чтение файлов в Pandas: read_csv, read_excel и знакомство с данными
read_csv со всеми нужными параметрами и примерами: разделители, кодировки с кириллицей, чтение куска большого файла и первые ошибки, которые вы увидите на практике.
как читать csv файл в pandas
FastAPI · Урок 8
Аутентификация по JWT: защищаем эндпоинты
Строим аутентификацию по JWT: хешируем пароли с солью, выдаём токен на /login и закрываем эндпоинты зависимостью get_current_user.
защита эндпоинтов fastapi
Компиляция · Урок 4
dis: дизассемблер байткода Python
dis печатает байткод функции инструкция за инструкцией: какая команда, над какими значениями, куда прыгает условие. Учимся читать колонки вывода и понимать стековую машину.
байткод python читатьинструкции байткода python