
Обнаружение создаваемых компилятором загрузок из памяти, которые превращают безопасный код на C в TOCTOU-уязвимости. Включает автоматизированный аудит исходного кода, бинарный анализ на основе Unicorn и прогоны по компиляторам/архитектурам/флагам в 100+ проектах.
«...определение „вменяемого компилятора“ становится всё более расплывчатым.»
Бинарный файл, который вы запускаете, — это не программа, которую вы написали. Оптимизатор компилятора переписывает
ваш исходный код так, что вы этого никогда не видите — и некоторые из этих изменений могут
незаметно и легально превращать, казалось бы, безопасный код в
уязвимые бинарные файлы. Та же самая строка может быть
безопасной в одном компиляторе и эксплуатируемой в другом,
при этом ничто в исходном коде не говорит вам, какой именно: уязвимость в
суперпозиции, схлопывающаяся только при сборке. Schrödinger's TOCTOU исследует
изобретённые компилятором загрузки и их широкие
последствия для уязвимостей типа «время проверки — время использования» (TOCTOU) — обнаруженных
в проектах с открытым исходным кодом:
ядрах,
гипервизорах,
анклавах,
прошивках,
и библиотеках.
Куда ни посмотри, казалось бы, безопасный код остаётся во власти прихотей
компилятора. Но это лишь выборка, а не граница; те
же ошибки весьма вероятны и в вашем коде.
«Начните с чего-нибудь простого.»
Сколько раз эта функция загружает *p?```c
unsigned int g(unsigned short *p)
{
short t = p; / copy *p into a local for safekeeping */
return (unsigned short)t - t;
}
Подсказка: ответ — 1 — исходный код загружает `*p` один раз в `t`.
Вставьте его в [Compiler Explorer](https://godbolt.org/z/c5K9P4dPd) (`arm gcc 14.2.0`,
`-O2`) и посчитайте загрузки из `r0`, который содержит `p`:```asm
g:
ldrh r2, [r0] # load *p, once
ldrsh r0, [r0] # load *p, twice
subs r0, r2, r0
bx lr
Одно чтение в исходном коде, два — в бинарнике. Второе — изобретённое чтение — чтение, которое создал компилятор, хотя вы его не писали. Оно допустимо в рамках абстрактной машины C, которая предполагает, что память не может измениться между двумя чтениями. Но когда эта память доступна атакующему для записи, предположение становится эксплойтом: изобретённое чтение может оказаться после проверки безопасности, незаметно открывая окно от проверки до использования (TOCTOU), которое программист считал закрытым. Больше не гарантировано, что значение, которое вы проверили, и значение, которое вы используете, совпадают — даже если вы никогда не писали код, который перечитывает его.
Задача доказывает, что изобретённое чтение существует; посмотрим, как это превращается в повреждение памяти.
В уязвимости TOCTOU программа проверяет, что значение безопасно, затем использует значение. Однако окно для эксплуатации существует, если атакующий может изменить значение в крошечном промежутке времени между этими двумя чтениями – безвредное значение проходит проверку, а опасное — именно то, которое используется:```c if (shared->len <= 20) // CHECK reads shared->len // ** attacker modifies shared->len ** memcpy(out, shared->data, shared->len); // USE reads it again: buffer overflow
Классическое исправление — **сначала сделать снимок**: скопировать любые данные, с которыми атакующий может возиться, в локальную переменную, недоступную атакующему, и затем не доверять ничему, кроме этой локальной переменной. Как только `len` оказывается в локальной переменной, она замораживается — атакующий, участвующий в гонке за разделяемую память, больше не может до неё добраться, — поэтому проверка и копирование гарантированно видят одно и то же значение. Именно так код в `receive` ниже устраняет проблему TOCTOU: он делает снимок сообщения, проверяет снимок и публикует проверенную копию в `slot`, чтобы потребитель мог её переслать:```c
#include <string.h>
struct message {
int len; /* payload length */
char data[20]; /* payload */
};
struct message slot; /* the most recently validated message */
char out[20]; /* fixed 20-byte destination */
void receive(struct message *shared) {
struct message local = *shared; /* 1. snapshot untrusted input */
if (local.len <= 20) /* 2. validate the snapshot */
slot = local; /* 3. publish the validated copy */
}
void forward(void) { /* the time of use, later */
memcpy(out, slot.data, slot.len); /* slot.len was checked <= 20 ... right? */
}
Исходно, по коду, это корректно. len читается ровно один раз — в снапшот —
поэтому значение, прошедшее проверку <= 20, — это значение, опубликованное в slot.
Окно TOCTOU закрыто, и код безопасен.
Но это не так. При компиляции x86-64 gcc с -O2 функция receive читает его из
исходной разделяемой памяти дважды: один
раз как скаляр для проверки условия и ещё раз как часть массового
копирования, результат которого публикуется в slot:```nasm
receive:
cmp DWORD PTR [rdi], 20 ; READ #1: the CHECK reads shared->len directly
movdqu xmm0, XMMWORD PTR [rdi] ; READ #2: the bulk copy re-reads it (len is byte 0)
mov rax, QWORD PTR [rdi+16] ; (the bulk copy's tail: struct bytes 16-23)
jg .L1 ; len > 20? skip the publish
mov QWORD PTR slot[rip+16], rax ; (publish that tail)
movaps XMMWORD PTR slot[rip], xmm0 ; and publish the TOCTOU-vulnerable snapshot
.L1:
ret
forward:
movsx rdx, DWORD PTR slot[rip] ; copy size = slot.len, the unchecked READ #2 value
mov esi, OFFSET FLAT:slot+4 ; src = slot.data
mov edi, OFFSET FLAT:out ; dst = out[20]
jmp memcpy ; copies slot.len bytes into out[20]
Проверка выполняется на ЧТЕНИИ №1; значение, которое попадает в `slot.len`, — это ЧТЕНИЕ №2. Атакующий, который изменяет `len` между ними, передаёт проверке `<= 20` безопасное значение, тогда как переразмеренное публикуется в `slot`, — и `forward` затем копирует это количество байтов в `out[20]`, порождая то самое переполнение, которое снимок должен был предотвратить и которое вернул оптимизатор.