Перейти к содержанию

412 уроков, 25 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 15 из 20 Средний 15 мин 100 XP

Декомпиляция 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. Для этой строки дизассемблер отдаёт примерно такую картину:

как та же строка выглядит в dis
  1           0 LOAD_NAME                0 (price)
              2 LOAD_NAME                1 (count)
              4 BINARY_OP                5 (*)
              6 STORE_NAME               2 (total)
Пример текстовый: в песочнице дизассемблер урока 4 доступен на странице, но точный вид колонок и смещения зависят от версии Python. Суть от версии к версии одна: загрузить два имени, применить операцию, сохранить результат в третье имя.

Читаются инструкции прямолинейно: загрузить 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
Это иллюстрация вида восстановленного файла, а не работающая программа в песочнице: реальный результат зависит от декомпилятора и версии Python, для которой собран pyc.

Логика на месте, имена на месте, даже 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 — размаршаливание — это ровно то, что делает декомпилятор, когда открывает настоящий файл:

круг: компиляция, 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
Команды выполняются в терминале на своей машине, не в песочнице. report.pyc здесь — условное имя: настоящий скомпилированный файл лежит в __pycache__, и его имя несёт метку версии, а декомпилятор требует, чтобы версия файла совпала с поддерживаемой.
ИнструментВерсии PythonСостояние
uncompyle6от 1.0 до 3.8Проверенный ветеран; новые версии не поддерживает
decompyle33.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)
Проверь себя
0 / 8

1. Что из перечисленного НЕЛЬЗЯ восстановить из pyc ни одним декомпилятором?

2. В каком атрибуте кодового объекта хранятся имена, использованные в коде?

3. Почему uncompyle6 не возьмёт pyc, собранный Python 3.12?

4. Что найдёт декомпилятор в co_consts обычной функции?

5. Кто-то распространяет программу только в виде pyc, чтобы скрыть код. Насколько это защита?

6. Как соотносятся байткод для 1 + 2 и байткод для 3 в выражении?

7. Декомпиляция чужой проприетарной программы из pyc — это...

8. Зачем декомпилятору marshal.loads, если есть сам pyc?

Карточки терминов
Запомнено: 0 / 9
Практика

Дана функция discount с docstring и сообщением для печати. Соберите опись: напечатайте кортеж аргументов и список строк-констант кодового объекта.

practice.py
Вопросы и ответы по уроку

Можно ли восстановить исходник из 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Что дальше

Осталось в разделе: 20 из 20. Прогресс хранится в вашем браузере.

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.