— Заходил тут знакомый, так он за 5 минут базу данных уронил.
— Он хакер?
— Он рукожоп! На пол уронил.

Так получилось, что пару десятилетий занимался в том числе и базами данных. Ставил. Тюнил. Выносил логику В базу. Выносил логику ИЗ базы. Поднимал когда падали... Когда перешёл в большой SRE‑спорт, почти октаду лет поддерживал экзабайтные аналитические query engine. В общем, развлекался, как мог.

Главное открытие заключалось в том, что почти всё, что считается невозможным, случается. Люди креативны. Ты им дашь регексп — и они начнут майнить биткойны. Ты им дашь data lake — и они начнут хранить данные в именах таблиц. Ты им дашь GEO... Сам виноват.

Но самое интересное в том, что есть пласт проблем, который лежит совсем не в области современных сверхсложных методов параллелизации обработки, а в наследии 80ых годов прошлого века. То, что никого раньше не волновало, внезапно стало вызывать сбои. Причём в самом лучшем их проявлении — всё работает, клиент доволен, система растёт, цветёт и пахнет. А в один внезапный день запросы не работают, сервер падает, и что делать непонятно, так как никто ничего не менял.

И это ещё хороший сценарий, так как никто ничего плохого и не хотел: можно поймать запрос, заблокировать, и спокойно разбираться.

Кто подвержен

В общем‑то, все. Практически любая база данных с SQL интерфейсом уязвима к этому классу ошибок. Если база на Java или другом языке такого типа, ошибка транслируется в исключение, и падает только поток запроса. А если база на компилируемом языке (C, C++, Go, etc) — то та же уже приводит к системному исключению и она к этому не готова. Ежедневные MySQL (и MariaDB) вместе с PostgreSQL, модные TiDB / CockroachDB / ScyllaDB... и множество других можно (как минимум — было) уронить простым SQL запросом.

Корректным SQL запросом, я подчеркну. Ещё и без необходимости доступа к данным.

Кто виноват

На первый взгляд виноват SQL парсер. Грамматики большие, их проще писать и поддерживать в удобном виде, поэтому они как правило написаны на языках грамматик. Практически все основные парсеры грамматик (yacc/bison/antlr) создавались в прошлом веке, и их задача (неявно) была корректно обработать корректный вход. Обработка некорректного входа всегда была проблемой (именно потому часто ошибки при компиляции указывают куда‑то не совсем туда, где ошибка), но цена ошибки была «пользователь у себя на компе не может обработать данные у себя на компе».

Что‑то упало? Штош. Памяти не хватило? Штош. И тому подобное

Вот только мы не в 80ых годах, и код теперь обрабатывает не локальные данные локального пользователя, а удалённо работает над данными разных пользователей. Если упала база данных — она влияет на всех, кто от неё зависит всё время, пока она отжимается. А если при этом ещё и данные испорчены — то это к затяжным выходным.

Ещё, возможно, виноват программист, который неправильно сформировал запрос. Ну в самом деле, если ошибка в SQL запросе, база же не должна падать? Значит, виноват тот, кто отправил базе плохой запрос. К счастью, у нас есть миллион средств: клиентские библиотеки, которые корректно формируют SQL запросы, плохой ввод не пройдёт! У нас есть WAF, который не пустит злобного хакера! У нас есть SQL Blockers. У нас много чего есть!

И всё же, кто?

Живой пример

Возьмём глупый пример: форма поиска по каталогу. Чекбоксы для выбора категорий, где искать, поле поиска для фильтра по имени.

Навайбкоженый сервер строит запрос:

SELECT id, cat_id, cat_name, name
FROM items JOIN categories ON (items.cat_id=categories.cat_it)
WHERE 1=1
  AND (name ILIKE ?)
  AND (0=1 OR cat_id=1 OR cat_id=2 OR cat_id=3)

Ну не прекрасно ли? Всё легко, всё просто. Надёжно. Ни одного места для SQL Injection. Никаких ошибок в запросе. Никаких сверхсложных JOIN'ов. В базе 100 элементов.

.. а злобный хакер присылает форму, в которой миллион категорий, строится корректный но длинный запрос... и база данных съедает всю память и падает. Запрос корректный. Форма корректная. Запрос не такой и большой (в десяток мегабайт POST), чтобы его блокировал фаервол.

Как это работает

Просто возьмите postgresql и скормите ему запрос SELECT 1 WHERE 1=1 OR 1=1 OR 1=1. А теперь скормите запрос чуток длиннее (в 100 тыщ раз длиннее) — и сервер начинает есть память и работать медленно. Незначительно, конечно, но это вектор.

(здесь могла бы быть картинка про троллейбус, но не будет)

Добавь доступ к какой‑либо базе данных: SELECT oid FROM pg_database WHERE oid=1 с 1м условием — моментально. С 1000 условий — моментально. 100 тысяч — секунда. Миллион условий — почти минута. 10 миллионов — база данных кушает 8 гигабайт памяти, 100% ядра, хотя запрос «всего» 85 мегабайт и запрос отрабатывает через 3 часа. А если устал ждать и попробуешь убить клиента, запрос всё равно не прекращается на сервере!

Для неверующих:

$ docker run --name postgre --rm -e POSTGRES_HOST_AUTH_METHOD=trust postgres:13
$ for k in 100 1000 10000 100000 1000000 10000000; do
    echo "Size $k: "
    python -c 'print("SELECT oid FROM pg_database WHERE ", end=" OR ".join("oid=1" for k in range('$k')))' | 
      time docker exec -i postgre psql -U postgres
  done

И это простейший пример совсем простейшего ввода. Если мы работаем с аналитикой, запросы растут естественным образом:

WHERE A and B and C ... — CASE WHEN ... THEN ... WHEN ... THEN ... — FROM date1 UNION FROM date2 ... — JOIN cat1 JOIN cat2 ...

Любое место где можно повторить кусочек запроса несколько раз — потенциальное уязвимое место.

Да что далеко ходить, простой SELECT 1+1+1+1+1...:

$ docker run --name tidb --rm pingcap/tidb:latest
$ python -c 'print("SELECT ", end="+".join("1" for k in range(10000000)))' | mysql -h $(docker inspect tidb --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}') -P4000 -uroot

В консоли сервера:

runtime: goroutine stack exceeds 1000000000-byte limit
runtime: sp=0xc17a1f0388 stack=[0xc17a1f0000, 0xc19a1f0000]
fatal error: stack overflow

Что же делать

А мы покупаем, или продаём? Если мы со стороны приложения — то можно втыкать проверки и ограничения на количества повторяемых элементов: ограничивать количество в IN (?), ограничивать количество OR/AND, ограничивать число элементов в форме, понизить максимальный размер форм которые можно разобрать — главное не переборщить и не поломать ещё и загрузку картинок. В общем, sql injection это не единственное, на что стоит обращать внимание.

Если мы со стороны базы: то ответ «стресс‑тест в глубину». Обычные fuzz или random тесты не в состоянии протестировать «а как себя сервер поведёт на запрос в мегабайт длиной». Ну в смысле, они могут строить «в глубь», но каждый следующий символ на вход для fuzz экспоненциально увеличивает площадь тестирования. А простой тест на 100–1000-миллион параметров протестирует только факт существования одной из проверок.

В реальности же, запрос проходит через много стадий обработки, и если парсер хорошо справляется с запросом в мегабайт и отказывается обрабатывать запрос в гигабайт, это не значит, что он не помрёт на запросе в 2 мегабайта.

Если парсер прожевал запрос в мегабайт, не значит, что оптимизатор обработает любое полученное дерево — так как на выходе мы можем получить как глубокое так и широкое дерево. А если оптимизатор его прожевал, не факт, что дерево не стало еще хуже для следующей стадии. И так далее.

В прошлом, я пришёл к достаточно эффективному методу тестирования этих узких мест. Пример этих тестов можно найти в ZetaSQL, где они претерпели несколько миграций и переписываний после меня и даже переехали на другую грамматику — но логика в depth_limit_detector_test_cases сохранилась: тесты «в глубину» идут через измерение поведения между двумя крайностями: супер маленький и супер большой тест (плюс несколько шагов между ними). Если где‑то поведение системы отличается (разные ошибки, или успех с одной стороны и ошибка с другой) — мы делим интервал пополам до тех пор, пока не найдём точку, где повторение теста на 1 дополнительный шаг меняет поведение.

Тесты сжаты до 5-элементных шаблонов: (начало, повтор1, середина, повтор2, конец), что лаконично выражает кучу случаев и при этом может эффективно генерировать запросы любой глубины N.

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

Один минус подхода: требуется создание и поддержка тестовых шаблонов вручную. Это хорошо в простых случаях ограниченной грамматики и в процессе разработки, когда на каждую новую фичу добавляется тест. Но не подходит для случаев тучных грамматик, накопивших пласты совместимости между разными версиями, особенно если уже никто не помнит все фичи, которые в ней наросли.

Что тут думать, трясти надо

Так же как хорошие ручные тесты дополняются Fuzz и QuickCheck, хорошие тесты на масштабирование грамматики дополняются геееенеерааатооорооом дллиииинннныыыыххх запппрооооосоооооов. Или, как я его назвал, SQeeeL.

Идея проста: берём грамматику, находим в ней крачайшие циклы. Каждый цикл — потенциальный кандидат для проблем.

Для каждого цикла находим кратчайший путь до него (это «начало»), кратчайший путь после него (это «конец»), и находится кратчайшее его внутреннее представление — это будет «середина». Сам цикл превращается в «повтор1»/«повтор2».

Всё, мы получаем генератор шаблонов под конкретную грамматику.

Чтобы поиск давал что‑то интересное, а не только ошибки разбора грамматики, добавим в шаблоны подсовывание имён таблиц и прочие мелочи, типа генерации уникальных псевдонимов, и проч. Всё, можно трясти!

И что теперь

Даже черновым наброском тестера удалось поймать много однотипных ошибок в разных базах: простые повторения 1+1+1+1, простые скобочки (((((1))))), простые списки как параметров SELECT 1,1,1,1 так и туплов SELECT 1 WHERE 1 IN (1,1,1,1,1); много вариаций проблем с джойнами. Примеры, вместе с номерами CVE и багрепортами — в репе.

При попытке бегать за вендорами, чтобы принести им репорт, выяснилось, что некоторые базы данных считают это CVE (в конце концов DoS это DoS); некоторые считают просто ошибкой пользователя.

«А как называете его вы?»


Английская версия в LinkedIn и Medium