Анатомия инъекции: данные против кода

Все инъекции — это когда интерпретатор принял ваши данные за свою команду.

Инъекция — уязвимость, при которой недоверенные данные попадают в интерпретатор (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 фиксируют грамматику запроса до подстановки значений.
Проверьте себя
1. В чём общий механизм всех инъекций?
AВ слабых паролях
BНедоверенные данные смешиваются с командой в одном тексте, и интерпретатор исполняет часть данных как код
CВ отсутствии шифрования
DВ переполнении буфера
2. Почему параметризованный запрос защищает от SQL-инъекции?
AОн шифрует данные
BСтруктура запроса фиксируется до подстановки, и значения приходят отдельным каналом только как данные
CОн запрещает кавычки в БД
DОн ускоряет выполнение
3. Можно ли параметризовать имя таблицы или колонки?
AДа, точно так же, как значения
BНет: идентификаторы нельзя передать плейсхолдером, их проверяют через allowlist допустимых имён
CДа, но только в NoSQL
DИмена не бывают пользовательским вводом