Привет!
Меня зовут Никита Соболев, я опенсорс разработчик и core-разработчик CPython.
Давайте поговорим про одну из самых сложных частей интерпретатора CPython – вызов Python кода из C кода. Почему сложных? Потому что Python может резко и внезапно менять стейт всего кода на C. А особо злобный код на Python вообще часто приводит к [1] 88503 segmentation fault python
Данный пост создан по материалам из моего канала в Телеграмеopensource_findings
: https://t.me/opensource_findings/842
Под катом – кишки питона, я предупредил!
Подготавливаем ноги к выстрелу
Сначала, давайте разберемся: как вообще можно вызвать Python код из C?
Существует множество кода, который делает так "by design". Например: вызов магических методов, которые определены пользователем. Скажем, мы сортируем список:
>>> class A:
... def __init__(self, number):
... self.number = number
... def __lt__(self, other):
... if not isinstance(other, A):
... return NotImplemented
... print(self, other)
... return self.number < other.number
... def __repr__(self):
... return f'A<{self.number}>'
...
>>> l = [A(2), A(3), A(1)]
>>> l.sort()
A<3> A<2>
A<1> A<3>
A<2> A<3>
A<1> A<3>
A<1> A<2>
>>> l
[A<1>, A<2>, A<3>]
Что будет вызвано внутри?
-
list.sort
: в виде C имплементацииlist_sort_impl
-
В нашем случае
unsafe_object_compare
(но может быть иsafe_object_compare
для немного другого случая): https://github.com/python/cpython/blob/c3ed775899eedd47d37f8f1840345b108920e400/Objects/listobject.c#L2637-L2656 -
Где уже вызовется функция CAPI
PyObject_RichCompareBool
: https://docs.python.org/3/c-api/object.html#c.PyObject_RichCompare -
Которая уже вызовет
do_richcompare
и слотtp_richcompare
: https://docs.python.org/3/c-api/typeobj.html#c.PyTypeObject.tp_richcompare -
А слот-враппер
slot_tp_richcompare
дляtp_richcompare
уже вызовет определенный нами магические метод__lt__
: https://github.com/python/cpython/blob/c3ed775899eedd47d37f8f1840345b108920e400/Objects/typeobject.c#L9920-L9936 внутри нашего класса
И уже здесь начинается много магии. Например: PyObject_RichCompare
может уйти в рекурсию, потому там есть специальные проверки от такого:
PyObject *
PyObject_RichCompare(PyObject *v, PyObject *w, int op)
{
PyThreadState *tstate = _PyThreadState_GET();
assert(Py_LT <= op && op <= Py_GE);
if (v == NULL || w == NULL) {
if (!_PyErr_Occurred(tstate)) {
PyErr_BadInternalCall();
}
return NULL;
}
if (_Py_EnterRecursiveCallTstate(tstate, " in comparison")) {
return NULL;
}
PyObject *res = do_richcompare(tstate, v, w, op);
_Py_LeaveRecursiveCallTstate(tstate);
return res;
}
Продолжаем стрелять по ногам с двух рук
Какие еще способы есть по вызову Python кода из C?
-
Обращение к магическим методам объектов:
PyObject_RichCompare
,PyObject_GetIter
,PyIter_Next
,PyObject_GetItem
, и тд -
Вызов переданных Python callback'ов:
PyObject_Call*
,PyObject_Vectorcall
, и тд -
Создание новых объектов:
PyObject_New
-
Специальный код, который прям имортирует и вызывает что-то из Python, как
call_typing_func_object
вtypevarobject.c
: https://github.com/python/cpython/blob/f95fc4de115ae03d7aa6dece678240df085cb4f6/Objects/typevarobject.c#L317-L333 -
И еще куча всего!
Рассмотрим два конкретных примера. Начнем с базы. Уменьшение ob_refcnt
в 0 при странных обстоятельствах. Например, такой код раньше крашился:
class evil:
def __lt__(self, other):
other.clear()
return NotImplemented
a = [[evil()]]
a[0] < a # crash without my patch
# [1] 9553 segmentation fault ./python.exe ex.py
Тут все просто:
-
other.clear()
уменьшаетob_refcnt
в 0 -
Python вызывает деструктор объекта
other
-
В месте сравнения мы уже сравниваем
PyObject*
объект сNULL
: https://github.com/python/cpython/blob/9e9ee50421c857b443e2060274f17fb884d54473/Objects/listobject.c#L3385 -
Получаем
use-after-free
Что делать? Конечно – увеличивать счетчик до сравнения, уменьшать сразу после:
// Вместо:
return PyObject_RichCompare(vl->ob_item[i], wl->ob_item[i], op);
// Используем:
PyObject *vitem = vl->ob_item[i];
PyObject *witem = wl->ob_item[i];
Py_INCREF(vitem);
Py_INCREF(witem);
PyObject *result = PyObject_RichCompare(vl->ob_item[i], wl->ob_item[i], op);
Py_DECREF(vitem);
Py_DECREF(witem);
return result;
Мой PR с фиксом: https://github.com/python/cpython/pull/120303
Второй, более сложный случай. Вызов PyObject_GetIter
. Данный код вызовет segmentation fault для Python <3.12.5
:
class evil:
def __init__(self, lst):
self.lst = lst
def __iter__(self):
yield from self.lst
self.lst.clear()
lst = list(range(10))
lst[::-1] = evil(lst)
# [1] 86725 segmentation fault python
Почему?
По сути, мы меняем сам список, в который вставляем слайс себя (да, я знаю, все плохо). Сначала из __iter__
мы вернем все нужные части для вставки через yield from self.lst
. А потом очистим список в self.lst.clear()
. Ну а далее C код получит обращение index out of bounds. Потому что список уже пуст. Стейт просто не обновился, потому что автор кода такого не ожидал. Да что уж там, никто не ожидал!
Такая проблема со слайсами – довольно частая, они в целом часто меняют размерность мутабельных последовательностей, потому у нас есть две основные функции для работы с ними:
-
PySlice_Unpack
: для получения начала, конца и шага в C коде https://docs.python.org/3/c-api/slice.html#c.PySlice_Unpack -
PySlice_AdjustIndices
: сделать их относительными для длины последовательности https://docs.python.org/3/c-api/slice.html#c.PySlice_AdjustIndices
Правильное решение в данном случае: пересчитывать индексы после вызова Python кода. Исправление данной проблемы с двойным пересчетом индексов слайса: https://github.com/python/cpython/pull/120442 До вызова __iter__
и после вызова __iter__
, когда стейт функции уже мог измениться.
И таких примеров падений сильно больше (сильно больше!):
За чем нужно следить в общем случае?
-
За вызовом потенциальных мест, где вызывается Python код из C
-
За "владением объектами" через
Py_INCREF
, если их можно удалить во внешнем коде -
За мутабельными объектами и их состоянием, как в случае с
PySlice_AdjustIndices
-
За ограничением определенных кусков кода флагом, который указывает, что мы прямо сейчас вызываем Python код. Как тут: https://github.com/python/cpython/pull/120297
// Проставляем флаг перед вызовом Python кода,
// чтоб не иметь доступа к разрушительной части:
pObj->flags |= POF_EXT_TIMER;
o = _PyObject_CallNoArgs(pObj->externalTimer);
pObj->flags &= ~POF_EXT_TIMER;
// ...
// Где-то вообще в другом месте, в деструкторе, например.
// Перед разрушительной частью проверяем, что мы не имеем данного флага:
if (self->flags & POF_EXT_TIMER) {
PyErr_SetString(PyExc_RuntimeError,
"cannot disable profiler in external timer");
return NULL;
}
И еще одна тысяча хаков, как остаться целым при вызове произвольного кода!
Как бороться с такими проблемами систематически?
Проблемы не самые простые и очевидные. Но критические. Конечно, методы борьбы с ними есть:
-
Фаззинг. В CPython используется
google/oss-fuzz
https://github.com/python/cpython/tree/main/Modules/_xxtestfuzz В него можно и нужно добавлять больше примеров и фаззеров! -
Флаги компилятора. Конечно же ужесточение компиляторов может помочь в некоторых случаях. Но, очевидно, не во всех. У нас тут не Раст. Пока
-
Санитайзеры:
--with-address-sanitizer
и--with-undefined-behavior-sanitizer
,--with-memory-sanitizer
,--with-thread-sanitizer
-
Специально исследовать подобные места при помощи кастомных скриптов, разметки и аудита
-
Собирать обратную связь от пользователей. Самый плохой способ
Заключение
Я надеюсь, что ваш код не будет злоупотреблять такой возможностью. Пожалуйста!
Помните, что вызов Python кода из C – всегда довольно опасно. И конечно медленно.
Если понравился такой формат, подписывайся на мой телеграм, где я пишу много подобного контента про CPython, Rust и другие языки, которые я разрабатываю: https://t.me/opensource_findings
Добра!
Автор: sobolevn