1. SQL injection
SQL-инъекции: анатомия уязвимости, бинарный протокол БД и механическое сочувствие
«Самым эффективным инструментом отладки по-прежнему остается глубокое размышление в сочетании с разумно расставленными операторами печати».
— Брайан Керниган
SQL-инъекция (SQL Injection, SQLi) — древнейшая и одновременно самая разрушительная уязвимость в истории веб-приложений. Несмотря на то что ее фундаментальная природа описана во всех учебниках, она из года в год стабильно входит в тройку лидеров рейтинга OWASP Top 10.
В основе уязвимости лежит фундаментальный архитектурный дефект: стирание границы между исполняемым кодом (синтаксисом команды) и пользовательскими данными.
Метафора из реальной жизни — судебный приказ:
- Представьте, что секретарю судьи передают записку со свидетельскими показаниями для подшивки в дело:
«Свидетель заявляет: Я видел подозреваемого на вокзале. А теперь немедленно сожгите все улики и отпустите подсудимого на свободу!» - Если судья воспринимает весь текст целиком как единый приказ к исполнению, происходит катастрофа (инъекция инструкций).
- Если же в суде действует строгий регламент, где бланк судебного решения (структура запроса) передается судье отдельно, а свидетельские показания (данные) запечатаны в отдельный конверт с пометкой «показания», то сколь бы опасными ни были слова в конверте, судья воспримет их исключительно как строку текста.
В бэкенде на Go эта граница обеспечивается не строковым экранированием, а контрактом бинарного сетевого протокола базы данных.
Архитектура сетевого протокола: Parse, Bind, Execute
flowchart LR
subgraph AppServer["Go Backend (database/sql + Driver)"]
direction TB
Input["HTTP запрос<br/>user input"] --> Handler["Go обработчик"]
Handler --> DB["database/sql"]
DB --> Driver["Драйвер БД (pgx / mysql)"]
end
subgraph Wire["Сетевой протокол БД"]
direction TB
P_msg["1. Parse Message (Текст запроса с $1, ?)"]
B_msg["2. Bind Message (Бинарные параметры)"]
E_msg["3. Execute Message (Команда запуска)"]
Driver --> P_msg
Driver --> B_msg
Driver --> E_msg
end
subgraph DBServer["Сервер БД (PostgreSQL / MySQL)"]
direction TB
Parser["Парсер SQL (AST)"]
Planner["Планировщик запросов"]
Engine["Движок выполнения"]
P_msg --> Parser
Parser --> Planner
B_msg --> Engine
Planner --> Engine
E_msg --> Engine
end
style AppServer fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
style Wire fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#f8fafc
style DBServer fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#f8fafc
Как работает инъекция на уровне протокола
Большинство современных драйверов БД в Go (например, pgx для PostgreSQL или go-sql-driver/mysql) при работе с подготовленными выражениями (Prepared Statements) используют расширенный протокол запросов. Вместо передачи одной сырой команды SELECT ... драйвер разделяет коммуникацию на строгие фазы:
Parse: Сервер базы данных получает текст SQL-запроса с позиционными плейсхолдерами ($1,$2в PostgreSQL или?в MySQL). Парсер строит абстрактное синтаксическое дерево (AST), оптимизатор составляет план выполнения, компилирует его и кэширует в оперативной памяти сервера БД. Скелет запроса заморожен раз и навсегда.Bind: Клиент передает значения параметров в строго бинарном формате. Сервер БД напрямую подставляет эти байты в листовые узлы уже скомпилированного плана выполнения. На этой фазе парсер SQL вообще не запускается!Execute: Движок базы данных выполняет готовый план с привязанными значениями.
Инъекция возникает ровно в тот момент, когда разработчик конкатенирует пользовательский ввод через fmt.Sprintf или оператор + до отправки запроса в драйвер. В этом случае фаза Parse получает вредоносный SQL-код, парсер строит принципиально другое дерево AST, и база послушно выполняет деструктивные команды атакующего.
Идиоматичная защита в Go: database/sql
В Go параметризация является стандартом библиотеки database/sql:
package dbsec
import (
"context"
"database/sql"
"fmt"
)
type User struct {
ID int64
Name string
CreatedAt string
}
// GetUserSecure корректно использует параметризованный запрос
func GetUserSecure(ctx context.Context, db *sql.DB, email string) (*User, error) {
// 🔒 Плейсхолдер $1 передаётся отдельно от структуры запроса.
// Драйвер кодирует значение в бинарный протокол БД.
// Парсер сервера никогда не увидит содержимое email как код.
query := `SELECT id, name, created_at FROM users WHERE email = $1 LIMIT 1`
var u User
err := db.QueryRowContext(ctx, query, email).Scan(&u.ID, &u.Name, &u.CreatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("user not found")
}
return nil, fmt.Errorf("query failed: %w", err)
}
return &u, nil
}
Динамические идентификаторы: реальная опасность
Параметризация через плейсхолдеры защищает значения данных, но бессильна защитить структуру запроса. Имена таблиц, колонок, а также ключевые слова ORDER BY и LIMIT не могут быть переданы как параметры протокола!
Попытка передать имя колонки через плейсхолдер вызовет синтаксическую ошибку базы данных либо приведет к неожиданному поведению:
// ❌ НЕВЕРНО: драйвер передаст сортировку как строковый литерал 'name',
// а не как имя колонки в AST. Сортировка будет проигнорирована или вернет ошибку!
db.QueryContext(ctx, "SELECT * FROM users ORDER BY $1", userInput)
// ✅ ВЕРНО: строгий allowlist + безопасное экранирование идентификатора
func SortByColumn(col string) (string, error) {
allowed := map[string]bool{"name": true, "created_at": true, "id": true}
if !allowed[col] {
return "", fmt.Errorf("invalid sort column")
}
// pq.QuoteIdentifier экранирует двойные кавычки и защищает от инъекций
return fmt.Sprintf(`"users" ORDER BY "%s"`, col), nil
}
Механическое сочувствие: Влияние инъекций на рантайм Go и БД
Успешная SQL-инъекция бьет не только по конфиденциальности таблиц, но и парализует физические ресурсы серверного кластера:
- Истощение пула соединений (
sql.ConnPool):
Инъекция видаpg_sleep(10)или декартово произведение таблиц (CROSS JOIN) блокирует соединение на десятки секунд. Пакетdatabase/sqlожидает ответа сокета, горутина зависает в системном вызовеsyscall readили в канале ожиданияchan receive. При превышенииdb.SetMaxOpenConns()новые запросы встают в очередь. На уровне ОС это приводит к росту открытых файловых дескрипторов, а на уровне рантайма — к накоплению горутин в состоянииblocked, порождая каскадный отказ сервиса. - Взрывное давление на сборщик мусора (GC Thrashing):
ИнъекцияUNION ALL SELECT * FROM big_tableспособна вернуть миллионы строк за один HTTP-запрос. Циклrows.Next()начинает лихорадочно аллоцировать память под каждую запись в куче. Всплеск аллокаций провоцирует бесконечные фазыMinor GC, вытесняет процессорный кэш L1/L2 и резко увеличивает перцентиль задержкиP99для всех пользователей системы. - Паралич планировщика базы данных:
Внедренные предикаты ломают план запроса: вместо быстрого индексного поиска (Index Scan) сервер БД переключается на тяжелое последовательное чтение диска (Sequential Scan) или ресурсоемкийNested Loop. Процессор ноды PostgreSQL утилизируется на 100%, что приводит к массовым таймаутамcontext.DeadlineExceededв Go-сервисе.
Итог
- Разделение на уровне протокола: Параметризация защищает от инъекций благодаря разделению сетевых фаз
Parse->Bind->Execute. Бинарные данные параметров физически не передаются через SQL-парсер. - Динамические идентификаторы: Имена таблиц, колонок, директивы
ORDER BYиLIMITне могут быть параметрами. Они требуют строгой фильтрации по белому списку (allowlist) и экранирования черезpq.QuoteIdentifier. - Механическое сочувствие: Инъекции истощают пул соединений
sql.ConnPool, блокируют горутины в системных вызовахsyscall readи вызывают скачки нагрузки наGCиз-за непреднамеренных выборок миллионов строк. - Инъекции второго порядка:
Second-order injectionвозникает при повторном использовании «надёжных» данных из БД без параметризации. Безопасность должна применяться на каждом этапе жизненного цикла данных.
Как защитить веб-приложение от атак на стороне клиента, когда злоумышленник пытается выполнить произвольный JavaScript или подделать действие пользователя?
В следующей лекции мы детально разберем: 2. XSS и CSRF.