Безопасность приложений OWASP Top 10: что это, как читать и что делать с каждым пунктом
0%

OWASP Top 10: что это, как читать и что делать с каждым пунктом

OWASP Top 10: что это, как читать и что делать с каждым пунктом

Почти в каждом требовании к безопасности продукта есть строчка «соответствие OWASP Top 10», и почти всегда она означает разное для того, кто её написал, и для того, кто её выполняет. Заказчик слышит «приложение защищено от десяти главных угроз», разработчик читает «нужно доказать, что мы не делаем десять типовых ошибок», аудитор проверяет наличие отчёта сканера. Все трое ошибаются, потому что Top 10 — не то, чем его считают.

Статья решает три задачи: объяснить, чем список является на самом деле (откуда данные, как ранжируются категории, почему две из десяти появляются вообще без данных); дать по каждой категории рабочий разбор — уязвимый код, механика, правильное исправление, способ проверки; показать, как встроить список в процесс, чтобы он менял код, а не только слайды. Мы продолжаем Моделирование угроз: модель отвечает на вопрос «что может пойти не так именно у нас», Top 10 — «что регулярно идёт не так у всех, проверьте, нет ли этого и у вас». Сразу о рамке жанра: дальше много разбора того, как работает уязвимость — иначе её не предотвратить и не починить, — но всё написано с позиции защищающейся стороны, и уязвимый код показан, чтобы вы узнали его в своём репозитории. Проверять что-либо можно только на своих системах или на чужих при письменном разрешении владельца с описанным объёмом работ; об этом отдельный раздел ближе к концу.

Что такое OWASP и что такое Top 10

OWASP (Open Worldwide Application Security Project) — некоммерческий фонд, выпускающий открытые материалы по безопасности приложений: стандарты, руководства, инструменты, обучающие площадки. Страница проекта — owasp.org/www-project-top-ten, текст последней редакции — owasp.org/Top10, исходные данные и обсуждения — в репозитории OWASP/Top10. Top 10 — это документ повышения осведомлённости (awareness document), так он описан самими авторами в первом же абзаце: список из десяти категорий распространённых рисков веб-приложений, обновляемый раз в 3–4 года. Категория — не уязвимость, а объединение десятков CWE (Common Weakness Enumeration) по общей причине или механике. Различие не педантизм, а ключ ко всему остальному: A01 в редакции 2021 объединяет 34 разных CWE — от обхода пути (CWE-22) до IDOR (CWE-639) и CSRF (CWE-352). Фраза «мы закрыли A01» не значит ничего; «мы устранили CWE-639 в модуле счетов и добавили тест на регрессию» — значит.

Распространённое заблуждение Как на самом деле
Top 10 — стандарт, по нему сертифицируют Стандарт верификации — ASVS, Application Security Verification Standard. Top 10 — обзор
Закрыв все 10 пунктов, приложение защищено; это чеклист для аудита Это верхний срез. Чеклист тестирования — WSTG, требований — ASVS, разработки — Proactive Controls
Порядок A01…A10 = порядок ваших приоритетов Порядок отражает данные по отрасли; ваш приоритет даёт модель угроз и ваши активы
Top 10 покрывает любое приложение Он веб-центричен: для API, мобильных приложений, LLM-систем и CI/CD есть отдельные списки
«Сканер не нашёл ничего из Top 10» = чисто Сканеры хорошо находят A03 и A06, почти не находят A01 и A04 — их находят люди и тесты

Последняя строка — самая дорогая ошибка на практике: категории распределены по «сканируемости» крайне неравномерно, и организация, измеряющая безопасность отчётом DAST, систематически слепа именно к тем категориям, что стоят на первых местах.

Как собирают список: данные, CWE и два «народных» пункта

Два места этой схемы обычно понимают неправильно. Смещение выборки: данные приходят от тех, кто их собирает и готов отдать — консалтинга, вендоров сканеров, программ bug bounty; категория, которую хорошо ищут автоматом, попадает в статистику чаще той, что находится только вдумчивым ручным тестированием. Поэтому в методологии есть поправка: у каждой категории указана доля приложений, которые вообще на неё тестировали — если категорию искали в 94% приложений, данные полны, а если в 10%, низкий ранг может быть артефактом того, что её не искали. Опрос сообщества: восемь категорий выведены из данных, две — из голосования специалистов, потому что данные всегда отстают, описывая уже найденное в уже написанном коде. Так в 2021 году в список попал Insecure Design — то, чего сканер не видит в принципе: там нет ошибочной строки, там нет нужной строки.

Анатомия карточки категории OWASP Top 10

Карточки на сайте OWASP устроены одинаково, и читать их нужно как описание выборки, а не как прогноз для вашей системы: «318 487 случаев» говорит о распространённости в мире, а не о вероятности инцидента у вас — у вас может не быть ни такого стека, ни таких данных, ни такой поверхности атаки.

Немного истории: что менялось от 2003 к 2025

Тренд за двадцать лет читается однозначно: список движется от «ошибок в строчке кода» к «ошибкам в решениях и в устройстве процесса». Инъекции никуда не делись, но за них во многом отвечают фреймворки и ORM, а контроль доступа, дизайн, конфигурация, зависимости и сборка — это то, что нельзя целиком выдать библиотекой, потому что оно зависит от предметной области. Практический вывод: доля защиты, которую даёт «пользоваться нормальным фреймворком», за двадцать лет выросла, а доля, которую даёт «думать про свою систему», не изменилась.

Карта: где какая категория живёт

Карта слоёв системы и категорий OWASP Top 10

Полезна и другая нарезка — не по слоям, а по природе ошибки: она объясняет, почему разные категории требуют принципиально разных инструментов проверки.

Природа ошибки Категории Чем ловится
Контроля нет в замысле A04, частично A01 моделирование угроз, abuse stories, ревью требований
Ошибка в своём коде A03, A10, A08 (десериализация) SAST, код-ревью, юнит-тесты
Ошибка в настройке A05, A02 скан конфигурации, политики как код, fail-fast при старте
Ошибка в чужом коде A06, A08 (артефакты) SCA, SBOM, проверка подписи
Ошибка в наблюдении A09 только учение: скан её не видит

Разбор по пунктам

Формат везде одинаковый: что это → уязвимый код → почему работает → как чинить → как проверить. Нумерация по редакции 2021 (её до сих пор цитирует большинство требований и инструментов); что изменилось в 2025 — отдельным разделом ниже.

A01:2021 — Broken Access Control

Самая частая и самая дорогая категория: CWE-284 (некорректный контроль доступа), CWE-639 (доступ по идентификатору объекта, IDOR), CWE-22 (обход пути), CWE-352 (CSRF), обход ограничений бизнес-логики, повышение привилегий.

# УЯЗВИМО: идентификатор пришёл от клиента, владельца объекта никто не спросил
@app.get("/api/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user: User = Depends(current_user)):
    invoice = db.get(Invoice, invoice_id)
    if invoice is None:
        raise HTTPException(404)
    return invoice          # аутентификация есть, авторизации нет

Почему это работает. Аутентификацию перепутали с авторизацией: система знает, кто пришёл, но не проверяет, что ему можно. Реальная проверка обычно существует — на фронтенде: кнопка «Открыть счёт» просто не отрисована для чужих счетов. Клиент не является границей доверия: запрос отправляется напрямую, а последовательные числовые идентификаторы перебираются тривиально.

@app.get("/api/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user: User = Depends(current_user)):
    invoice = db.scalar(select(Invoice).where(   # принадлежность — часть выборки,
        Invoice.id == invoice_id,                # а не проверка «потом»
        Invoice.tenant_id == user.tenant_id,     # арендатор из сессии, НЕ из запроса
    ))
    if invoice is None:
        raise HTTPException(404)   # 403 подтвердил бы существование чужого объекта
    policy.require(user, action="invoice:read", resource=invoice)   # доменные правила
    return invoice

Применённые принципы: deny by default; проверка на сервере, рядом с данными; идентичность из серверной сессии или токена, а не из параметра; централизованная точка решения вместо if в каждом обработчике. Непредсказуемые идентификаторы (UUIDv4/ULID вместо автоинкремента) — полезная мера в глубину, но не замена проверке прав.

Как проверить. Автотест на каждый ресурсный эндпоинт с матрицей «роль × свой объект / чужой / несуществующий»; такие тесты дёшевы, если генерировать их по списку маршрутов, а падение при добавлении эндпоинта без проверки прав — именно тот сигнал, который нужен. Модели прав — в Авторизации, IDOR в контексте интерфейсов — в Безопасности API.

def test_чужой_счёт_недоступен(client, alice, invoice_of_bob):
    r = client.get(f"/api/invoices/{invoice_of_bob.id}", headers=alice.auth)
    assert r.status_code == 404          # не 200 и не 403

A02:2021 — Cryptographic Failures

До 2021 года называлась «Sensitive Data Exposure» — по симптому; новое имя указывает на причину. Ключевые CWE: 327 (рискованный алгоритм), 328 (слабый хеш), 331 (недостаточная энтропия), 259 (зашитый пароль).

// УЯЗВИМО: Math.random — не криптографический ГПСЧ. Его внутреннее состояние
// восстанавливается по нескольким выдачам, после чего следующий токен предсказуем.
const resetToken = Math.random().toString(36).slice(2);
await db.users.update(userId, { resetToken });   // и хранится в открытом виде

Почему это работает. Токен сброса пароля — одноразовый эквивалент пароля: если он предсказуем, ничего взламывать не нужно, значение вычисляется; если он хранится как есть, утечка дампа мгновенно превращается в захват всех аккаунтов. Та же логика — для идентификаторов сессий, кодов подтверждения и ссылок «поделиться файлом».

import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
const token = randomBytes(32).toString("base64url");             // 256 бит из CSPRNG
const tokenHash = createHash("sha256").update(token).digest();   // в базе — только хеш
await db.resetTokens.insert({
  userId, tokenHash, usedAt: null,                 // строго одноразовый
  expiresAt: new Date(Date.now() + 15 * 60_000),   // короткий срок жизни
});
// сравнение за постоянное время, иначе утечка через тайминг
const ok = candidate.length === tokenHash.length && timingSafeEqual(candidate, tokenHash);

Правила категории: пароли — argon2id, scrypt или bcrypt, никогда не «соль + SHA-256»; симметричное шифрование — только AEAD-режимы (AES-GCM, ChaCha20-Poly1305), никогда ECB и никогда фиксированный IV; ключи — не в коде и не в репозитории (Секреты и ключи); TLS везде, включая внутренние вызовы (Транспортная безопасность). Главное решение принимается до всякой криптографии: какие данные вы вообще не будете хранить — ненайденное невозможно расшифровать. Как проверить. Правило SAST, запрещающее список примитивов (md5/sha1 для паролей, Math.random для секретов, DES, ECB); тест, что токен имеет не меньше 128 бит энтропии и не повторяется; проверка, что в базе лежит хеш; ревью, что выдача нового токена инвалидирует старый. Механика — в Прикладной криптографии.

A03:2021 — Injection

В 2021 году сюда влился XSS: механика одна и та же — данные интерпретируются как код, отличается только интерпретатор. Входят CWE-89 (SQL), CWE-79 (XSS), CWE-77/78 (команды ОС), инъекции в LDAP, XPath, шаблонизаторы и запросы NoSQL.

# УЯЗВИМО: значение попадает в ТЕКСТ запроса — парсер СУБД не отличит данные от синтаксиса
cur.execute(f"SELECT id, role FROM users WHERE email = '{email}'")

Почему это работает. У запроса две плоскости: управляющая (структура: SELECT, WHERE, кавычки, операторы) и информационная (значения). Конкатенация склеивает их в одну строку, и решение о том, где кончаются данные и начинается синтаксис, принимает парсер — по символам, которые контролирует пользователь. Экранирование пытается исправить это постфактум и проигрывает на краевых случаях: многобайтовые кодировки, вложенные контексты, диалекты.

# ПРАВИЛЬНО: структура фиксирована, значение идёт отдельным каналом
cur.execute("SELECT id, role FROM users WHERE email = %s", (email,))

# Имя таблицы или колонки параметром не передать — только белый список
SORTABLE = {"created_at", "amount", "status"}
if sort_by not in SORTABLE:
    raise BadRequest("недопустимое поле сортировки")
cur.execute(f"SELECT id FROM orders WHERE tenant_id = %s ORDER BY {sort_by} DESC", (tenant_id,))

Для HTML принцип тот же, но экранирование контекстно-зависимое: одно значение по-разному опасно в тексте, атрибуте, href, <script> и CSS. Отсюда правило: не собирать HTML строками, пользоваться автоэкранированием шаблонизатора и не подставлять пользовательские данные в innerHTML, eval или dangerouslySetInnerHTML без санитайзера вроде DOMPurify. ORM иммунитета не даёт: raw-фрагменты, order_by из параметра запроса и «умные» фильтры возвращают проблему.

Как проверить. Юнит-тест с нагрузкой из кавычек, комментариев SQL и угловых скобок — ожидаемый результат: значение сохранено как данные, ничего не выполнилось; правило SAST против f-строк и конкатенации внутри execute/query; DAST по своему стенду; ревью всех мест с raw. Полный разбор — Инъекции и XSS, CSRF и клиентские атаки.

A04:2021 — Insecure Design

Единственная категория, которую нельзя починить исправлением строки: ошибка не в том, как написано, а в том, что нужного контроля нет в замысле. Пример: восстановление доступа по четырёхзначному коду без ограничения попыток — каждая строка кода корректна, механизм в целом эквивалентен отсутствию защиты.

# Код без ошибок. Проблема в том, чего в нём нет.
def refund(order: Order, amount: Decimal, actor: User) -> Refund:
    payment_gateway.refund(order.payment_id, amount)
    return Refund.create(order=order, amount=amount, actor=actor)

Почему это работает. Требование звучало как «оператор может оформить возврат», и никто не задал вопрос «а что, если оператор оформит возврат сто раз подряд, на сумму больше оплаты или после отмены заказа». Ограничений нет не потому, что программист ошибся, а потому что их не было в требованиях. Сканер такое не находит: он не знает, каким должно быть поведение.

def refund(order: Order, amount: Decimal, actor: User) -> Refund:
    if amount <= 0 or amount > order.paid_total - order.refunded_total:
        raise DomainError("возврат превышает фактически оплаченное")
    if refunds_today(actor) + amount > REFUND_DAILY_LIMIT:
        raise DomainError("превышен дневной лимит оператора")
    if amount > MANUAL_APPROVAL_THRESHOLD:
        return Refund.create_pending(order, amount, actor)   # подтверждает второй человек
    with transaction():                      # атомарно: без гонки на двойной возврат
        order.lock_for_update()
        payment_gateway.refund(order.payment_id, amount, idempotency_key=order.refund_key())
        audit.log("refund.executed", actor=actor, order=order, amount=amount)
        return Refund.create(order=order, amount=amount, actor=actor)

Организационно это лечится до кода: моделирование угроз на этапе проектирования, «истории злоупотребления» (abuse stories) рядом с пользовательскими историями в бэклоге, лимиты и пороги как явные требования, а не как «здравый смысл». Практический вход — OWASP Proactive Controls и OWASP SAMM. Как проверить. Тесты на злоупотребление (превышение лимита, двойное выполнение, гонка), а не только на «счастливый путь»; в определении готовности фичи — пункт «сценарии злоупотребления рассмотрены»; запись решения в ADR, чтобы через год было понятно, почему порог именно такой.

A05:2021 — Security Misconfiguration

Сюда переехал XXE (CWE-611). Представители: включённая отладка в проде, учётные данные по умолчанию, подробные трассировки в ответах, открытые наружу служебные порты и панели, разрешительный CORS, необновлённые настройки облачных хранилищ.

# УЯЗВИМО: парсер по умолчанию резолвит внешние сущности — XXE (CWE-611): документ
# заставит сервер прочитать локальный файл или сходить во внутреннюю сеть
tree = lxml.etree.parse(uploaded_file)

# УЯЗВИМО: одна переменная окружения не выставилась — трассировки уехали пользователям
DEBUG = os.getenv("DEBUG", "True") == "True"
ALLOWED_HOSTS = ["*"]

Почему это работает. Значения по умолчанию исторически оптимизированы под «чтобы заработало», а не под «чтобы было безопасно». Плюс конфигурация — единственная часть системы, которая меняется без ревью: её правят руками в консоли, в переменных окружения, в панели облака. Дрейф между «как задумано» и «как настроено» — нормальное состояние системы без автоматической проверки.

from defusedxml.ElementTree import parse   # DTD и внешние сущности выключены
tree = parse(uploaded_file)

def validate_settings(s: Settings) -> None:
    """Fail-fast: приложение не должно запускаться в опасной конфигурации."""
    if s.env == "production":
        assert not s.debug, "DEBUG включён в проде"
        assert s.secret_key != DEFAULT_SECRET_KEY, "ключ по умолчанию"
        assert "*" not in s.allowed_hosts, "ALLOWED_HOSTS не ограничен"

Дополнительно: конфигурация как код и одинаковый способ развёртывания для всех сред; минимальные базовые образы; удаление неиспользуемых компонентов и примеров приложений; заголовки безопасности на периметре; автоматическая сверка с эталоном (CIS Benchmarks). Процесс — в Безопасности в конвейере.

Как проверить. Тест «приложение падает при старте, если DEBUG включён в проде» — конфигурационные инварианты проверяются так же, как обычные, — плюс внешняя проверка своего стенда:

curl -sI https://staging.example.internal/ | grep -iE 'strict-transport|content-security|x-frame'
trivy image --severity HIGH,CRITICAL registry.example.internal/api:1.42

A06:2021 — Vulnerable and Outdated Components

Единственная категория, где уязвимый код написали не вы: типичный сервис на 90%+ состоит из чужих пакетов, причём большая часть — транзитивные зависимости, которые никто сознательно не выбирал. Проявления: зависимость с известной CVE, зафиксированная в lock-файле полтора года назад; базовый образ :latest, который последний раз пересобирали при запуске проекта; библиотека, которую перестали поддерживать. Почему это работает. Публикация CVE — публичная инструкция: с момента раскрытия эксплуатация становится массовой и автоматической, а сканирование интернета на уязвимые версии стоит копейки. Довод «наш сервис никому не интересен» не работает: интересен не сервис, а его вычислительные ресурсы и сетевая позиция.

npm audit --omit=dev            # только то, что реально едет в прод
pip-audit -r requirements.txt
govulncheck ./...               # анализ достижимости: уязвимая функция реально вызывается?
syft dir:. -o cyclonedx-json > sbom.json      # SBOM: список того, что вы выкатили
grype sbom:sbom.json --fail-on high

Приоритизация: сначала то, что есть в каталоге эксплуатируемых уязвимостей CISA KEV, затем достижимое из вашего кода, затем остальное по расписанию; порог блокировки сборки лучше ставить по «эксплуатируется в дикой природе», а не по формальному CVSS, иначе команда научится игнорировать красный конвейер. Как проверить. Проверка в CI на каждом PR и по расписанию (уязвимость появляется в коде, который не менялся); бот обновлений с автослиянием патч-версий при зелёных тестах; SBOM для каждого релиза и его сравнение между релизами. Подробнее — Безопасность цепочки поставок.

A07:2021 — Identification and Authentication Failures

Про то, как система устанавливает и поддерживает личность: подбор паролей без ограничений, слабая политика, фиксация сессии (CWE-384), небезопасное восстановление доступа, отсутствие второго фактора.

// УЯЗВИМО: идентификатор сессии не меняется при входе — фиксация сессии (CWE-384).
// Известный атакующему идентификатор, подсунутый жертве заранее, после её входа
// становится аутентифицированным.
app.post("/login", async (req, res) => {
  const user = await verifyPassword(req.body.email, req.body.password);
  req.session.userId = user.id;
  res.json({ ok: true });
});

Почему это работает. Сессия — привязка «строка ↔ личность»: если строка не меняется в момент, когда меняется уровень доверия, всё, что было известно об этой строке до входа, продолжает действовать после. Тот же класс ошибок: не завершать сессии при смене пароля, не ограничивать частоту попыток, отвечать «такого пользователя нет» вместо единого сообщения.

app.post("/login", async (req, res) => {
  await rateLimiter.consume([req.body.email, req.ip]);   // лимит по учётке И по источнику
  const user = await verifyPassword(req.body.email, req.body.password);  // argon2id внутри
  if (!user) {
    // одинаковый ответ и сопоставимое время: не раскрываем существование учётной записи
    return res.status(401).json({ error: "invalid_credentials" });
  }
  // новый идентификатор сессии при смене уровня доверия
  await new Promise<void>((ok, err) => req.session.regenerate(e => (e ? err(e) : ok())));
  req.session.userId = user.id;
  res.cookie("sid", req.sessionID, { httpOnly: true, secure: true, sameSite: "lax" });
  res.json({ ok: true });
});

Требования к паролям берите не из фольклора, а из NIST SP 800-63B: длина важнее состава символов, обязательная периодическая смена вредна, проверка по спискам утёкших паролей полезна. Второй фактор снимает целые классы атак, но не все: против фишинга по-настоящему работают только привязанные к домену методы — WebAuthn и passkeys.

Как проверить. Тесты: идентификатор сессии до и после входа различается; после N неудачных попыток запросы получают отказ; токен восстановления одноразовый и протухает; смена пароля завершает остальные сессии. Детали — Аутентификация, OAuth 2.0 и OpenID Connect, JWT и токены.

A08:2021 — Software and Data Integrity Failures

Категория о доверии без проверки: код и данные принимаются из источника, чья подлинность не подтверждена. Входят небезопасная десериализация (CWE-502), обновления без проверки подписи (CWE-494), зависимость от недоверенного шага сборки (CWE-829).

# УЯЗВИМО: pickle.loads над данными из запроса — это выполнение произвольного кода,
# а не «разбор данных»: формат pickle по определению исполняем
state = pickle.loads(base64.b64decode(request.form["state"]))

Почему это работает. Форматы вроде pickle (Python), readObject (Java), BinaryFormatter (.NET) восстанавливают не данные, а объекты — вызывая конструкторы и магические методы. Достаточно, чтобы нашлась подходящая цепочка классов, и «разбор данных» превращается в вызов чего угодно. Ни валидация после разбора, ни подпись поверх такого формата не спасают: код исполняется в процессе десериализации.

# 1. Формат без исполнения + схема
state = StateSchema.model_validate(json.loads(raw))   # pydantic: типы и границы проверены

# 2. Если состояние путешествует через клиента — подписываем и проверяем ДО разбора
body = json.dumps(state, separators=(",", ":"), sort_keys=True).encode()
mac = hmac.new(SIGNING_KEY, body, hashlib.sha256).digest()
if not hmac.compare_digest(mac, expected):            # сравнение за постоянное время
    raise SecurityError("подпись состояния не совпала")

Вторая половина категории — целостность конвейера: шаг сборки, тянущий скрипт по изменяемой ссылке, эквивалентен доверию к чужому серверу. Поэтому шаги CI пиннятся по неизменяемому идентификатору (actions/checkout@11bd719… вместо @v4), а curl … | bash без проверки контрольной суммы или подписи не допускается. Ориентир по зрелости — SLSA, подпись артефактов — Sigstore/cosign.

Как проверить. Поиск запрещённых десериализаторов правилом SAST по всему репозиторию; проверка, что шаги CI закреплены по SHA; шаг проверки подписи артефакта перед развёртыванием, умеющий валить релиз; тест, что подделанная подпись состояния приводит к отказу.

A09:2021 — Security Logging and Monitoring Failures

Категория, которая ничего не предотвращает — и потому её постоянно откладывают. Она определяет, узнаете ли вы об инциденте вообще и сможете ли восстановить, что произошло.

# УЯЗВИМО дважды: 1) ввод уходит в лог как есть — подделка строк журнала (CWE-117):
# перевод строки внутри email дописывает фальшивую запись; 2) неудачный вход
# не порождает события, по которому можно построить алерт
log.info("login failed for " + email)

Почему это работает. Журнал — доказательная база: если в него можно писать управляющие символы, доказательства перестают быть доказательствами. Если события безопасности не отличаются от прочего шума, ни один алерт по ним не построить. А если срок хранения — три дня, расследование инцидента, обнаруженного через месяц, начинается с пустоты.

# Структурная запись: поля отдельно от текста — экранирование не нужно,
# а по полям строятся агрегаты и пороги алертов
log.info("auth.login.failed", extra={
    "event": "auth.login.failed", "outcome": "failure",
    "user_id": user_id,              # идентификатор, а не сам email
    "src_ip": request.client.host,
    "reason": "bad_password",        # из перечисления, не из пользовательского ввода
    "request_id": request_id,        # сквозной идентификатор для трассировки
})

Минимальный набор событий для любого сервиса: вход (успех и отказ), выход, смена пароля и второго фактора, восстановление доступа, изменение прав и ролей, отказ авторизации, доступ к персональным данным, административные действия, изменения конфигурации, ошибки проверки подписи. Чего в журнале быть не должно никогда: паролей, токенов, номеров карт, содержимого персональных данных «для удобства отладки».

Как проверить. Пройти по списку событий и убедиться, что каждое реально попадает в хранилище и по нему строится запрос. Полноценная проверка одна — учение: заранее согласованное с командой действие на своём стенде, после которого измеряется, за сколько минут сработал алерт и хватило ли данных для восстановления картины. Практика — в Наблюдаемости и дежурствах.

A10:2021 — Server-Side Request Forgery (SSRF)

Попала в список по итогам опроса сообщества: в данных встречалась редко, но последствия специалисты видели слишком часто. Причина роста — облака и микросервисы: у сервера есть сетевая позиция, которой нет у клиента.

// УЯЗВИМО: адрес назначения задаёт пользователь. Сервер сходит куда угодно:
// во внутреннюю сеть, на сервис метаданных облака, на localhost.
resp, err := http.Get(r.FormValue("url"))

Почему это работает. Функции «импортировать по ссылке», «проверить вебхук», «сделать превью», «сгенерировать PDF по URL» превращают сервер в прокси. Внутренние адреса недоступны снаружи, но доступны изнутри, а многие внутренние сервисы исторически не требуют аутентификации, потому что «они же в приватной сети». Отдельно опасны перенаправления (проверили один адрес, ушли на другой) и DNS rebinding (при повторном разрешении имени тот же домен указывает уже на приватный адрес).

// Разрешаем только известные схемы и хосты, запрещаем перенаправления и подключаемся
// к УЖЕ ПРОВЕРЕННОМУ IP — иначе повторное разрешение имени даёт DNS rebinding.
func safeDial(ctx context.Context, network, addr string) (net.Conn, error) {
    host, port, err := net.SplitHostPort(addr)
    if err != nil {
        return nil, err
    }
    ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
    if err != nil || len(ips) == 0 {
        return nil, errors.New("имя назначения не разрешается")
    }
    for _, ip := range ips {
        if ip.IP.IsLoopback() || ip.IP.IsPrivate() || ip.IP.IsLinkLocalUnicast() {
            return nil, errors.New("адрес во внутренней сети запрещён")
        }
    }
    return (&net.Dialer{Timeout: 3 * time.Second}).DialContext(
        ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
}

Этот safeDial подставляется в http.Client вместе с общим таймаутом и CheckRedirect, возвращающим http.ErrUseLastResponse: перенаправления не следуются автоматически, иначе проверка адреса обходится одним ответом 302. Меры в глубину поверх кода: отдельная политика исходящего трафика (сервису, которому нужен интернет, не нужна внутренняя сеть); выделенный прокси со списком разрешённых адресов; IMDSv2 для сервиса метаданных в облаке; аутентификация внутренних сервисов через mTLS вместо доверия по адресу. Опорный документ — OWASP SSRF Prevention Cheat Sheet. Как проверить (только на своём стенде). Регрессионные тесты с адресами http://127.0.0.1/, http://169.254.169.254/, http://[::1]/, доменом из вашей тестовой зоны, который разрешается в приватный адрес, и адресом, отвечающим перенаправлением на приватный. Ожидаемый результат во всех случаях — отказ и запись события в журнал.

Куда ставить проверку: одна схема на все категории

Половина ошибок из списка — следствие проверки не в том месте: клиент подсказывает пользователю, но не защищает; шлюз фильтрует общее, но не знает предметной области; решение принимается там, где есть и личность, и объект, и правила.

Ключевые места схемы: личность берётся из проверенного токена, а не из тела запроса; условие принадлежности едет в сам запрос к хранилищу; отказ порождает событие; ответ не различает «нет прав» и «нет объекта» там, где это раскрывает лишнее.

Редакция 2025: что изменилось

Осенью 2025 года OWASP выпустил новую редакцию. Смысловой сдвиг тот же: вверх идёт то, что находится вне отдельной строки кода.

2021 2025 Комментарий
A01 Broken Access Control A01 Broken Access Control Остался на первом месте
A05 Security Misconfiguration A02 Security Misconfiguration Поднялась: инфраструктуры и настроек стало больше, чем кода
A06 Vulnerable and Outdated Components A03 Software Supply Chain Failures Расширена: не только зависимости, но сборка, артефакты, реестры
A02 Cryptographic Failures, A03 Injection, A04 Insecure Design A04, A05, A06 соответственно Сдвинулись вниз: фреймворки закрывают всё больше, содержание прежнее
A07 / A08 / A09 A07 Authentication, A08 Integrity, A09 Logging & Alerting Уточнены названия; акцент A09 смещён на оповещение
A10 SSRF A10 Mishandling of Exceptional Conditions SSRF растворился в других категориях; новая категория — про ошибки

Software Supply Chain Failures — признание того, что «обновляйте зависимости» больше не описывает проблему: вектор сместился к процессу (компрометация аккаунта сопровождающего, вредоносный постустановочный скрипт, подмена артефакта в реестре, доверие чужому шагу CI). Отсюда SBOM, подпись и проверка артефактов, воспроизводимые сборки, пиннинг по неизменяемым идентификаторам.

Mishandling of Exceptional Conditions — про нештатные пути: подавленное исключение, из-за которого проверка «прошла»; ответ с полной трассировкой; частичный откат, оставляющий систему в противоречивом состоянии; fail-open вместо fail-closed. Правило простое: при ошибке система обязана переходить в запрещающее состояние — сервис проверки прав недоступен, значит доступа нет; подпись не проверилась, значит артефакт не разворачивается. Точные формулировки сверяйте на owasp.org/Top10: редакции меняются, а требования в контрактах — нет, и вам регулярно придётся объяснять, на какую версию вы отвечаете.

Как встроить Top 10 в работу команды

Наличие списка на вики не меняет ни одной строки кода — меняет встраивание в точки, где и так принимаются решения: модель угроз и abuse stories на проектировании (ловят A04 и A01); шаблон сервиса; линтеры и правила SAST при написании кода (A03, A02, A08); короткий список вопросов на ревью; SCA, SBOM и проверка конфигурации в CI (A06, A05); тесты авторизации и злоупотреблений на стенде; события, алерты и учения в проде (A09).

Самый недооценённый пункт здесь — шаблон сервиса: категории A02, A05, A07 и A09 дешевле всего закрываются один раз в общем каркасе, где разработчик получает готовые заголовки, готовое подключение к хранилищу секретов, готовый формат событий безопасности и настроенную сессию. Безопасность по умолчанию масштабируется, а «помните про безопасность» — нет. Ревью тоже стоит свести к короткому списку вопросов, иначе им не пользуются:

  1. Есть ли новый эндпоинт или новый доступ к данным? Кто проверяет права и где — рядом с данными или в UI?
  2. Есть ли строка, собирающая запрос, HTML, команду или шаблон конкатенацией?
  3. Появились ли новые данные пользователя? Что из них нельзя логировать и что нельзя хранить вообще?
  4. Есть ли новый исходящий запрос по адресу, на который влияет пользователь?
  5. Есть ли новая зависимость? Кто её сопровождает и что она тянет за собой?
  6. Что произойдёт при ошибке в этой ветке — доступ закроется или откроется?
  7. Какое событие безопасности порождает изменение и можно ли по нему построить алерт?

Приоритизировать найденное лучше не по номеру категории, а по паре «риск для нас × стоимость исправления»: дешёвое и рискованное делается сейчас, дорогое и рискованное планируется как проект, дешёвое и нерискованное автоматизируется, остальное фиксируется как принятый риск со сроком пересмотра. И у самой находки есть жизненный цикл — без него отчёт сканера превращается в архив.

Метрика «сколько уязвимостей закрыто» толкает к косметике. Работают другие: доля сервисов на общем шаблоне, медианное время от появления публичной CVE до обновления, доля эндпоинтов, покрытых тестами авторизации, время от действия до срабатывания алерта на учении.

Легальность: проверять можно только своё

Всё, что описано выше как «как проверить», подразумевает ваши системы или прямое письменное разрешение владельца — это не формальность.

  • Тестирование чужой инфраструктуры без разрешения — правонарушение независимо от намерений и результата; отсутствие ущерба не является оправданием.
  • Разрешение оформляется письменно и содержит: перечень систем и адресов, окно времени, допустимые и недопустимые техники (обычно исключены отказ в обслуживании и социальная инженерия), контакты для экстренной остановки, порядок обращения с найденными данными.
  • Публичные программы bug bounty — это и есть форма разрешения, но строго в границах опубликованной политики: вышли за scope — разрешения нет; в облаке дополнительно действуют правила провайдера. Найденные персональные данные не выгружаются, не сохраняются и не пересылаются: цель проверки — подтвердить факт доступа, а не собрать выборку.

Тренироваться нужно на площадках, специально для этого созданных: OWASP Juice Shop, WebGoat, PortSwigger Web Security Academy — там разрешение выдано заранее и явно.

Типичные ошибки применения списка

  • Считать Top 10 объёмом работ. Это верхний срез: полный набор требований — ASVS, полный набор проверок — WSTG.
  • Сводить безопасность к отчёту сканера. Сканеры плохо видят A01 и почти не видят A04; «красных находок нет» и «прав доступа не проверяют» сосуществуют прекрасно.
  • Путать с CWE Top 25. CWE Top 25 — список конкретных слабостей от MITRE на основе CVE; OWASP Top 10 — категории рисков веб-приложений. Наборы и источники разные.
  • Игнорировать профильные списки. Для REST и GraphQL есть OWASP API Security Top 10, для мобильных — Mobile Top 10, для систем с языковыми моделями — OWASP Top 10 for LLM Applications (перекликается с материалом Безопасность и инъекции в промпты).
  • Устраивать разовую «зачистку по списку». Категории возвращаются вместе с новым кодом; работает только встроенный в конвейер контроль и регрессионные тесты. И отчитываться нужно той версией списка, которая указана в требовании: если в контракте «Top 10 2021», покажите соответствие 2021 и отдельно отметьте, что учли изменения 2025.

Мини-итог

Top 10 — компактная карта того, на чём отрасль ошибается чаще всего, а не программа работ и не стандарт. Пользы от неё ровно столько, сколько вы переведёте в конкретные действия: категория → CWE → место в вашем коде → исправление → тест, который не даст этому вернуться. Три вещи, которые стоит унести. Первое: порядок категорий — про мир, приоритет — про вас, его даёт модель угроз, а не номер пункта. Второе: самые дорогие категории плохо автоматизируются — контроль доступа и дизайн находят люди, тесты и разбор требований, поэтому сканер нельзя использовать как измеритель безопасности. Третье: любая категория закрывается связкой «исправление + проверка»; исправление без теста возвращается в среднем через пару релизов.

Источники

Что дальше

Карта пройдена — дальше вглубь по каждой категории, начиная с самой механической: почему интерпретатор не отличает данные от синтаксиса, как это выглядит в SQL, NoSQL, командах ОС и шаблонизаторах, и почему параметризация решает проблему, а экранирование только откладывает.

Инъекции: SQL, NoSQL, команды, шаблоны — механика и защита

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов