Покупатель маркетплейса открывает «Мои заказы» и фильтрует их по статусу. Бэкенд склеивает SQL из строки запроса, и вместо PAID в фильтр приходит ' OR 1=1 --. Ответ приходит длинный: в нём заказы всех покупателей с адресами доставки. Ещё одна строка, и в том же ответе маски карт из таблицы платежей. Это инъекция: данные пользователя попали в место, где интерпретатор ждёт код, и стали кодом.
Как устроена атака и чем грозитспросят на собеседовании
В OWASP Top 10:2025 это категория A05:2025 Injection: уязвимость «allows untrusted user input to be sent to an interpreter (e.g. a browser, database, the command line) and causes the interpreter to execute parts of that input as commands». Интерпретаторов в бэкенде много, и у каждого свой CWE:
| Интерпретатор | Что приходит от атакующего | CWE |
|---|---|---|
| SQL-база | ' OR 1=1 --, ' UNION SELECT ... | CWE-89 SQL Injection |
| MongoDB и другие NoSQL | {"$ne": ""} вместо строки | CWE-943 Improper Neutralization of Special Elements in Data Query Logic |
| shell | 1; rm -rf /tmp/labels | CWE-78 OS Command Injection |
| аргументы команды | --output=/etc/passwd | CWE-88 Argument Injection |
| движок шаблонов | {{.Customer.Phone}}, {order.__init__.__globals__} | CWE-1336 Improper Neutralization of Special Elements Used in a Template Engine |
Механика одна. Код собирает текст команды склейкой строк, а интерпретатор разбирает этот текст целиком и не знает, какая часть пришла от программиста, а какая от покупателя. Кавычка закрывает строковый литерал, OR 1=1 делает условие истинным для всех строк, -- превращает остаток запроса, включая AND customer_id = 42, в комментарий. UNION SELECT дописывает к ответу строки из любой таблицы, которую видит пользователь базы. Если у этого пользователя есть права на запись, в ход идут UPDATE и DELETE; если база умеет вызывать команды ОС, атакующий выходит за пределы базы.
Это не теоретическая угроза. С 27 мая 2023 года группировка CL0P эксплуатировала неизвестную до того SQL-инъекцию в MOVEit Transfer (CVE-2023-34362), ставила веб-шелл LEMURLOOT и выкачивала данные из баз, по совместному бюллетеню CISA и FBI AA23-158A от 7 июня 2023 года.
Как выглядит в коде
Обработчик «Мои заказы» берёт status из строки запроса и подставляет его в SQL. Программа на Python выполняет запрос на настоящей SQLite в памяти; в остальных языках запрос печатается вместе с тем, что база выполнит после отсечения комментария.
live example
public class OrderQueryVulnerable {
static String myOrders(long customerId, String status) {
return "SELECT id, address FROM orders WHERE status = '" + status
+ "' AND customer_id = " + customerId;
}
public static void main(String[] args) {
System.out.println(myOrders(42, "PAID"));
String sql = myOrders(42, "' OR 1=1 --");
System.out.println(sql);
String executed = sql.substring(0, sql.indexOf("--"));
System.out.println("база выполнит: " + executed);
System.out.println("фильтр по покупателю остался: " + executed.contains("customer_id"));
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
package main
import (
"fmt"
"strings"
)
func myOrders(customerID int64, status string) string {
return fmt.Sprintf("SELECT id, address FROM orders WHERE status = '%s' AND customer_id = %d", status, customerID)
}
func main() {
fmt.Println(myOrders(42, "PAID"))
sql := myOrders(42, "' OR 1=1 --")
fmt.Println(sql)
executed, _, _ := strings.Cut(sql, "--")
fmt.Println("база выполнит:", executed)
fmt.Println("фильтр по покупателю остался:", strings.Contains(executed, "customer_id"))
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
function myOrders(customerId, status) {
return `SELECT id, address FROM orders WHERE status = '${status}' AND customer_id = ${customerId}`;
}
console.log(myOrders(42, "PAID"));
const sql = myOrders(42, "' OR 1=1 --");
console.log(sql);
const executed = sql.split("--")[0];
console.log("база выполнит:", executed);
console.log("фильтр по покупателю остался:", executed.includes("customer_id"));
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders (id INTEGER, customer_id INTEGER, status TEXT, address TEXT)")
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", [
(1, 42, "PAID", "Москва, Тверская 1"),
(2, 42, "NEW", "Москва, Тверская 1"),
(3, 77, "PAID", "Казань, Баумана 5"),
(4, 91, "SHIPPED", "Пермь, Ленина 10"),
])
db.execute("CREATE TABLE payments (order_id INTEGER, card_mask TEXT)")
db.execute("INSERT INTO payments VALUES (3, '2200 **** **** 7351')")
def my_orders(customer_id: int, status: str):
sql = f"SELECT id, customer_id, address FROM orders WHERE status = '{status}' AND customer_id = {customer_id}"
print(sql)
return db.execute(sql).fetchall()
print(my_orders(42, "PAID"))
for row in my_orders(42, "' OR 1=1 --"):
print("чужой заказ" if row[1] != 42 else "свой заказ", row)
print(my_orders(42, "' UNION SELECT order_id, 0, card_mask FROM payments --"))
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Первый вызов возвращает один свой заказ. Второй возвращает все четыре, два из них чужие. Третий с UNION достаёт маску карты из таблицы, к которой эндпоинт вообще не обращается. Проверка доступа в контроллере не помогает: покупатель аутентифицирован, customer_id взят из токена честно, его просто вырезали из запроса.
Параметризованный запросспросят на собеседовании
Главная мера по OWASP SQL Injection Prevention Cheat Sheet называется «Use of Prepared Statements (with Parameterized Queries)». Текст запроса фиксирован и содержит только места для значений (?, $1, %s, :name), а значения передаются драйверу отдельным списком. Значение никогда не разбирается как SQL: ' OR 1=1 -- остаётся строкой, с которой сравнивают status, и такого статуса нет.
То, что нельзя передать параметром (имя колонки в ORDER BY, направление сортировки), берут только из фиксированного списка на сервере. Список значений для IN разворачивают в нужное число мест под параметры или передают одним массивом через = ANY($1).
live example
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
public class OrderQueryFixed {
record Query(String sql, List<Object> params) {}
static final Map<String, String> SORT = Map.of("newest", "id DESC", "oldest", "id ASC");
static Query myOrders(long customerId, List<String> statuses, String sort) {
String orderBy = SORT.get(sort);
if (orderBy == null) {
throw new IllegalArgumentException("недопустимая сортировка: " + sort);
}
String marks = String.join(", ", Collections.nCopies(statuses.size(), "?"));
List<Object> params = new ArrayList<>(statuses);
params.add(customerId);
return new Query(
"SELECT id, address FROM orders WHERE status IN (" + marks + ") AND customer_id = ? ORDER BY " + orderBy,
params);
}
public static void main(String[] args) {
Query q = myOrders(42, List.of("PAID", "' OR 1=1 --"), "newest");
System.out.println(q.sql());
System.out.println("параметры: " + q.params());
try {
myOrders(42, List.of("PAID"), "(SELECT card_mask FROM payments LIMIT 1)");
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
package main
import (
"errors"
"fmt"
)
var sortColumns = map[string]string{"newest": "id DESC", "oldest": "id ASC"}
func myOrders(customerID int64, statuses []string, sort string) (string, []any, error) {
orderBy, ok := sortColumns[sort]
if !ok {
return "", nil, errors.New("недопустимая сортировка: " + sort)
}
sql := "SELECT id, address FROM orders WHERE customer_id = $1 AND status = ANY($2) ORDER BY " + orderBy
return sql, []any{customerID, statuses}, nil
}
func main() {
sql, args, _ := myOrders(42, []string{"PAID", "' OR 1=1 --"}, "newest")
fmt.Println(sql)
fmt.Printf("$1 = %v, $2 = %q\n", args[0], args[1])
_, _, err := myOrders(42, []string{"PAID"}, "(SELECT card_mask FROM payments LIMIT 1)")
fmt.Println(err)
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
const SORT = { newest: "id DESC", oldest: "id ASC" };
function myOrders(customerId, statuses, sort) {
const orderBy = Object.hasOwn(SORT, sort) ? SORT[sort] : null;
if (!orderBy) throw new Error(`недопустимая сортировка: ${sort}`);
return {
text: `SELECT id, address FROM orders WHERE customer_id = $1 AND status = ANY($2) ORDER BY ${orderBy}`,
values: [customerId, statuses],
};
}
const q = myOrders(42, ["PAID", "' OR 1=1 --"], "newest");
console.log(q.text);
console.log("values:", JSON.stringify(q.values));
try {
myOrders(42, ["PAID"], "(SELECT card_mask FROM payments LIMIT 1)");
} catch (e) {
console.log(e.message);
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders (id INTEGER, customer_id INTEGER, status TEXT, address TEXT)")
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", [
(1, 42, "PAID", "Москва, Тверская 1"),
(2, 42, "NEW", "Москва, Тверская 1"),
(3, 77, "PAID", "Казань, Баумана 5"),
(4, 91, "SHIPPED", "Пермь, Ленина 10"),
])
SORT = {"newest": "id DESC", "oldest": "id ASC"}
def my_orders(customer_id: int, statuses: list[str], sort: str = "newest"):
order_by = SORT.get(sort)
if order_by is None:
raise ValueError(f"недопустимая сортировка: {sort!r}")
marks = ", ".join("?" * len(statuses))
sql = f"SELECT id, customer_id, address FROM orders WHERE status IN ({marks}) AND customer_id = ? ORDER BY {order_by}"
return db.execute(sql, (*statuses, customer_id)).fetchall()
print(my_orders(42, ["PAID", "NEW"]))
print("атака:", my_orders(42, ["' OR 1=1 --"]))
try:
my_orders(42, ["PAID"], sort="(SELECT address FROM orders WHERE customer_id = 77)")
except ValueError as e:
print(e)
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
В Python атака возвращает пустой список: SQLite честно ищет заказ со статусом ' OR 1=1 -- и не находит. Подстановка ORDER BY {order_by} здесь безопасна, потому что в строку попадает значение из словаря SORT, а не ввод пользователя.
В рабочем сервисе то же самое пишут средствами драйвера или ORM. Этот код без запуска: ему нужна база.
JDBC: PreparedStatement с ? и setString/setLong (Oracle JDBC Tutorial). В Spring тот же принцип дают JdbcTemplate и NamedParameterJdbcTemplate, который сам разворачивает коллекцию в IN (:statuses); в JPQL параметр :name задают через setParameter.
List<OrderView> myOrders(long customerId, List<String> statuses) {
return namedJdbc.query(
"SELECT id, address FROM orders WHERE customer_id = :customerId AND status IN (:statuses)",
Map.of("customerId", customerId, "statuses", statuses),
(rs, rowNum) -> new OrderView(rs.getLong("id"), rs.getString("address")));
}
List<Order> myOrdersJpql(long customerId, List<String> statuses) {
return em.createQuery(
"select o from Order o where o.customerId = :customerId and o.status in :statuses", Order.class)
.setParameter("customerId", customerId)
.setParameter("statuses", statuses)
.getResultList();
}
database/sql и pgx принимают значения аргументами после текста запроса: «You can avoid an SQL injection risk by providing SQL parameter values as sql package function arguments» (go.dev). pgx передаёт срез []string как массив PostgreSQL.
func (h *Handler) MyOrders(w http.ResponseWriter, r *http.Request) {
customerID := auth.CustomerID(r.Context())
statuses := r.URL.Query()["status"]
rows, err := h.pool.Query(r.Context(),
"SELECT id, address FROM orders WHERE customer_id = $1 AND status = ANY($2)",
customerID, statuses)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
orders, err := pgx.CollectRows(rows, pgx.RowToStructByName[OrderView])
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(orders)
}
node-postgres прямо пишет: «avoid string concatenating parameters into the query text directly», значения идут вторым аргументом и подставляются на стороне PostgreSQL (документация pg). Массив JavaScript превращается в массив PostgreSQL.
app.get("/me/orders", async (req, res) => {
const statuses = [].concat(req.query.status ?? []).map(String);
const { rows } = await pool.query(
"SELECT id, address FROM orders WHERE customer_id = $1 AND status = ANY($2)",
[req.user.id, statuses],
);
res.json(rows);
});
psycopg 3 принимает значения вторым аргументом execute и предупреждает «NEVER DO THIS» про cur.execute(SQL % data) (документация). Список для IN там же советуют передавать через = ANY(%s) (адаптация списков). В SQLAlchemy то же даёт select(Order).where(Order.customer_id == customer.id, Order.status.in_(status)).
@app.get("/me/orders")
def my_orders(status: list[str] = Query(default=[]), customer: Customer = Depends(current_customer)):
with pool.connection() as conn:
rows = conn.execute(
"SELECT id, address FROM orders WHERE customer_id = %s AND status = ANY(%s)",
(customer.id, status),
).fetchall()
return [{"id": row[0], "address": row[1]} for row in rows]
ORM не гарантия: сортировка, LIKE, списки IN, сырые запросыспросят на собеседовании
ORM параметризует то, что строит сам. Дыры появляются там, где разработчик выходит за его пределы.
Сырые запросы. У каждого ORM есть выход в чистый SQL: createNativeQuery, text() в SQLAlchemy, pool.query рядом с query builder. Если текст для него собран через +, fmt.Sprintf, шаблонную строку или f-строку, ORM вокруг ничего не меняет. Правило то же: в тексте только места под параметры.
Имена колонок и ORDER BY. Параметром передают значение, но не идентификатор: «PostgreSQL does not support parameters for identifiers» (pg). Поэтому ?sort=created_at соблазняет склеить строку. Квотирование идентификаторов (sql.Identifier в psycopg, pgx.Identifier{...}.Sanitize() в pgx, pg-format для Node) закрывает синтаксис, но оставляет выбор колонки за пользователем: сортировка по password_hash или по скрытому полю тоже утечка, по одному биту за запрос. Надёжнее словарь «имя из API → выражение SQL», как SORT выше.
LIKE. Значение передали параметром, и инъекции нет, но % и _ внутри него остаются шаблоном: «An underscore (_) in pattern stands for (matches) any single character; a percent sign (%) matches any sequence of zero or more characters» (PostgreSQL). Поиск обращений в поддержку по email с вводом % вернёт все обращения в области видимости пользователя и заставит базу просмотреть таблицу целиком. Перед подстановкой экранируют \, % и _ обратным слешем (он escape-символ по умолчанию) или задают свой через ESCAPE.
Списки IN. IN (?) с одним параметром не принимает список, и рука тянется к String.join. Варианты без склейки: развернуть столько мест под параметры, сколько элементов (как в примерах с ? выше), передать массив через = ANY($1) (PostgreSQL, pgx, pg, psycopg) или отдать коллекцию ORM, который умеет in. Пустой список проверяют до запроса: IN () в SQL не бывает, а = ANY('{}') просто ничего не находит.
NoSQL: инъекция операторовспросят на собеседовании
В MongoDB фильтр это объект, а не строка, поэтому кавычки там не помогают атакующему. Помогает тип. Обработчик «найти заказ по коду отслеживания» берёт code из JSON-тела и кладёт в фильтр. Если вместо строки прислать объект {"$ne": ""}, фильтр становится «код не пустой», и под него подходит первый попавшийся чужой заказ. OWASP NoSQL Security Cheat Sheet прямо говорит: «Building a query object does not prevent operator injection when an untrusted value is itself an object». Мера: проверить, что значение скалярного типа, до того как оно попало в фильтр.
Типизированный DTO закрывает дыру сам: в поле String Jackson объект не положит, запрос упадёт на разборе тела.
record TrackingRequest(@NotBlank String code) {}
@PostMapping("/orders/track")
OrderView track(@Valid @RequestBody TrackingRequest body) {
Document order = orders.find(Filters.eq("trackingCode", body.code())).first();
if (order == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
return OrderView.from(order);
}
encoding/json не положит объект в поле string: Decode вернёт ошибку, и до фильтра дело не дойдёт.
func (h *Handler) Track(w http.ResponseWriter, r *http.Request) {
var body struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Code == "" {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var order Order
err := h.orders.FindOne(r.Context(), bson.D{{Key: "trackingCode", Value: body.Code}}).Decode(&order)
if errors.Is(err, mongo.ErrNoDocuments) {
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(order)
}
В Node опаснее всего: express.json() отдаёт в req.body ровно то, что прислали, вместе с вложенными объектами. Программа ниже воспроизводит, как драйвер понимает оператор $ne, на массиве в памяти.
live example
const orders = [
{ id: 3, customerId: 77, trackingCode: "TRK-77-A1", address: "Казань, Баумана 5" },
{ id: 4, customerId: 91, trackingCode: "TRK-91-B2", address: "Пермь, Ленина 10" },
];
function matches(doc, filter) {
return Object.entries(filter).every(([field, cond]) => {
if (cond !== null && typeof cond === "object" && "$ne" in cond) return doc[field] !== cond.$ne;
return doc[field] === cond;
});
}
function findByTracking(body) {
return orders.find((o) => matches(o, { trackingCode: body.code })) ?? null;
}
function findByTrackingSafe(body) {
if (typeof body.code !== "string") return null;
return findByTracking(body);
}
const attack = JSON.parse('{"code": {"$ne": ""}}');
console.log("без проверки:", findByTracking(attack));
console.log("с проверкой типа:", findByTrackingSafe(attack));
console.log("честный код:", findByTrackingSafe({ code: "TRK-91-B2" }).id);
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Модель Pydantic с полем code: str отклонит объект на входе, FastAPI ответит 422 раньше, чем вызовется обработчик.
class TrackingRequest(BaseModel):
code: str = Field(min_length=1)
@app.post("/orders/track")
def track(body: TrackingRequest):
order = orders.find_one({"trackingCode": body.code})
if order is None:
raise HTTPException(status_code=404)
return OrderView.from_document(order)
Команды ОС: список аргументов без shellспросят на собеседовании
Склад печатает этикетку внешней утилитой print-label, номер заказа приходит в запросе. Уязвимая версия запускает shell одной строкой: sh -c "print-label " + number, exec() в Node, subprocess.run(f"print-label {number}", shell=True). Номер 1; rm -rf /tmp/labels shell разберёт как две команды.
OWASP OS Command Injection Defense Cheat Sheet ставит на первое место «avoid calling OS commands directly»: если то же умеет библиотека языка (создать каталог, сжать файл, сконвертировать картинку), команда не нужна вовсе. Если нужна, то её запускают списком аргументов без shell, и тогда ;, |, $() и пробелы ничего не значат: программа получает строку целиком одним аргументом. Значение дополнительно проверяют по allow-list, а перед ним ставят --, чтобы ввод вида --output=/etc/passwd не стал опцией: по POSIX Utility Syntax Guidelines первый -- означает конец опций.
ProcessBuilder со списком запускает программу напрямую, shell не участвует. echo получает строку атаки одним аргументом и печатает её как есть.
live example
import java.util.List;
import java.util.regex.Pattern;
public class LabelPrinter {
static final Pattern ORDER_NUMBER = Pattern.compile("[0-9]{1,12}");
static String runEcho(String orderNumber) throws Exception {
Process p = new ProcessBuilder(List.of("echo", "label", orderNumber)).start();
String out = new String(p.getInputStream().readAllBytes()).trim();
p.waitFor();
return out;
}
public static void main(String[] args) throws Exception {
String attack = "1; rm -rf /tmp/labels";
System.out.println("echo получил одну строку: " + runEcho(attack));
System.out.println(attack + " проходит проверку: " + ORDER_NUMBER.matcher(attack).matches());
System.out.println("100500 проходит проверку: " + ORDER_NUMBER.matcher("100500").matches());
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Документация os/exec: пакет «intentionally does not invoke the system shell and does not expand any glob patterns or handle other expansions, pipelines, or redirections typically done by shells».
live example
package main
import (
"fmt"
"os/exec"
"regexp"
)
var orderNumber = regexp.MustCompile(`^[0-9]{1,12}$`)
func main() {
attack := "1; rm -rf /tmp/labels"
out, err := exec.Command("echo", "label", attack).Output()
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("echo получил одну строку: %s", out)
fmt.Println(attack, "проходит проверку:", orderNumber.MatchString(attack))
fmt.Println("100500 проходит проверку:", orderNumber.MatchString("100500"))
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
child_process в песочнице нет, поэтому живой пример показывает проверку и список аргументов. Регулярное выражение отсекает и ;, и опцию, и массив, который Express соберёт из ?n=1&n=2.
live example
const ORDER_NUMBER = /^[0-9]{1,12}$/;
function labelArgs(orderNumber) {
if (typeof orderNumber !== "string" || !ORDER_NUMBER.test(orderNumber)) {
throw new Error(`недопустимый номер заказа: ${JSON.stringify(orderNumber)}`);
}
return ["--", orderNumber];
}
for (const input of ["100500", "1; rm -rf /tmp/labels", "--output=/etc/passwd", ["1"]]) {
try {
console.log("spawn('print-label',", JSON.stringify(labelArgs(input)) + ")");
} catch (e) {
console.log(e.message);
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Запуск в обработчике. У spawn опция shell по умолчанию false; про exec() и shell: true документация Node пишет: «Never pass unsanitized user input to this function».
const { spawn } = require("node:child_process");
app.post("/labels/:orderNumber", (req, res) => {
let args;
try {
args = labelArgs(req.params.orderNumber);
} catch {
return res.status(400).end();
}
const child = spawn("print-label", args);
child.on("close", (code) => res.status(code === 0 ? 204 : 500).end());
});
Проверка и список аргументов. re.fullmatch требует совпадения со всей строкой, поэтому номер с переводом строки на конце тоже не проходит.
live example
import re
ORDER_NUMBER = re.compile(r"[0-9]{1,12}")
def label_args(order_number: str) -> list[str]:
if not ORDER_NUMBER.fullmatch(order_number):
raise ValueError(f"недопустимый номер заказа: {order_number!r}")
return ["print-label", "--", order_number]
for value in ["100500", "1; rm -rf /tmp/labels", "--output=/etc/passwd", "100500\n"]:
try:
print("subprocess.run(", label_args(value), ")")
except ValueError as e:
print(e)
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Запуск в обработчике. subprocess по умолчанию shell не вызывает: «all characters, including shell metacharacters, can safely be passed to child processes»; с shell=True экранирование становится вашей обязанностью.
@app.post("/labels/{order_number}", status_code=204)
def print_label(order_number: str):
try:
args = label_args(order_number)
except ValueError:
raise HTTPException(status_code=400)
subprocess.run(args, check=True, timeout=10)
Шаблоны: SSTI и автоэкранированиеспросят на собеседовании
Продавец настраивает текст письма «заказ передан в доставку». Если его текст становится исходником шаблона, а не данными, это server-side template injection: движок исполняет выражения продавца в контексте, где лежат заказ, покупатель и всё, что разработчик туда положил. OWASP SSTI Prevention Cheat Sheet: «Treat templates as code», ввод передают «only as data», контекст рендера держат минимальным, и отдельно: экранирование вывода от SSTI само по себе не защищает. Опасные вызовы там названы поимённо: Jinja2 from_string(), Flask render_template_string(), конструктор Template и ?interpret во FreeMarker.
FreeMarker: шаблоны только из своего каталога, расширение .ftlh включает HTML-экранирование, ?new и ?api выключены, как советует OWASP. Конструктор Template с текстом продавца и есть SSTI.
Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setClassForTemplateLoading(MailRenderer.class, "/templates");
cfg.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER);
cfg.setAPIBuiltinEnabled(false);
Template unsafe = new Template("seller", new StringReader(sellerText), cfg);
Template shipped = cfg.getTemplate("shipped.ftlh");
shipped.process(Map.of("orderId", order.id(), "note", sellerText), out);
html/template исходит из того, что «template authors are trusted, that Execute's data parameter is not». Первый шаблон собран из текста продавца и выдаёт телефон покупателя, хотя автоэкранирование включено. Во втором тот же текст пришёл как данные: выражения остались текстом, <script> экранирован.
live example
package main
import (
"html/template"
"os"
)
type Customer struct {
Name string
Phone string
}
type Order struct {
ID int
Address string
Customer Customer
Note string
}
func main() {
order := Order{ID: 3, Address: "Казань, Баумана 5", Customer: Customer{Name: "Анна", Phone: "8 900 123-45-67"}}
sellerText := "Заказ {{.ID}} передан в доставку, телефон покупателя: {{.Customer.Phone}}"
leak := template.Must(template.New("seller").Parse(sellerText))
leak.Execute(os.Stdout, order)
os.Stdout.WriteString("\n")
page := template.Must(template.New("page").Parse("<p>Заказ {{.ID}}: {{.Note}}</p>\n"))
order.Note = sellerText + "<script>alert(1)</script>"
page.Execute(os.Stdout, order)
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Handlebars: «the values returned by the {{expression}} are HTML-escaped», а {{{ }}} выводит сырой HTML (документация). Компилировать можно только свои файлы шаблонов, текст продавца идёт в контекст.
const Handlebars = require("handlebars");
const unsafe = Handlebars.compile(req.body.template);
const shipped = Handlebars.compile(fs.readFileSync("templates/shipped.hbs", "utf8"));
res.send(shipped({ orderId: order.id, note: req.body.note }));
Без сторонних библиотек тот же класс дыры даёт str.format: строка формата умеет обходить атрибуты и индексы, и через них добраться до глобальных переменных модуля. string.Template подставляет только плоские имена. В Jinja2 шаблоны грузят из FileSystemLoader с autoescape=select_autoescape() (у Template по умолчанию autoescape=False), а from_string с чужим текстом не вызывают.
live example
from string import Template
SIGNING_KEY = "prod-signing-key-7f3a"
class Order:
def __init__(self, order_id: int, address: str):
self.id = order_id
self.address = address
order = Order(3, "Казань, Баумана 5")
seller_text = "Заказ {order.id} передан в доставку: {order.address}"
print(seller_text.format(order=order))
attack = "Заказ {order.__init__.__globals__[SIGNING_KEY]}"
print(attack.format(order=order))
safe = Template("Заказ $order_id передан в доставку: $address")
print(safe.safe_substitute(order_id=order.id, address=order.address))
print(Template("Заказ $order.__init__.__globals__").safe_substitute(order="3"))
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Если продавцам нужен настраиваемый текст, лучше дать им несколько плоских подстановок ($order_id, $address) или логически пустой движок вроде Mustache. Когда без пользовательских шаблонов никак, OWASP советует песочницу движка (SandboxedEnvironment в Jinja2), только безопасные функции и рендер в отдельном процессе с лимитами времени и памяти и без секретов.
Как проверить
Тест на сам эндпоинт. Для каждого параметра, который попадает в запрос, тест с ' OR 1=1 -- и ' UNION SELECT ... в значении: ответ содержит только данные текущего покупателя, а сортировка с неизвестным именем возвращает 400, а не 500 с текстом ошибки базы. Для NoSQL тот же тест с объектом вместо строки, для команд с ; и значением, начинающимся с -.
Статический анализ. Он хорошо ловит склейку, потому что она видна в коде. В Go правила gosec G201 «SQL query construction using format string», G202 «... using string concatenation» и G204 «Audit use of command execution» (список правил). В Python Bandit B608 hardcoded_sql_expressions, B602 subprocess_popen_with_shell_equals_true и B701 jinja2_autoescape_false (плагины Bandit). Для Java есть SpotBugs с FindSecBugs, для Node и остальных языков правила Semgrep; как их встроить в сборку, разобрано в гайде по SAST.
Динамическая проверка. sqlmap на тестовом стенде перебирает техники инъекции по каждому параметру; запускать его только против своих стендов.
Ревью. Ищите в диффе: + или форматирование рядом с SELECT, WHERE, ORDER BY; f-строки и шаблонные строки, переданные в execute, query, text(), нативный запрос; sh -c, shell=True, exec( с вводом; компиляцию шаблона из строки, пришедшей извне; req.body.x в фильтре MongoDB без проверки типа.
Типичные ошибки
- Экранирование вручную.
replace("'", "''")вместо параметра. OWASP помечает этот путь «STRONGLY DISCOURAGED»; подробнее в разделе ниже. - Параметризовали значение, склеили остальное.
WHERE status = ?и рядомORDER BY " + sort,LIMIT " + size. Числа приводят к числу, имена берут из allow-list. - Чёрный список вместо белого. Вырезать
--,;,UNIONили{{из ввода бесполезно: обходов больше, чем строк в списке. OWASP про шаблоны пишет отдельно: «Do not try to filter template syntax out of input». - «Это же внутренний параметр». Значение пришло из другого сервиса, из Kafka, из колонки в своей же базе, и его склеили, потому что оно «наше». Инъекции второго порядка живут именно здесь.
- Ошибка базы в ответе. Текст
SQLExceptionилиpq: syntax error at or nearв теле 500 подсказывает атакующему структуру запроса. Как отдавать ошибку без подробностей, в статье про PII и секреты. - Сервис ходит в базу владельцем. Учётка с правами на DDL превращает чтение чужих заказов в
DROP TABLE. Отдельный пользователь с минимальными правами не заменяет параметры, но ограничивает ущерб.
Глубже: инъекция второго порядкарасширенное
Покупатель сохраняет в профиле имя Анна' --. Запись идёт через параметр, и в базе лежит ровно эта строка. Через неделю ночная выгрузка для службы поддержки собирает "... WHERE name = '" + customer.name + "'", потому что значение «из нашей таблицы». Атака срабатывает в другом сервисе и в другое время, и по логам её трудно связать с исходным запросом.
Вывод: доверие к значению не зависит от того, откуда его прочитали. Параметр ставят в каждый запрос, включая отчёты, миграции данных, скрипты поддержки и обработчики событий. Тест на это простой: в фикстурах держать покупателя с кавычкой, % и {{ в имени и прогонять через все выгрузки.
Глубже: почему экранирование хуже параметроврасширенное
Экранирование пытается сделать так, чтобы интерпретатор прочитал ввод как литерал, и для этого надо знать его грамматику целиком: кодировку соединения, режимы кавычек конкретной СУБД, обратные слеши, юникодные варианты символов. Ошибка в любом пункте снова открывает дыру, поэтому OWASP ставит «Escaping All User Supplied Input» последним вариантом с пометкой «STRONGLY DISCOURAGED». Параметр устроен иначе: текст запроса и значения передаются драйверу раздельно, и угадывать грамматику не нужно. То же верно для команд (список аргументов вместо shlex.quote) и шаблонов (данные в контекст вместо очистки текста).
Экранирование остаётся там, где параметра не бывает по природе: % и _ внутри значения для LIKE, HTML-вывод в шаблоне (это автоэкранирование движка) и идентификаторы, если allow-list невозможен.
Коротко
- Инъекция (A05:2025) возникает, когда ввод попадает в текст для интерпретатора: SQL (CWE-89), фильтр NoSQL (CWE-943), shell (CWE-78), аргументы команды (CWE-88), шаблон (CWE-1336).
- SQL: текст запроса фиксирован, значения только параметрами (
?,$1,%s,:name). Ручное экранирование OWASP не рекомендует. - То, что не бывает параметром (колонка в
ORDER BY, направление сортировки), берут из словаря на сервере; списки передают развёрнутыми местами под параметры или массивом через= ANY. - ORM защищает только то, что строит сам: сырые запросы, сортировка,
LIKEс%и_и склеенныеINостаются на вас. - NoSQL: проверяйте тип значения до фильтра; типизированный DTO делает это автоматически, голый
req.bodyнет. - Команды: сначала попробовать обойтись библиотекой, иначе список аргументов без shell, allow-list значения и
--перед ним. - Шаблоны: исходник только из своих файлов, ввод только как данные, автоэкранирование включено; от SSTI оно не спасает.
- Проверка: тесты с
' OR 1=1 --и объектом вместо строки, gosec и Bandit в сборке, sqlmap на стенде, поиск склейки на ревью.
Что почитать дальше
- SQL-инъекция через конкатенацию - задача на код-ревью: склейка в JPQL и
JdbcTemplate. - PII и секреты - безопасный обработчик ошибок без текста исключения базы в ответе.
- Где какая проверка - слои проверки и раздел «Глубже: дыры помимо доступа».
- Владение ресурсом и ABAC - вторая половина защиты «Моих заказов»: чужой заказ по идентификатору.
- Безопасность браузера и сети - XSS, тот же класс инъекции со стороны браузера.
- SAST в сборке - как подключить статический анализ и не утонуть в ложных срабатываниях.