Анатомия инъекции: данные против кода
Все инъекции — это когда интерпретатор принял ваши данные за свою команду.
Инъекция — уязвимость, при которой недоверенные данные попадают в интерпретатор (SQL, shell, LDAP, шаблонизатор) и часть данных исполняется как команда.
Один механизм на много языков
SQL-инъекция, командная инъекция, LDAP-, NoSQL-, шаблонная инъекция выглядят по-разному, но устроены одинаково. Где-то есть интерпретатор — компонент, который разбирает текст на команды и данные. Если вы собираете этот текст конкатенацией из доверенной части (ваш запрос) и недоверенной (ввод пользователя), интерпретатор не может отличить, где кончается код и начинаются данные. Достаточно, чтобы ввод содержал «управляющие» символы — и часть данных станет командой.
Корень проблемы стоит сформулировать в одну фразу: данные и код едут по одному каналу — текстовой строке. Для интерпретатора нет разницы между «текстом, который написал разработчик» и «текстом, который пришёл от пользователя» — он видит единую строку и разбирает её по своей грамматике целиком. Граница между «это команда» и «это просто значение» существует только в голове программиста; в самой строке её нет. Атака — это умение пользователя сдвинуть эту воображаемую границу, дописав во вводе символ, который интерпретатор воспримет как переход от данных обратно к коду.
Именно поэтому инъекции так живучи и так универсальны. Это не баг конкретной библиотеки и не недосмотр одного драйвера, а структурное следствие смешения двух сущностей в одном представлении. Меняется технология — SQL, оболочка, каталог LDAP, документная БД, движок шаблонов, — но как только в дело идёт «склею строку из своего и чужого и отдам интерпретатору», возникает та же самая брешь. Поэтому осмысленно изучать инъекции не как длинный список частных уязвимостей, а как один принцип с разными декорациями.
Замысел программиста: [команда: найди заказ][данные: id = 42]
Что собрала строка: SELECT * FROM orders WHERE id = 42
Что прислал атакующий: 42 OR 1=1
Что увидел интерпретатор: SELECT * FROM orders WHERE id = 42 OR 1=1
^^^^^^^^^^^^^ данные стали логикой запроса
Заметьте, насколько безобидно выглядит исходный ввод 42 OR 1=1: ни кавычек, ни экзотических символов, обычный текст. И тем не менее он меняет смысл запроса, потому что попадает в числовой контекст, где разделители не нужны вовсе. Это разрушает наивную интуицию «опасны только спецсимволы»: опасность определяется не набором символов, а тем, что недоверенные данные вообще оказались в позиции, которую интерпретатор разбирает как код. Поэтому защищаться перечислением «плохих символов» бессмысленно — надёжно только не пускать данные в эту позицию.
Универсальное лекарство: разделить каналы
Раз проблема в склейке кода и данных в один текст, решение — не склеивать. Передавайте команду и данные по разным каналам, чтобы интерпретатор заранее знал структуру и обращался с данными только как с данными:
- SQL → параметризованные запросы (плейсхолдеры), а не конкатенация;
- shell → массив аргументов без оболочки, а не строка для
sh -c; - LDAP → экранирование спецсимволов фильтра или безопасный билдер;
- шаблоны → не подставлять пользовательский ввод в исходник шаблона.
Везде идея одна: структуру задаёт разработчик, данные едут отдельным, «обезвреженным» каналом.
Обратите внимание, чего в этом списке нет: «отфильтровать опасные символы». Фильтрация — это попытка лечить следствие (конкретные метасимволы конкретного интерпретатора), тогда как разделение каналов лечит причину (само смешение кода и данных). Фильтр приходится переписывать под каждый интерпретатор и под каждую новую кодировку, и он всё равно остаётся denylist'ом со всеми его слабостями. Разделение каналов структурно: оно не зависит от того, какие именно символы пользователь прислал, потому что данные в принципе не попадают в позицию, где могли бы быть истолкованы как код.
Есть и приятный побочный эффект. Когда разделение каналов становится привычкой по умолчанию, безопасность перестаёт быть отдельной «фичей», о которой надо помнить, и встраивается в обычный способ писать код. Параметризованный запрос или вызов процесса массивом аргументов — это не «защитная мера», а просто корректный способ обратиться к интерпретатору. Самый надёжный код — тот, в котором небезопасный вариант даже не пишется, потому что безопасный удобнее.
Как работает под капотом: prepared statements
В параметризованном запросе драйвер сначала отправляет в СУБД шаблон с плейсхолдерами — СУБД разбирает его и фиксирует структуру (план). Затем отдельно приезжают значения, которые подставляются уже как данные, после разбора. На этом этапе никакая кавычка во вводе не может «дорисовать» новую команду — грамматика запроса уже зафиксирована.
Подчеркнём принципиальную разницу с экранированием. Экранирование пытается обезвредить опасные символы внутри единого текста запроса — то есть данные и код по-прежнему едут вместе, просто перед отправкой мы надеемся, что вычистили всё вредное. Prepared statement убирает само смешение: текст команды и значения идут разными сообщениями, и СУБД никогда не «склеивает» их обратно в строку для повторного разбора. Поэтому это не «более аккуратное экранирование», а другой по природе механизм — структурный, а не косметический. Именно структурность делает его устойчивым к трюкам с кодировками, юникодом и вложенными кавычками, на которых спотыкается ручная чистка.
// Концептуально
1. PREPARE: SELECT * FROM orders WHERE id = ? // структура фиксируется
2. BIND: ? = "42 OR 1=1" // приезжает как одно значение
3. EXECUTE: ищет заказ с id, равным строке "42 OR 1=1" -> ничего не находит
Частые ошибки
- Экранировать кавычки вручную. Легко ошибиться с кодировками и вложенностью; параметризация надёжнее.
- Параметризовать только значения, а имена таблиц/колонок клеить. Идентификаторы нельзя параметризовать — их проверяют allowlist'ом.
- Думать «инъекция — это только про SQL». Тот же механизм работает в shell, LDAP, NoSQL, шаблонах.
- Полагаться на «второй слой» вместо разделения каналов. WAF и фильтры символов полезны как дополнение, но это denylist; первичная защита — структурное разделение кода и данных.
- Подставлять пользовательский ввод в исходник шаблона. Если ввод попадает в текст шаблона (а не в его данные), движок шаблонов сам становится исполняющим инъекцию интерпретатором.
Итоги
- Все инъекции — следствие смешения данных и команд в одном тексте.
- Универсальная защита — разделить каналы: структуру задаёт код, данные едут отдельно.
- Prepared statements фиксируют грамматику запроса до подстановки значений.