2. Рождение уязвимости
Рождение уязвимости: Смерть через fmt.Sprintf
SQL-инъекция не появляется из ниоткуда. Она рождается в тот момент, когда разработчик решает «склеить» SQL-команду и данные от пользователя вручную. В мире Go это чаще всего происходит при использовании функций форматирования строк.
Почему именно Go-разработчики попадают в эту ловушку чаще, чем питонисты с Django ORM? Парадоксально, но причина — в дизайне database/sql. Стандартная библиотека намеренно низкоуровневая, без ORM, чтобы дать программисту контроль над производительностью и SQL-логикой. Она предоставляет два метода: db.Query(query) без параметров и db.Query(query, args...) с параметрами. Первый принимает любую строку — что соблазнительно для динамических запросов. Если в команде нет культуры «всегда параметризовать», новый член команды быстро находит «удобный» способ через fmt.Sprintf, и через несколько месяцев в кодовой базе появляются десятки уязвимых endpoints.
Аналогия: представь кухню с двумя ножами на столе — обычный кухонный для нарезки овощей и специальный для разделки мяса (с защитной гардой). Если хозяин кухни оставляет оба на видном месте и никому не объясняет разницу, любой повар возьмёт тот, что удобнее лежит. В случае Go — это «удобный» fmt.Sprintf, который позже окажется тем самым ножом без защиты. ORM в других языках принудительно «прячет» опасный нож и даёт только защищённый, поэтому SQLi там встречаются реже.
Давайте проследим путь данных от HTTP-запроса до выполнения команды в базе и найдем ту самую критическую точку отказа.
Конкатенация — это ловушка
В программировании на Go есть много способов «склеивать» строки:
+(плюс)fmt.Sprintfstrings.Join
Представьте, что вы хотите добавить пользователя в базу по его имени, которое пришло из формы. Разработчик пишет:
// ❌ СМЕРТЕЛЬНО ОПАСНО: конкатенация строк в SQL
name := r.FormValue("username")
id := r.FormValue("id")
// Хакер видит: "О, я могу поменять этот запрос!"
query := fmt.Sprintf("SELECT bio FROM users WHERE id = %s AND name = '%s'", id, name)
rows, err := db.Query(query)
В чём проблема?
SQL-парсер — это программа, которая выполняет ваши команды. Она «тупая» в том смысле, что она не различает, где в строке query ваши команды (SELECT, WHERE, AND), а где данные (имя или ID пользователя).
С точки зрения парсера, вы передали ему текст на языке SQL, и его задача — разобрать этот текст в синтаксическое дерево (AST), потом построить план выполнения. Если в тексте есть OR '1'='1', парсер видит это как валидное логическое выражение и компилирует его в проверку условия. Парсер физически не знает, что часть строки пришла от пользователя, а часть написана разработчиком — для него это монолитный SQL-текст. Это и есть фундаментальная проблема: разделение «трастового» и «недоверенного» происходит только в голове у программиста, но не в формате передачи данных к парсеру.
Почему это работает
Когда вы используете fmt.Sprintf, Go просто подставляет одну строку в другую. Для базы данных весь этот текст выглядит как цельная команда.
Вариант 1: Обычный пользователь (Безопасно)
- Ввод:
id = 1,name = 'vasya' - Результат Sprintf:
SELECT bio FROM users WHERE id = 1 AND name = 'vasya' - Итог: База вернула био Васи. Все довольны.
Вариант 2: Злоумышленник (Взлом)
- Ввод:
id = 1,name = 'admin' OR '1'='1' - Результат Sprintf:
SELECT bio FROM users WHERE id = 1 AND name = 'admin' OR '1'='1' - ОШИБКА: Выражение
'1'='1'всегда истинно. SQL-парсер выполнит это условие для каждой записи в таблице.
В результате база вернёт все записи. Go-приложение в типичной реализации сделает if rows.Next() { /* доверять первой записи */ } — и атакующий войдёт под учёткой пользователя с самым низким ID. По индексу B-tree это обычно admin (он был создан первым при инициализации БД). Полная компрометация системы через один POST-запрос на форму логина.
Таблица сравнения: Данные vs Команды
| Компонент | Пример (Ожидалось) | Пример (Атака) |
|---|---|---|
| Данные | vasya |
' OR '1'='1 |
| Код (SQL) | SELECT * FROM... |
(Код изменился!) |
| Результат | 1 запись | ВСЕ записи |
Смешивание кода и данных — это корень всех проблем с инъекциями. Пока база данных может исполнить ввод пользователя как команду, ваша система остается открытой.
Решение, которое мы будем разбирать на протяжении всего курса, формулируется одной строкой: вместо db.Query(fmt.Sprintf("... %s ...", input)) всегда писать db.Query("... ? ...", input). Знак вопроса (или $1 для PostgreSQL) — это plaintext-плейсхолдер, который драйвер базы данных правильно отделяет от SQL-кода через bind-параметры на уровне сетевого протокола. Атакующая нагрузка попадает в bind-данные, никогда не становясь частью SQL-парсинга. Это одна строка кода, которая закрывает класс уязвимостей, стоящих компаниям миллионы долларов в год.
Соответствие стандартам: CWE-89 SQL Injection (Top 25 Most Dangerous Software Weaknesses); OWASP Top 10 A03:2021 Injection (#3 в рейтинге); CWE-89 указан как primary classification для большинства SQLi CVE.
Теперь, когда мы увидели, как рождается уязвимость, пора изучить её анатомию и понять, на какие именно части запроса нацелены атаки.
Продолжить чтение
Что бы прочитать модуль полностью, зарегистрируйтесь/войдите на платформу
Когда закончишь — отметь раздел, чтобы продолжить.