Я начал изучать Python в 2009 году, столкнувшись с очень нетривиальной и, кстати, необычной задачей на этом языке. Тогда я разрабатывал приложение для ПК, где графический пользовательский интерфейс создавался на PyQT, а основным языком в программе был Python.

Чтобы скрыть код, я встроил интерпретатор Python в самостоятельный исполняемый файл Windows. Это можно сделать при помощи множества инструментов (напр., pyinstaller, pyexe), все они функционально похожи. Они компилируют в байт-код ваши скрипты, написанные на Python, а далее, связывая их с интерпретатором, укладывают в исполняемый файл. Если компилировать скрипты, понижая их до байт-кода, то злоумышленникам становится сложнее добраться до вашего исходного кода и взломать ваше приложение. Байт-код приходится извлекать из исполняемого файла и декомпилировать. Кроме того, таким способом можно выполнять обфускацию кода, и в результате код становится гораздо сложнее понимать.

image

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

Несколько лет спустя мне стало очевидно, что никогда нельзя выполнять сторонний код в том же процессе, что и ваш код на Python, если вы опасаетесь утечек исходного кода. Существует множество продуктов для ПК, в которых Python используется в качестве основного скриптового языка. Некоторые подобные случаи сопряжены с риском.

Есть множество способов извлечь байт-код Python, даже если вы не выполняете никакого стороннего кода. Это нескончаемая гонка вооружений между разработчиками и специалистами по реверс-инжинирингу, но злоумышленнику гораздо проще извлечь байт-код и взломать вашу программу, если он может сам выполнять ваш код. Позже мою программу однажды взломали, не обращаясь к подсистеме плагинов.

Итак, что же можно поделать с «хост»-кодом, когда он выполняет ваши скрипты?

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

При работе с обычным процессом Python даже не требуется система плагинов. Всегда можно подключиться к действующему процессу при помощи GDB и внедрить ваш собственный код.

Обезьяний патчинг


Если плагин удастся инициализировать раньше, чем ту функцию, которую вы хотите модифицировать, то эту функцию можно просто сымитировать.
Предположим, у нас есть функция, проверяющая лицензию:

def validate_license():
    hw_hash, hw_id = get_hardware_id()
    data = {"timestamp": time.time(), "hid": hw_id, }
    r = requests.post('https://rushter.com/validate', data)
    server_hash = r.text
    return hw_hash == server_hash

Можно обойти эту проверку, заменив несколько функций:

def mock_licensing():
    requests = __import__('requests')
    licensing = __import__('licensing')


    def post(*args, **kwargs):
        mocked_object = types.SimpleNamespace()
        mocked_object.text = "a8f5f167"
        return mocked_object

    licensing.get_hardware_id = lambda: ("a8f5f167", 123)
    requests.post = post

Frame-объекты


В Python в объектах кадров (frame object) сохраняются состояния функций, исполняемых в настоящий момент. Имя каждого кадра соответствует выполнению ровно одной функции. В модулях и определениях классов Python также используются кадры. Кадр — это составная часть стека вызовов.

Имея объект кадра, можно:

  • Во время исполнения менять глобальные, локальные и встроенные значения.
  • Получать байт-код (блок кода) с той функцией, что выполняется в настоящий момент.

Вот как можно перечислить все кадры, относящиеся к актуальному стеку вызовов:

def list_frames():
    current_frame = sys._getframe(0)
    while current_frame.f_back:
        print(f"""
locals: {current_frame.f_locals}
globals: {current_frame.f_globals}
bytecode: {current_frame.f_code.co_code}
function name: {current_frame.f_code.co_name}
line number: {current_frame.f_lineno}
        """)
        current_frame = current_frame.f_back

В модуле inspect описываются все доступные атрибуты объектов frame и code.

Изменение локальных значений


Предположим, у нас есть функция, выполняющая обратный вызов, и мы управляем этим обратным вызовом. Например, путь для обратного вызова можно определить в файле настроек.

def get_amount():
    return 10


def update_database(user, amount):
    pass


def charge_user_for_subscription(user, logger=logging.info):
    amount = get_amount()
    print(amount)
    logger(amount)
    update_database(user, amount)
    print(amount)

Последняя функция тарифицирует клиенту месячную подписку и позволяет указать пользовательский обратный вызов, применяемый для пользовательского логирования. Можете скопипастить этот код и посмотреть, что получится.

Поскольку логирование происходит в середине процесса, можно модифицировать переменную amount.

def fix_amount(_):
    import ctypes
    # получить родительский кадр
    frame = sys._getframe(1)
    # обновить словарь с локальными переменными
    frame.f_locals['amount'] = -100
    # синхронизировать словарь
    ctypes.pythonapi.PyFrame_LocalsToFast(ctypes.py_object(frame), 0)
In [8]: charge_user_for_subscription('Ivan', fix_amount)
10
-100

Быстрая обработка локальных переменных


В Python локальные и глобальные переменные хранятся в словарях. Всякий раз, когда вы используете переменную, Python требуется найти её в словаре. Поскольку такие операции поиска в словаре требуют времени и ресурсов, в Python для этого применяются различные приёмы оптимизации.

Анализируя код функции, можно выявить имена тех переменных, которые будут использоваться в ходе выполнения функции. В нашей функции три локальные переменные: amount, user, logger. Аргументы функции — это тоже локальные переменные.

Компилируя исходный код в байт-код, Python отображает известные имена переменных на индексы в специальном массиве и сохраняет их там. Обращаться к переменной по номеру в индексе можно быстро, и у большинства функций — заранее определённые имена. Оптимизированные переменные называются fast locals. Python перестраховывается, чтобы не потерять имена переменных, генерируемых на лету, и для этого использует словарь.
При разыменовании переменных Python отдаёт приоритет быстрым локальным и игнорирует изменения в словаре. Вот почему мы пользуемся ctypes и вызываем внутреннюю функцию PyFrame_LocalsToFast.

Пропатчивание байт-кода


Есть статья о пропатчивании байт-кода, в которой описано, как вносить изменения в определение функции. Можно вносить изменения даже в такую функцию, которая выполняется прямо сейчас.

Интерпретатор Python выполняет не исходный код, а байт-код, сгенерированный при помощи специального компилятора. Работает над кодом специальная виртуальная машина, которая выполняет все инструкции по очереди. Поэтому невыполненные инструкции мы можем заменить прямо на лету.

Рассмотрим для примера следующую функцию:

def is_valid():
    return False


def check_license(callback):
    callback()
    if not is_valid():
        print('exiting')
        exit(0)

Встроенный модуль dis позволяет просмотреть байт-код в человеко-читаемом формате:

In [12]: check_license.__code__.co_code
Out[12]: b'|\x00\x83\x00\x01\x00t\x00\x83\x00s\x1ct\x01d\x01\x83\x01\x01\x00t\x02d\x02\x83\x01\x01\x00d\x00S\x00'

In [13]: dis.dis(check_license)
6           0 LOAD_FAST                0 (callback)
              2 CALL_FUNCTION            0
              4 POP_TOP

  7           6 LOAD_GLOBAL              0 (is_valid)
              8 CALL_FUNCTION            0
             10 POP_JUMP_IF_TRUE        28

  8          12 LOAD_GLOBAL              1 (print)
             14 LOAD_CONST               1 ('exiting')
             16 CALL_FUNCTION            1
             18 POP_TOP

  9          20 LOAD_GLOBAL              2 (exit)
             22 LOAD_CONST               2 (0)
             24 CALL_FUNCTION            1
             26 POP_TOP
        >>   28 LOAD_CONST               0 (None)
             30 RETURN_VALUE

Наша лицензия недействительна, и мы хотим удалить из кода оператор not. Для этого потребуется заменить инструкцию POP_JUMP_IF_TRUE на POP_JUMP_IF_FALSE.
Поскольку мы управляем функцией callback, можно прямо посреди неё поставить горячий патч.

import sys, ctypes

def fix():
    # получить родительский кадр
    frame = sys._getframe(1)
    # найти, где расположен байт-код
    memory_offset = id(frame.f_code.co_code) + sys.getsizeof(b'') - 1
    # обновить 10-й элемент в байт-коде
    ctypes.memset(memory_offset + 10, dis.opmap['POP_JUMP_IF_FALSE'], 1)

if __name__ == '__main__':
    check_license(fix)

Как видите, внутрисистемно байт-код хранится в виде bytes. К сожалению, мы не можем менять атрибут frame.f_code.co_code, поскольку он доступен только для чтения.

Чтобы обойти это ограничение, воспользуемся модулем ctypes, при помощи которого можно изменить ту часть оперативной памяти, в которой работает процесс Python. В каждом объекте bytes содержится метаинформация, в частности, количество ссылок и сведения о типе. Чтобы найти точный адрес необработанной строки на C, воспользуемся функцией id, возвращающей адрес объекта в памяти. При этом пропустим всю метаинформацию (размер пустой строки байт). Вывод из dis.dis показывает, что инструкция POP_JUMP_IF_TRUE — это 10-й элемент в строке байт, который нам необходимо заменить.

Извлечение исходного кода


Любой скрипт, выполняемый или импортируемый в Python, создаёт объект module, в котором хранятся константы, функции, определения классов и т.д. Если у вас нет исходного кода, то до него можно докопаться, декомпилировав байт-код.

Вот как можно перебрать все модули и найти все доступные функции:

for name, module in list(sys.modules.items()):
    if name not in ['license', 'runner']:
        continue
    for obj_name, obj in inspect.getmembers(module):
        if inspect.isfunction(obj):
            print(obj_name, obj.__code__)

К счастью (или к несчастью, смотря для кого) отсутствует лёгкий способ извлечь байт-код целого модуля, если у вас нет файла pyc (кэш байт-кода). Если вы хотите получить исходный код действующего процесса Python, то вам придётся извлечь байт-код всех функций, классов и определений модулей, а также код некоторых объектов frame. После этого вам потребуется прогнать этот код через один из декомпиляторов.

Заключение


В этой статье рассмотрен один из аспектов внутреннего устройства CPython. Сложно найти практическое применение разобранным здесь примерам, но надеемся, что эти детали языка будут интересны людям, исследующим компьютерную безопасность или участвующим в подобных состязаниях.

Комментарии (2)


  1. dyadyaSerezha
    11.06.2024 18:52
    +2

    И все же, если надо произвести отчуждаемое ПО, работающее на компах пользователей в виде черного ящика, то питон явно не лучший выбор.


  1. SquareRootOfZero
    11.06.2024 18:52

    Если компилировать скрипты, понижая их до байт-кода, то злоумышленникам становится сложнее добраться до вашего исходного кода

    Насколько я помню - не знаю, как щас, возможно, оптимизация скомпилированного питоновского байт-кода шагнула далеко вперёд - а в плюс-минус 2009 году этот байт-код легко и непринуждённо декомпилировался обратно в исходный практически один в один, чуть ли не с комментариями.