Проблемы вызова Python кода из C кода

900ebb9c439e87eb5c3dee54de11a032

Привет, Хабр!

Меня зовут Никита Соболев, я опенсорс разработчик и 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>]

Что будет вызвано внутри?

  1. list.sort: в виде C имплементации list_sort_impl

  2. В нашем случае unsafe_object_compare (но может быть и safe_object_compare для немного другого случая): https://github.com/python/cpython/blob/c3ed775899eedd47d37f8f1840345b108920e400/Objects/listobject.c#L2637-L2656

  3. Где уже вызовется функция CAPI PyObject_RichCompareBool: https://docs.python.org/3/c-api/object.html#c.PyObject_RichCompare

  4. Которая уже вызовет do_richcompare и слот tp_richcompare: https://docs.python.org/3/c-api/typeobj.html#c.PyTypeObject.tp_richcompare

  5. А слот-враппер 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

Тут все просто:

Что делать? Конечно — увеличивать счетчик до сравнения, уменьшать сразу после:

// Вместо:
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. Потому что список уже пуст. Стейт просто не обновился, потому что автор кода такого не ожидал. Да что уж там, никто не ожидал!

Такая проблема со слайсами — довольно частая, они в целом часто меняет размерность мутабельных последовательностей, потому у нас есть две основные функции для работы с ними:

Правильное решение в данном случае: пересчитывать индексы после вызова 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

Добра!

© Habrahabr.ru