Перейти к содержимому
Назад к пути
Теория 3 мин чтения

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.Sprintf
  • strings.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.

Теперь, когда мы увидели, как рождается уязвимость, пора изучить её анатомию и понять, на какие именно части запроса нацелены атаки.

Продолжить чтение

Что бы прочитать модуль полностью, зарегистрируйтесь/войдите на платформу

Когда закончишь — отметь раздел, чтобы продолжить.

🚧 Сайт в разработке. Полный функционал пока недоступен. Все вопросы — support@hackandfix.ru