Прим. Wunder Fund: в жизни каждого человека случается момент, когда ему приходиться позаниматься реверс-инжинирингом. В статье вы найдёте базовые особенности работы с ассемблером, а также прочитаете увлекательную историю господина, который решил написать Питон-библиотеку на ассемблере и многому научился на своём пути.

Иногда, чтобы полностью разобраться с тем, как что-то устроено, нужно это сначала разобрать, а потом собрать. Уверен, многие из тех, кто это читают, в детстве часто поступали именно так. Это были дети, которые хватались за отвёртку для того, чтобы узнать, что находится внутри у чего-то такого, что им интересно. Разбирать что-то — это невероятно увлекательно, но чтобы снова собрать то, что было разобрано, нужны совсем другие навыки.

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

Эксперимент, о котором я хочу рассказать, пронизан тем же духом. Мне хотелось узнать о том, смогу ли я написать расширение для CPython на чистом ассемблере.

Зачем мне это? Дело в том, что после того, как я дописал книгу CPython Internals, разработка на ассемблере всё ещё была для меня чем-то весьма таинственным. Я начал изучать ассемблер для x86-64 по этой книге, понял какие-то базовые вещи, но не мог связать их со знакомыми мне высокоуровневыми языками.

Вот некоторые вопросы, ответы на которые мне хотелось найти:

  • Почему расширения для CPython надо писать на Python или на C?

  • Если C-расширения компилируются в общие библиотеки, то что такого особенного в этих библиотеках? Что позволяет загружать их из Python?

  • Как воспользоваться ABI между CPython и C, чтобы суметь расширять возможности CPython, пользуясь другими языками?

Пара слов об ассемблере

Код на ассемблере — это последовательность команд, взятых из определённого набора инструкций. Разные процессоры имеют различные наборы инструкций. Самыми распространёнными наборами инструкций являются наборы для процессорных архитектур x86, ARM и x86-64. Существуют и расширенные наборы инструкций для этих архитектур. С выходом новых версий архитектур производители добавляют в их наборы инструкций новые команды. Часто это делается ради увеличения производительности процессоров.

У CPU имеется множество регистров, из которых он загружает данные, обработка которых выполняется с помощью инструкций. Данные можно копировать и из оперативной памяти (RAM, Random Access Memory), но нельзя скопировать данные между разными участками RAM, не прибегнув к регистрам. Это значит, что при написании программ на ассемблере нужно пользоваться длинными последовательностями команд для решения задач, которые в высокоуровневых языках решаются в одной строке кода.

Например, присвоим переменной a ссылку на переменную b в Python:

a = b

А в коде, написанном на ассемблере, переменную-источник сначала копируют в регистр (мы используем RAX), а потом содержимое регистра копируют в целевую переменную:

mov RAX, a
mov b, RAX

Если подробнее описать этот код, то окажется, что инструкция mov RAX, a копирует адрес переменной a в регистр. RAX — это 64-битный регистр, поэтому он может содержать любое значение, размер которого не превышает 64 бита (8 байт). В 64-битной операционной системе области памяти адресуются с использованием 64-битных значений, в результате адрес переменной будет представлен именно таким значением.

Ещё можно скопировать не адрес, а значение переменной в регистр, заключив имя переменной в квадратные скобки:

mov a, 1
mov RAX, [a]

Теперь содержимым регистра RAX будет десятичное число 1 (0000 0000 0000 0001 в шестнадцатеричном представлении).

Я выбрал регистр RAX из-за того, что это первый регистр, но если пишут независимое приложение, можно выбрать любой регистр.

Имена 64-битных регистров начинаются с r, первые 8 регистров можно использовать и для работы с 32-, 16- и 8-битными значениями путём обращения к их нижним битам. Адресация 32 бит регистра выполняется быстрее, поэтому большинство компиляторов используют адреса меньших регистров в том случае, если значение укладывается в 32 бита:

64-битный регистр

Нижние 32 бита

Нижние 16 бит

Нижние 8 бит

rax

eax

ax

al

rbx

ebx

bx

bl

rcx

ecx

cx

cl

rdx

edx

dx

dl

rsi

esi

si

sil

rdi

edi

di

dil

rbp

ebp

bp

bpl

rsp

esp

sp

spl

r8

r8d

r8w

r8b

r9

r9d

r9w

r9b

r10

r10d

r10w

r10b

r11

r11d

r11w

r11b

r12

r12d

r12w

r12b

r13

r13d

r13w

r13b

r14

r14d

r14w

r14b

r15

r15d

r15w

r15b

Так как код, написанный на ассемблере — это последовательность инструкций, организация ветвления в таком коде может оказаться непростой задачей. Для реализации ветвления используются команды условного и безусловного перехода, с помощью которых осуществляется перемещение указателя инструкций (регистр rip) на адрес нужной инструкции. В исходном коде, написанном на языке ассемблера, адресам инструкций можно назначать метки. Ассемблер заменит имена этих меток на реальные адреса памяти. Это могут быть либо относительные, либо абсолютные адреса (об этом мы поговорим ниже).

jmp leapfrog ; Переход на метку leapfrog
mov rax, rcx ; Эта строка никогда не будет выполнена
leapfrog:
mov rcx, rax

Следующий простой Python-код содержит команду ветвления при сравнении a с десятичным значением 5:

a = 2
a += 3
if a == 5:
  print("YES")
else:
  print("NO")

В ассемблере решить ту же задачу можно, упростив программу, сведя присваивание значения переменной и увеличение его на 3 к простому сравнению. Большинство компиляторов могут автоматически выполнять оптимизации такого рода, так как они способны определить, что программист сравнивает константы.

Вот как это выглядит в псевдокоде, напоминающем ассемблер:

 mov rcx, 2  ; Поместить десятичное значение 2 в регистр процессора RCX
 add rcx, 3  ; Добавить 3 к значению, хранящемуся в RCX, после чего там будет 5
 cmp rcx, 5  ; Сравнить RCX со значением 5 
 je YES      ; Если RCX равен 5, перейти к метке YES
 jmp NO      ; Перейти к метке NO
 YES:  ; RCX == 5
   ... 
   jmp END
 NO:   ; RCX != 5
   ...
   jmp END

Вызов внешних функций

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

В ассемблере сослаться на адрес внешней функции можно, воспользовавшись инструкцией extern с символическим именем функции. Компоновщик заменит эту конструкцию на конкретное значение в том случае, если с библиотекой установлена статическая связь, или, при динамической связи с библиотекой, нужное значение будет подставлено во время выполнения программы. Я не собираюсь вдаваться в подробности компоновки программ, иначе эта статья превратится в небольшую книгу (и я, честно говоря, не обладаю достаточно глубокими знаниями в сфере компоновки программ).

Если пишут приложение на C и необходимо вызвать функцию из какой-нибудь библиотеки — используют заголовочные файлы (файлы с расширением .h).

Заголовочный файл сообщает компилятору следующее:

  • Имя функции (символ).

  • Сведения о значении, которое возвращает функция, и о размере этого значения.

  • Сведения об аргументах функции и об их типах.

Предположим, мы определяем функцию в C:

char* pad_right(char * message, int width, char padding);

Подобное описание функции в заголовочном файле сообщает нам о том, что функция принимает 3 аргумента. Первый — это указатель типа char, то есть — 64-битный адрес 8-битного значения (char). Второй — это целое число (int), которое (что зависит от операционной системы и от некоторых других факторов), вероятно, будет представлено 32-битным значением. Третий — это 8-битное значение типа char. Функция возвращает указатель типа char, а это значит, что нам, чтобы сохранить это значение, понадобится где-то разместить 64-битный адрес.

При вызове функций из ассемблерного кода концепция аргументов не используется. Вместо этого операционная система определяет спецификацию (называемую «соглашением о вызовах»), описывающую соответствие регистров и аргументов.

В macOS и в Linux, к счастью, используется одно и то же соглашение о вызовах, System-V, в котором сказано, что значения аргументов при вызове функции должны быть размещены в следующих регистрах:

Аргумент

64-битный регистр

Аргумент 1

rdi

Аргумент 2

rsi

Аргумент 3

rdx

Аргумент 4

rcx

Аргумент 5

r8

Аргумент 6

r9

Отмечу, что в соглашении о вызовах Windows используются регистры, отличающиеся от тех, что описаны в System-V.

Дополнительные аргументы загружаются из стека значений, и, так как речь идёт о стеке, их в него помещают в обратном порядке. Например, если у функции есть 10 аргументов, то аргумент №10 помещают в стек первым:

 push arg10
 push arg9
 push arg8
 push arg7

Соглашение о вызовах указывает на то, что если вызывают функцию, написанную на C, на C++, или даже на Rust, то эта функция прочтёт то, что находится в регистре процессора rdi и использует это в виде своего первого аргумента.

Функцию pad_right() можно вызвать с помощью следующего кода, написанного на ассемблере:

extern pad_right
section .data
    message db "Hello", 0 ; Строка, завершающаяся нулём
section .bss
    result  resb 11
section .text
    mov rdi, db  ; аргумент 1 
    mov rsi, 10  ; аргумент 2
    mov rdx, '-' ; аргумент 3
    call pad_right
    mov [result], rax ; результат

В соглашении о вызовах говорится, что результат вызова функции попадает в регистр rax. Так как эта функция возвращает char *, мы ожидаем, что результат её вызова будет указателем (64-битным значением, представляющим адрес в памяти). Мы резервируем 11 байтов (10 символов и 0, завершающий строку) в сегменте bss, а потом записываем результат из rax в это место.

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

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

... выполнение неких действий с регистром r9
push r9
call externalFunction
pop r9

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

push rbp
mov rbp, rsp

... ваш код

mov rsp, rbp
pop rbp

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

Превращение программы, написанной на ассемблере, в исполняемый файл

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

Ассемблер — транслятор — берёт исходный код, написанный на языке ассемблера, и переводит его в машинный код. То, в каком именно формате будет представлен этот машинный код, зависит от операционной системы, для которой готовят исполняемый файл. Среди распространённых форматов исполняемых файлов можно отметить следующие:

  • Mach-O для macOS.

  • ELF для Linux.

  • PE для Windows.

В состав исполняемых файлов входят не только инструкции. В частности, речь идёт о следующем:

  • Инструкции машинного кода (в сегменте text).

  • Список внешних символов (внешних ссылок).

  • Список требований к памяти (сегмент bss — Block Started by Symbol — блок, начинающийся с символа).

  • Константы — наподобие строк (в сегменте data).

Заголовки исполняемых файлов содержат и другие данные, нужные операционной системе.

У файлов формата Mach-O имеется подробный заголовок, находящийся перед сегментами данных или инструкций. Мне нравится программа Synalyze It! — шестнадцатеричный редактор, который может применять так называемые «грамматики» для визуализации и декодирования бинарных файлов. Редактор поддерживает формат Mach-O. Если открыть исполняемый файл CPython (находящийся по адресу /usr/bin/python3 или там, где вы его установили), можно видеть и изучать эти заголовки.

Исследование бинарного файла в шестнадцатеричном редакторе
Исследование бинарного файла в шестнадцатеричном редакторе

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

  • Архитектура процессора, для которой был ассемблирован данный бинарный файл. Когда Apple выпустит MacBook с процессором ARM, на нём этот исполняемый файл работать не будет, так как система посмотрит на этот заголовок и, ещё до попытки загрузки инструкций, выявит несоответствие в архитектуре процессора.

  • Длина, позиция и смещение сегментов data, text и bss.

  • Флаги времени выполнения, такие, как PIE (Position-Independent-Executable, независимый от расположения исполняемый файл), о которых мы поговорим ниже.

Ещё одной особенностью форматов ELF, Mach-O и PE является возможность создания общих библиотек. В Linux они представлены .so-файлами, в macOS — это .dylib- или .so-файлы, в Windows это .dll-файлы.

Общую библиотеку можно импортировать в программу динамически (как плагин) или связать с программой на этапе её сборки в виде зависимости. При разработке C-расширений для CPython нужно связывать эти расширения с общими Python-библиотеками. И сами C-расширения являются общими библиотеками, динамически загружаемыми CPython (при выполнении команды вида import mylibrary).

Сложные структуры данных в ассемблере

Если вы вызываете функцию, аргументы которой имеют достаточно сложные типы данных (вроде указателя на struct), вам нужно знать о том, какой объём памяти необходим для хранения соответствующих типов данных C:

Скалярный тип

Тип данных C

Размер хранилища (в байтах)

Рекомендованное выравнивание

INT8

char

1

Байт

UINT8

unsigned char

1

Байт

INT16

short

2

Слово

UINT16

unsigned short

2

Слово

INT32

int, long

4

Двойное слово

UINT32

unsigned int, unsigned long

4

Двойное слово

INT64

__int64

8

Учетверённое слово

UINT64

unsigned __int64

8

Учетверённое слово

FP32 (одинарная точность)

float

4

Двойное слово

FP64 (двойная точность)

double

8

Учетверённое слово

POINTER

*

8

Учетверённое слово

Рассмотрим следующую структуру C, имеющую три целочисленных поля (xy и z):

typedef struct { 
    int x; 
    int y;
    int z;
} position

Для хранения каждого из этих четырёх полей понадобится 4 байта (32 бита). Объявим в C переменную myself:

position myself = { 3, 9, 0} ;

В шестнадцатеричном представлении эта переменная будет выглядеть так:

0000 0003 0000 0009 0000 0000

Эту структуру можно воссоздать на ассемблере NASM, используя макрос struc и механизм istruc:

section .data:
    struc position
        x: resd 1
        y: resd 1
        z: resd 1
    endstruc
    myself:
        istruc position
            at x, dd 3
            at y, dd 9
            at z, dd 0
        iend

Макрос struc — это то же самое, что и конструкция struct в C. Он позволяет описывать структуры, хранящиеся в памяти. Конструкция istruc позволяет поместить в память константы. Инструкция resd резервирует двойные слова (4 байта), а инструкция dd инициализирует зарезервированную память данными.

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

0000 0003 0000 0009 0000 0000

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

Предположим, в C имеется функция, использующая тип, определённый с помощью typedef:

void calculatePosition(position* p);

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

mov rdi, myself
call calculatePosition

Функции calculatePosition() неважно то, на каком языке написана программа, которая её вызывает. Это может быть C, ассемблер, C++ или какой-то другой язык.

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

Регистрация модуля расширения Python

Когда в Python загружают модуль, библиотека, отвечающая за импорт, заглянет в PYTHONPATH в поиске модуля, соответствующего предоставленному программистом имени.

Модули могут быть либо написаны на C (они представлены скомпилированными расширениями), либо на Python. Многие модули стандартной библиотеки CPython написаны на C, так как им нужны интерфейсы с низкоуровневыми API операционной системы (дисковый ввод/вывод данных, работа с сетью и так далее). Другие модули стандартной библиотеки написаны на Python. При создании некоторых из них используется и Python, и C. Это Python-модули с функциями-расширениями, написанными на C. Обычно подобные конструкции оформлены в виде общедоступного Python-модуля, к которому прилагается скрытый модуль, написанный на C. Python-модуль импортирует скрытый C-модуль и реализует обёртки для его функций.

Вот что нужно для того, чтобы написать модуль расширения на C:

  • C-компилятор.

  • Компоновщик.

  • Python-библиотеки.

  • Библиотека Setuptools.

C-код, который мы попытаемся воссоздать на ассемблере — это функция PyInit_pymult(), которая возвращает PyObject*, создаваемый путём вызова PyModule_Create2().

PyObject* PyInit_pymult() {
    return PyModule_Create2(&_moduledef, 1033); 
}

Существует несколько способов регистрации модуля. Я воспользуюсь так называемой «однофазной регистрацией» модуля.

Когда в Python-программе встречается конструкция import XYZ, система ищет следующее:

  1. Файл с именем XYZ-cpython-{version}-{os.name}.so по пути Python.

  2. Файл XYZ.so по пути Python.

В первом пункте этого списка показан файл библиотеки, скомпилированной для конкретной версии Python. В бинарном дистрибутиве пакета (wheel) может присутствовать несколько вариантов библиотеки, скомпилированной для разных версий Python. Например:

  • XYZ-cpython-39-darwin.so Python 3.9

  • XYZ-cpython-38-darwin.so Python 3.8

  • XYZ-cpython-37-darwin.so Python 3.7

Если вам интересно узнать о том, что такое «darwin», то знайте, что это — старое имя ядра macOS. Оно до сих пор используется в CPython.

PyModule_Create2() — это функция, которая принимает PyModule_Def  и int с версией CPython, для которой предназначен этот модуль.

Структуры типа определены в CPython, в файле Include/moduleobject.h:

typedef struct PyModuleDef_Base {
  PyObject_HEAD // Заголовок PyObject
  PyObject (m_init)(void); // Указатель на функцию init
  Py_ssize_t m_index; // Индекс
  PyObject m_copy; // Необязательный указатель на функцию copy()
} PyModuleDef_Base;
... 
typedef struct PyModuleDef{
  PyModuleDef_Base m_base; // Базовые данные 
  const char* m_name;      // Имя модуля
  const char* m_doc;       // Строка документации модуля
  Py_ssize_t m_size;       // Размер модуля
  PyMethodDef m_methods;  // Список методов, завершающийся NULL, NULL, NULL, NULL
  struct PyModuleDef_Slot m_slots; // Объявленные слоты для протоколов Python (например - eq, contains)
  traverseproc m_traverse; // Необязательный пользовательский метод обхода
  inquiry m_clear;         // Необязательный пользовательский метод очистки
  freefunc m_free;         // Необязательный пользовательский метод освобождения памяти (вызывается, когда модуль уничтожается сборщиком мусора)
} PyModuleDef;
...

Мы можем воссоздать эти структуры в ассемблере, пользуясь знаниями о требованиях к памяти, которые предъявляют базовые типы C:

default rel
bits 64
section .data
    modulename db "pymult", 0
    docstring db "Simple Multiplication function", 0
    struc   moduledef
        ;pyobject header
        m_object_head_size: resq 1
        m_object_head_type: resq 1
        ;pymoduledef_base
        m_init: resq 1
        m_index: resq 1
        m_copy: resq 1
        ;moduledef
        m_name: resq    1
        m_doc:  resq    1
        m_size: resq    1
        m_methods:  resq    1
        m_slots: resq   1
        m_traverse: resq    1
        m_clear: resq   1
        m_free: resq    1
    endstruc
section .bss
section .text

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

global PyInit_pymult

Функция init может загружать подходящие значения в структуру moduledef:

PyInit_pymult:
    extern PyModule_Create2
    section .data
        _moduledef:
            istruc moduledef
                at m_object_head_size, dq  1
                at m_object_head_type, dq 0x0  ; null
                at m_init, dq 0x0       ; null
                at m_index, dq 0        ; zero
                at m_copy, dq 0x0       ; null
                at m_name, dq modulename
                at m_doc, dq   docstring
                at m_size, dq 2
                at m_methods, dq 0 ; null - нет функций
                at m_slots, dq 0    ; null - нет слотов
                at m_traverse, dq 0 ; null
                at m_clear, dq 0    ; null - нет пользовательской функции clear()
                at m_free, dq 0     ; null - нет пользовательской функции free()
            iend

Инструкции функции init будут следовать соглашению о вызовах System-V, вызывая PyModule_Create2(&_moduledef, 1033):

    section .text
        push rbp                    ; защитить указатель стека от изменений
        mov rbp, rsp
        lea rdi, [_moduledef]  ; загрузить moduledef
        mov esi, 0x3f5              ; 1033 - версия API модуля
        call PYMODULE_CREATE2       ; создать модуль, оставить возвращаемое значение в регистре и вернуть результат
        mov rsp, rbp ; восстановить указатель стека
        pop rbp
        ret

Константа 0x3f5 — это 1033 — версия используемого нами API CPython.

Чтобы скомпилировать исходный код, нам нужно ассемблировать файл pymult.asm, а потом — скомпоновать его с библиотекой libpythonXX. Это двухшаговая процедура. На первом шаге создают, с использованием nasm, объектный файл. На втором шаге компонуют объектный файл с библиотекой Python 3.X (в моём случае — 3.9).

Для macOS мы используем объектный формат macho64, включим отладочные символы с использованием флага -g и сообщим компилятору NASM о том, что все символы должны иметь префикс _. Когда внешний модуль будет подключён к программе, PyModule_Create2 будет вызывать в macOS PyModule_Create2. А позже мы попробуем поработать с Linux и там такого префикса не будет.

nasm -g -f macho64 -DMACOS --prefix= pymult.asm -o pymult.obj
cc -shared -g pymult.obj -L/Library/Frameworks/Python.framework/Versions/3.9/lib -lpython3.9 -o pymult.cpython-39-darwin.so

Эта команда создаст артефакт pymult.cpython-39-darwin.so, который можно загрузить в CPython. Мы собираем проект с включёнными отладочными символами (флаг -g), поэтому сможем воспользоваться отладчиком lldb или gdb для установки в ассемблерном коде точек останова.

$ lldb python3.9
(lldb) target create "python3.9"
Current executable set to 'python3.9' (x86_64).
(lldb) b pymult.asm:128
Breakpoint 2: where = pymult.cpython-39-darwin.so`PyInit_pymult + 16, address = 0x00000001059c7f6c

Когда модуль загружается — lldb доходит до точки останова. Запустить процесс можно с использованием аргументов -c 'import pymult' для того, чтобы импортировать новый модуль и выйти:

(lldb) process launch -- -c "import pymult"
Process 30590 launched: '/Library/Frameworks/Python.framework/Versions/3.9/Resources/Python.app/Contents/MacOS/Python' (x86_64)
1 location added to breakpoint 1
Process 30590 stopped

thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
    frame #0: 0x00000001007f6f6c pymult.cpython-39-darwin.so`PyInit_pymult at pymult.asm:128
   125
   126          lea rdi, [_moduledef]  ; load module def
   127          mov esi, 0x3f5              ; 1033 - module_api_version

-> 128          call PyModule_Create2       ; create module, leave return value in register as return result
   129
   130          mov rsp, rbp ; reinit stack pointer
   131          pop rbp
Target 0: (Python) stopped.

Ура! Модуль инициализируется! В этот момент можно манипулировать регистрами или визуализировать данные.

(lldb) reg read
General Purpose Registers:
       rax = 0x00000001007d3d20
       rbx = 0x0000000000000000
       rcx = 0x000000000000000f
       rdx = 0x0000000101874930
       rdi = 0x00000001007f709a  pymult.cpython-39-darwin.so..@31.strucstart        rsi = 0x00000000000003f5        rbp = 0x00007ffeefbfdbf0        rsp = 0x00007ffeefbfdbf0         r8 = 0x0000000000000000         r9 = 0x0000000000000000        r10 = 0x0000000000000000        r11 = 0x0000000000000000        r12 = 0x00000001007d3cf0        r13 = 0x000000010187c670        r14 = 0x00000001007f6f5c  pymult.cpython-39-darwin.soPyInit_pymult
       r15 = 0x00000001003a1520  Python_Py_PackageContext        rip = 0x00000001007f6f6c  pymult.cpython-39-darwin.soPyInit_pymult + 16
    rflags = 0x0000000000000202
        cs = 0x000000000000002b
        fs = 0x0000000000000000
        gs = 0x0000000000000000

Ещё отладчик позволяет исследовать кадры и просматривать содержимое стековых кадров:

(lldb) fr info
frame #0: 0x0000000101adbf6c pymult.cpython-39-darwin.so`PyInit_pymult at pymult.asm:128
(lldb) bt

thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
  * frame #0: 0x0000000101adbf6c pymult.cpython-39-darwin.soPyInit_pymult at pymult.asm:128     frame #1: 0x000000010023326a Python_PyImport_LoadDynamicModuleWithSpec + 714
    frame #2: 0x0000000100232a2a Python_imp_create_dynamic + 298     frame #3: 0x0000000100166699 Pythoncfunction_vectorcall_FASTCALL + 217
    frame #4: 0x000000010020131c Python_PyEval_EvalFrameDefault + 28636     frame #5: 0x0000000100204373 Python_PyEval_EvalCode + 2611
    frame #6: 0x00000001001295b1 Python_PyFunction_Vectorcall + 289     frame #7: 0x0000000100203567 Pythoncall_function + 471
    frame #8: 0x0000000100200c1e Python_PyEval_EvalFrameDefault + 26846     frame #9: 0x0000000100129625 Pythonfunction_code_fastcall + 101

Для компиляции кода в расчёте на Linux нам нужно прибегнуть к поддержке PIE или PIC (Position-Independent Code, позиционно-независимый программный код). Обычно это делается компилятором GCC, но, так как мы пишем код на чистом ассемблере, нам придётся всё делать самостоятельно. Позиционно-независимый код может быть выполнен, без модификации, при размещении его в любом месте памяти. Единственные компоненты, при работе с которыми нам нужно учитывать позиции, это внешние ссылки на C API Python.

Мы не будем объявлять внешние символы так, как делали для macOS, рассчитывая на их статическое размещение в памяти:

call PyModule_Create2

Нам нужно иметь возможность обращаться к позиции символа с учётом глобальной таблицы смещений (Global Offset Table, GOT). В NASM имеется краткая конструкция для обращения к позициям символов в виде смещений с учётом таблицы компоновки процедур (Procedure Linkage Table, PLT) или GOT:

call PyModule_Create2 wrt ..plt

Вместо того чтобы поддерживать два файла с исходным кодом, один — для PIE-систем, другой — для систем, где PIE не используется, мы можем воспользоваться макросами NASM для замены инструкций при использовании NOPIE:

%ifdef PIE
    %define PYARG_PARSETUPLE PyArg_ParseTuple wrt ..plt
    %define PYLONG_FROMLONG PyLong_FromLong wrt ..plt
    %define PYMODULE_CREATE2 PyModule_Create2 wrt ..plt
%else
    %define PYARG_PARSETUPLE PyArg_ParseTuple
    %define PYLONG_FROMLONG PyLong_FromLong
    %define PYMODULE_CREATE2 PyModule_Create2
%endif

Затем заменим call PyModule_Create2 на значение из макроса call PYMODULE_CREATE2. При ассемблировании кода NASM заменит это на правильную инструкцию.

Linux использует, в отличие от macOS, формат ELF, а не macho64, поэтому укажем выходной формат файлов при вызове NASM:

nasm -g -f elf64 -DPIE pymult.asm -o pymult.obj
cc -shared -g pymult.obj -L/usr/shared/lib -lpython3.9 -o pymult.cpython-39-linux.so

Добавление функции в модуль

При инициализации модуля мы в виде списка функций используем значение 0 (NULL). Если мы воспользуемся тем же паттерном, что и раньше, структура PyMethodDef будет выглядеть так:

struct PyMethodDef {
    const char  ml_name;   / Имя встроенной функции или встроенного метода /
    PyCFunction ml_meth;    / C-функция, реализующая эту функцию или этот метод /
    int         ml_flags;   / Комбинация флагов METH_xxx, которые, по большей части,
                               описывают аргументы, необходимые C-функции */
    const char  ml_doc;    / Атрибут doc или NULL */
};

На ассемблере эти поля можно представить так:

    struc methoddef
        ml_name:  resq 1
        ml_meth: resq 1
        ml_flags: resd 1
        ml_doc: resq 1

        ml_term: resq 1  // Завершающий NULL
        ml_term2: resq 1 // Завершающий NULL
    endstruc
    method1name db "multiply", 0
    method1doc db "Multiply two values", 0
    _method1def:
        istruc methoddef
            at ml_name, dq method1name
            at ml_meth, dq PyMult_multiply
            at ml_flags, dd 0x0001 ; METH_VARARGS
            at ml_doc, dq 0x0
            at ml_term, dq 0x0 ; Объявления методов завершаются двумя значениями NULL,
            at ml_term2, dq 0x0 ; которые эквивалентны qword[0x0], qword[0x0]
        iend

Затем объявим функцию, эквивалентную аналогичной C-функции:

static PyObject* PyMult_multiply(PyObject *self, PyObject *args) {
    long x, y, result;
    if (!PyArg_ParseTuple(args, "LL", &x, &y))
        return NULL;
    result = x * y;
    return PyLong_FromLong(result);
}

Для написания модулей расширений на C (или на ассемблере) нужно уметь обращаться с C API Python. Например, если вы работаете с целыми числами в Python, то для их представления в памяти не подходят простые низкоуровневые структуры памяти, вроде тех, что используются для представления C-типа long. Для того чтобы преобразовать С-тип long в Python-тип long, нужно вызвать функцию PyLong_FromLong(). А для обратного преобразования — функцию PyLong_AsLong(). Так как в Python числа типа long могут быть больше, чем числа C-типа с таким же названием, при подобном преобразовании существует риск переполнения. Поэтому для решения той же задачи можно воспользоваться функцией PyLong_AsLongAndOverFlow(). А если long-значение Python уместится в значения C-типа long long — можно воспользоваться функцией PyLong_AsLongLong().

Выполнение вышеописанных действий сводится к передаче аргументов функции PyArg_ParseTuple(), которая конвертирует кортеж аргументов метода в родные типы C. Этой функции передают особую форматную строку и список указателей на целевые адреса.

Вот пример вызова этой функции, после которого аргументы преобразуются в два C-значения типа long (LL), а результаты преобразования размещаются в указанных адресах:

PyArg_ParseTuple(args, "LL", &x, &y)

Решая ту же задачу в ассемблере, функции PyArg_ParseTuple передают аргументы (в rsi), строку в виде константы и адреса двух зарезервированных мест в памяти, размер которых соответствует учетверённому слову.

В ассемблере это делается с использованием инструкции lea:

lea rdx, [x]

Преобразовать C-функцию в функцию, написанную на ассемблере, можно, прибегнув к соглашению о вызовах System-V:

global PyMult_multiply
PyMult_multiply:
    ;
    ; pymult.multiply (a, b)
    ; Multiplies a and b
    ; Returns value as PyLong(PyObject*)
    extern PyLong_FromLong
    extern PyArg_ParseTuple
    section .data
        parseStr db "LL", 0 ; преобразование аргументов в тип long long
    section .bss
        result resq 1 ; результат типа long 
        x resq 1      ; входное значение типа long
        y resq 1      ; входное значение типа long
    section .text
        push rbp ; защитить указатель стека от изменений
        mov rbp, rsp
        push rbx
        sub rsp, 0x18
        mov rdi, rsi                ; аргументы
        lea rsi, [parseStr]    ; Преобразование аргументов в LL
        xor ebx, ebx                ; очистить ebx
        lea rdx, [x]           ; сделать адрес x 3-м аргументом
        lea rcx, [y]           ; сделать адрес y 4-м аргументом
        xor eax, eax                ; очистить eax
        call PYARG_PARSETUPLE       ; Разобрать аргументы с помощью C-API
        test eax, eax               ; если PyArg_ParseTuple - NULL, выйти с ошибкой
        je badinput
        mov rax, [x]                ; умножить x и y
        imul qword[y]
        mov [result], rax
        mov edi, [result]           ; преобразовать результат в PyLong
        call PYLONG_FROMLONG
        mov rsp, rbp ; восстановить указатель стека
        pop rbp
        ret
        badinput:
            mov rax, rbx
            add rsp, 0x18
            pop rbx
            pop rbp
            ret

Далее, изменим определение модуля, включив в него определение нового метода at m_methods, dq _methoddef.

Если вы — пользователь Mac — рекомендую вам Hopper Disassembler, так как он поддерживает полезный режим вывода данных в виде псевдокода. Если открыть в нём свежий .so-файл и посмотреть на код только что созданной функции — можно убедиться в том, что её C-подобное представление выглядит примерно так, как того можно ожидать:

Работа с Hopper Disassembler
Работа с Hopper Disassembler

После повторной компиляции и повторного импорта модуля можно будет увидеть функцию в dir(pymult), и то, что она принимает два аргумента.

Установите точку останова в строке 77:

(lldb) b pymult.asm:77
Breakpoint 4: where = pymult.cpython-39-darwin.so`PyMult_multiply + 67, address = 0x00000001019dbf42

Запустите процесс и, после импорта, запустите функцию. Отладчик lldb должен остановиться на точке останова:

(lldb) process launch -- -c "import pymult; pymult.multiply(2, 3)"
Process 39626 launched: '/Library/Frameworks/Python.framework/Versions/3.9/Resources/Python.app/Contents/MacOS/Python' (x86_64)
Process 39626 stopped

thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 4.1
    frame #0: 0x00000001007f6f42 pymult.cpython-39-darwin.so`PyMult_multiply at pymult.asm:77
   74           imul qword[y]
   75           mov [result], rax
   76

-> 77           mov edi, [result]           ; convert result to PyLong
   78           call PyLong_FromLong
   79
   80           mov rsp, rbp ; reinit stack pointer
Target 0: (Python) stopped.
(lldb)

Проверить десятичное значение, хранящееся в регистре rax, можно так:

(lldb) p/d $rax
(unsigned long) $6 = 6

Ура! Работает!

Кстати сказать, на то, чтобы привести это всё в рабочее состояние, мне понадобилось перекомпилировать код 25-30 раз. Глядя в прошлое, я понимаю, что всё это устроено не особенно сложно, но заставить всё это работать было очень нелегко.

Одна из проблем ассемблера заключается в том, что программа либо работает, либо с треском «падает». Ассемблер безжалостен к разработчику. Стоит совершить ошибку и перед нами — либо «падение» процесса, либо повреждение хост-процесса.

Расширение setuptools/distutils

Если отправить в PyPi кучу файлов с исходным кодом, написанным на ассемблере, ничего хорошего не получится, так как после выполнения команды pip install соответствующий пакет окажется неработоспособным. Конечному пользователю этого пакета нужно знать о том, как компилировать библиотеки.

Пакет setuptools добавляет команду build_ext в файл setup.py. Предположим, в этом файле есть следующее:

...
setup(
    name='pymult',
    version='0.0.1',
    ...
    ext_modules=[
        Extension(
            splitext(relpath(path, 'src').replace(os.sep, '.'))[0],
            sources=[path],
        )
        for root, , _ in os.walk('src')
        for path in glob(join(root, '*.c'))
    ],
)

Если это так — можно запустить такую команду:

$ python setup.py build_ext --force -v

Она запустит компилятор GCC, передаст ему исходный код, скомпонует его с Python-библиотекой, соответствующей тому исполняемому файлу python, который использовали для запуска setup.py, и поместит скомпилированный модуль в директорию build.

Мы хотим пользоваться GCC для компоновки объектного файла, а для компиляции исходного кода, написанного на ассемблере, нам нужен NASM.

Есть ещё некоторые моменты, имеющие отношение к NASM, которые нам нужно учесть:

  • Если платформа не требует PIE — использовать -DNOPIE.

  • Использовать -f macho64 в macOS и -f elf64 в Linux.

  • Использовать -g для добавления отладочных символов в том случае, если setup.py был запущен с флагом, включающим отладку.

  • Добавить в macOS префикс.

Я предусмотрел это всё в собственной команде сборки setuptools, которая называется NasmBuildCommand. Можно сделать так, чтобы метод setup() использовал бы этот класс, а затем указать исходные .asm-файлы:

    cmdclass={'build_ext': NasmBuildCommand},
    ext_modules=[
        Extension(
            splitext(relpath(path, 'src').replace(os.sep, '.'))[0],
            sources=[path],
            extra_compile_args=[],
            extra_link_args=[],
            include_dirs=[dirname(path)]
        )
        for root, , _ in os.walk('src')
        for path in glob(join(root, '*.asm'))
    ],
)

Теперь, если запустить setup.py build с ключами -v (вывод подробных сведений о работе) и --debug (отладка) — он скомпилирует библиотеку:

$ python setup.py build --force -v --debug
running build
running build_ext
building 'pymult' extension
nasm -g -Isrc -I/Users/anthonyshaw/CLionProjects/mucking-around/venv/include -I/Library/Frameworks/Python.framework/Versions/3.8/include/python3.8 -f macho64 -DNOPIE --prefix= src/pymult.asm -o build/temp.macosx-10.9-x86_64-3.8/src/pymult.obj
cc -shared -g build/temp.macosx-10.9-x86_64-3.8/src/pymult.obj -L/Library/Frameworks/Python.framework/Versions/3.8/lib -lpython3.8 -o build/lib.macosx-10.9-x86_64-3.8/pymult.cpython-38-darwin.so

После того, как всё будет готово, можно создать wheel-пакет со скомпилированными бинарными файлами и с исходным кодом библиотеки:

$ python setup.py bdist_wheel sdist

А теперь этот wheel-пакет можно выгрузить на PyPi:

$ twine upload dist/*

Если некий пользователь загрузит этот пакет и окажется так, что в пакет входят файлы для платформы, на которой работает этот пользователь (в нашем случае — это только macOS), то система установит скомпилированную библиотеку. Если же пакет загрузит кто-то, кто работает на другой платформе — команда pip install попытается скомпилировать библиотеку из файлов с исходным кодом, используя особую команду build.

Обеспечить принудительное выполнение этой операции можно, выполнив команду вида pip install --no-binary :all: <package> -v --force. Это позволит понаблюдать за подробностями процесса загрузки и компиляции пакета.

Загрузка и компиляция пакета в подробностях
Загрузка и компиляция пакета в подробностях

Организация CI/CD-цепочек на GitHub

И наконец — мне хотелось оснастить GitHub-репозиторий проекта модульными тестами и системой непрерывного тестирования. Это подразумевает компиляцию кода с использованием сценариев GitHub Actions.

Теперь это не так уж и сложно, так как пакет setuptools был расширен в расчёте на выполнение сборки нашего проекта с помощью одной команды.

Тут имеется лишь один модульный тест, который осторожно избегает отрицательных чисел (!):

from pymult import multiply
def test_basic_multiplication():
    assert multiply(2, 4) == 8

Для тестирования кода в Linux я просто установил NASM с помощью apt, а затем, в директории с исходным кодом, выполнил команду python setup.py install, которая сама вызывает python setup.py build_ext:

jobs:
  build-linux:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.8]
    steps:
    - name: Install NASM
      run: |
        sudo apt-get install -y nasm
    - uses: actions/checkout@v2
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v2
      with:
        python-version: ${{ matrix.python-version }}
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip pytest
        python setup.py install
    - name: Test with pytest
      run: |
        python -X dev -m pytest test.py

Дополнительный флаг -X dev позволяет выдавать более подробные сведения когда (а не «если») CPython даст сбой.

В macOS сборка проекта представлена теми же шагами, за исключением того, что NASM устанавливается с помощью brew:

    - name: Install NASM
      run: |
        brew install nasm

А потом я решил испытать судьбу и попробовал это всё на Windows, воспользовавшись Chocolatey-пакетом NASM:

build-windows:
    runs-on: windows-latest
    strategy:
      matrix:
        python-version: [3.8]
    steps:
      - name: Install NASM
        run: |
          choco install nasm
      - name: Add NASM to path
        run: echo '::add-path::c:\Program Files\NASM'
      - name: Add VC to path
        run: echo '::add-path::C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\bin'
      - uses: actions/checkout@v2
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v2
        with:
          python-version: ${{ matrix.python-version }}
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip pytest
          python setup.py install
      - name: Test with pytest
        run: |
          python -X dev -m pytest test.py

Поддержка Windows

В итоге я расширил setuptools, организовав поддержку NASM и Microsoft Linker в виде особой реализации компилятора WinAsmCompiler.

Самыми серьёзными изменениями были следующие:

  • Использование объектного формата -f win64 (64-битный PE).

  • Использование -DNOPIE.

Правда, сам код работать отказался из-за того, что писал я его в расчёте на соглашение о вызовах System-V.

Для решения этой проблемы можно написать ещё одну функцию на ассемблере, или абстрагировать соглашения о вызовах, реализовав поддержку обоих стандартов, и переключаться между ними с помощью макроса. В этот момент я решил, что сделал всё, что хотел! (Код компилировался, но, при импорте, давал сбой).

Итоги

Полный исходный код того, о чём мы говорили, можно найти здесь.

Вот кое-что из того, что я узнал, занимаясь этим проектом:

  • Как исследовать регистры в отладчике lldb (это, на самом деле, очень полезный навык).

  • Как правильно использовать Hopper Disassembler.

  • Как устанавливать точки останова в ассемблированных библиотеках для того чтобы узнать, почему они могут давать сбои при возникновении ошибок сегментации.

  • Как setuptools/distutils компилируют C-расширения и как эти инструменты можно и нужно доработать в расчёте на современные компиляторы.

  • Как компилировать .asm-файлы с использованием сценариев GitHub Actions.

  • Как устроены объектные форматы и в чём различия форматов Mach-O и ELF.

Сферой применения этих знаний мне видится, в первую очередь, безопасность. Я всё ещё читаю книгу Shellcoder's Handbook, приступив к ней после Black Hat Python (стоящая книжка, кстати).

Хочу поделиться несколькими идеями применения полученных мной знаний:

  • Реверс-инжиниринг скомпилированных библиотек на предмет поиска в них эксплойтов.

  • Возможность участия в большем количестве испытаний на Hack The Box.

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

  • Создание шелл-код-эксплойтов для демонстрации ошибок, вызванных переполнением стека и другими событиями, которые, в обычных условиях, не происходят.

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

О, а приходите к нам работать? ????

Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.

Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.

Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.

Присоединяйтесь к нашей команде.

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