Пишем Python-расширение на Ассемблере (зачем?)

Kate

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

922580ef6baf873cf502e82e923b96ae.jpg

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

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

Эксперимент, о котором я хочу рассказать, пронизан тем же духом. Мне хотелось узнать о том, смогу ли я написать расширение для 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 бит
raxeaxaxal
rbxebxbxbl
rcxecxcxcl
rdxedxdxdl
rsiesisisil
rdiedididil
rbpebpbpbpl
rspespspspl
r8r8dr8wr8b
r9r9dr9wr9b
r10r10dr10wr10b
r11r11dr11wr11b
r12r12dr12wr12b
r13r13dr13wr13b
r14r14dr14wr14b
r15r15dr15wr15b
Так как код, написанный на ассемблере — это последовательность инструкций, организация ветвления в таком коде может оказаться непростой задачей. Для реализации ветвления используются команды условного и безусловного перехода, с помощью которых осуществляется перемещение указателя инструкций (регистр 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-битный регистр
Аргумент 1rdi
Аргумент 2rsi
Аргумент 3rdx
Аргумент 4rcx
Аргумент 5r8
Аргумент 6r9
Отмечу, что в соглашении о вызовах 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Размер хранилища (в байтах)Рекомендованное выравнивание
INT8char1Байт
UINT8unsigned char1Байт
INT16short2Слово
UINT16unsigned short2Слово
INT32int, long4Двойное слово
UINT32unsigned int, unsigned long4Двойное слово
INT64__int648Учетверённое слово
UINT64unsigned __int648Учетверённое слово
FP32 (одинарная точность)float4Двойное слово
FP64 (двойная точность)double8Учетверённое слово
POINTER*8Учетверённое слово
Рассмотрим следующую структуру C, имеющую три целочисленных поля (x, y и 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. Причём, не в стандартной библиотеке, за которой, хочется надеяться, внимательно следят, а в сторонних библиотеках.

 
Сверху