OWASP Top 10: разбор уязвимостей с примерами на PHP

OWASP Top 10: разбор уязвимостей с примерами на PHP (редакция 2025)

Безопасность веб-приложений — это не роскошь, а необходимость. Сайты ежедневно подвергаются атакам: SQL-инъекции, XSS, подделка запросов, утечки данных, атаки через цепочку поставок. По данным отчёта Verizon DBIR, атаки на веб-приложения стабильно входят в число ведущих причин утечек, и большинство из них предотвратимы — если знать, где именно ломаются границы доверия.

OWASP Top 10 — список десяти самых критичных рисков безопасности веб-приложений, который выпускает фонд OWASP (Open Worldwide Application Security Project). Это не догма, а ориентир для аудита и защиты.

Что обновилось. В этой статье разобран актуальный список OWASP Top 10:2025 — восьмая редакция, официально заменившая редакцию 2021 года. В ней две новые категории, одна слияние, переставлены места и пересмотрены названия. Ниже — каждая уязвимость: суть, уязвимый код на PHP, как её эксплуатируют и как закрыть.

Важно: все примеры приведены исключительно в образовательных целях. Не используйте их для атак.


Что изменилось в редакции 2025 (коротко)

  • Добавлены A03:2025 — Software Supply Chain Failures (выросла из бывших «устаревших компонентов» в отдельную большую тему про всю цепочку поставок) и A10:2025 — Mishandling of Exceptional Conditions (некорректная обработка ошибок и нештатных ситуаций, fail-open).
  • SSRF больше не отдельная строка Top-10. Риск «встроен» в A01 Broken Access Control (доступ к внутренним ресурсам). Как техника он не стал менее важен — поэтому ему посвящён отдельный разбор в конце статьи.
  • Переставлены места: Security Misconfiguration поднялась с 5-го на 2-е; Cryptographic Failures и Injection опустились на две позиции (на 4-е и 5-е).
  • Переименованы категории: «Identification and Authentication Failures» → Authentication Failures; «Vulnerable and Outdated Components» → Software Supply Chain Failures; «Logging and Monitoring Failures» → Logging & Alerting Failures; «Software and Data Integrity Failures» → «Software or Data Integrity Failures».
  • Стал шире охват: анализировалось 589 CWE (против ~400 в 2021), в сами категории вошли 248 CWE; категория A01 включает до 40 CWE, A03 и A09 — по 5.

Соответствие старой и новой нумерации:

Редакция 2021Редакция 2025
A01 Broken Access ControlA01 Broken Access Control
A02 Cryptographic FailuresA04 Cryptographic Failures
A03 InjectionA05 Injection
A04 Insecure DesignA06 Insecure Design
A05 Security MisconfigurationA02 Security Misconfiguration
A06 Vulnerable and Outdated ComponentsA03 Software Supply Chain Failures
A07 Identification and Authentication FailuresA07 Authentication Failures
A08 Software and Data Integrity FailuresA08 Software or Data Integrity Failures
A09 Security Logging and Monitoring FailuresA09 Security Logging & Alerting Failures
A10 Server-Side Request Forgery (SSRF)A10 Mishandling of Exceptional Conditions (SSRF → отдельный разбор)

A01:2025 — Broken Access Control (нарушение контроля доступа)

Суть: пользователь получает доступ к данным или функциям, которые ему не разрешены — по горизонтали (чужие данные) или по вертикали (чужие роли). Самая распространённая категория Top-10; в неё же теперь входит и SSRF (доступ к внутренним ресурсам через сервер).

Уязвимый PHP:

PHP
// profile.php — просмотр профиля по ID
$userId = $_GET['id'];
$user   = getUserById($userId);
echo "Имя: "   . $user['name'];
echo "Email: " . $user['email'];

Если не проверять, что $userId принадлежит текущему пользователю, любой подменит ?id=2 и увидит чужой профиль.

Эксплуатация: перебор ID (IDOR), подмена параметров (?admin=1), прямой доступ к «скрытым» URL, обход проверки на клиенте.

Защита: проверяйте права на сервере на каждом запросе; идентифицируйте пользователя по сессии, а не по параметрам из URL.

PHP
session_start();
$currentUserId = (int) ($_SESSION['user_id'] ?? 0);
$requestedId   = (int) ($_GET['id'] ?? 0);   // приводим к int — против type-juggling

if ($currentUserId === 0) {                  // fail closed: нет сессии — нет доступа
    http_response_code(401);
    exit('Требуется вход');
}
if ($currentUserId !== $requestedId && !isAdmin($currentUserId)) {
    http_response_code(403);
    exit('Доступ запрещён');
}

A02:2025 — Security Misconfiguration (небезопасная конфигурация)

Суть: неверные настройки сервера, приложения и БД: открытые порты, дефолтные пароли, включённый debug на проде, лишние сервисы, устаревшие TLS. В 2025 поднялась на 2-е место — поведение приложений всё сильнее зависит от конфигурации.

Пример: вывод ошибок PHP на проде.

PHP
; php.ini на продакшене — ТАК НЕЛЬЗЯ
display_errors = On

Это раскрывает пути к файлам, структуру БД и версии ПО.

Защита:

PHP
; продакшен
display_errors = Off
log_errors     = On
error_log      = /var/log/php/error.log
  • Удалите стандартные аккаунты и демо-контент, закройте лишние порты.
  • Отключите неиспользуемые модули и директории листинга.
  • Настройте заголовки безопасности (CSP, HSTS, X-Content-Type-Options).
  • Автоматизируйте проверку конфигураций (сканеры, IaC-линтеры).
  • Обновляйте ПО (см. также A03).

A03:2025 — Software Supply Chain Failures (сбои цепочки поставок ПО)

Суть: компрометация не только «своего» кода, а всей экосистемы зависимостей, сборки и дистрибуции: заражённые пакеты, подменённые реестры, уязвимые транзитивные зависимости, атаки на CI/CD, typosquatting, перехват обновлений и подписанных артефактов. В сообществе это назвали риском №1 по важности, хотя в автоматических тестах он пока мало представлен.

Примеры рисков:

  • старый WordPress/PHP/jQuery с известными CVE;
  • пакет из npm/Composer с подменённым maintainer’ом или удалённый и «перехваченный» под тем же именем;
  • сборка, подтягивающая latest вместо зафиксированной версии;
  • артефакт из CI/CD без проверки подписи.

Защита:

PHP
# PHP/Composer — аудит зависимостей на известные CVE
composer audit

# Node — аналогично
npm audit
  • Фиксируйте версии и lock-файлы (composer.lock, package-lock.json), никогда не тяните latest на проде.
  • Обновляйте зависимости регулярно и удаляйте неиспользуемые.
  • Следите за CVE и подписками безопасности своих пакетов.
  • Проверяйте целостность и происхождение артефактов: контрольные суммы, подписи (cosign/Sigstore), подписанные коммиты в Git, воспроизводимые сборки.
  • Ограничьте цепочку сборки: изолированные раннеры CI/CD, право публиковать — у минимум аккаунтов, 2FA для реестров.
  • Проверяйте имя пакета по буквам (защита от typosquatting) и используйте приватные зеркала/прокси реестров.

A04:2025 — Cryptographic Failures (криптографические сбои)

Суть: неверное шифрование, хранение паролей в открытом виде, передача по HTTP, слабые алгоритмы (MD5/SHA1). Раньше эта категория называлась Sensitive Data Exposure; акцент смещён на корневую причину — плохую криптографию.

Уязвимый PHP:

PHP
// Хранение пароля в открытом виде + SQL-конкатенация (сразу и A05)
$password = $_POST['password'];
$query = "INSERT INTO users (password) VALUES ('$password')";

Эксплуатация: утечка БД отдаёт все пароли; перехват трафика по HTTP; подбор по хешу, если это MD5/SHA1 без соли.

Защита:

PHP
// Хеш с адаптивной стоимостью (bcrypt/Argon2 — выбирает PHP сам)
$hash = password_hash($_POST['password'], PASSWORD_DEFAULT);

// Проверка
if (password_verify($inputPassword, $storedHash)) {
    // успешный вход
}
  • Используйте password_hash() и password_verify(), а не самодельные хеши.
  • Всегда HTTPS; шифрование данных при передаче и при хранении.
  • Не изобретайте свои алгоритмы; берите проверенные библиотеки и актуальные параметры (AES-GCM, Argon2id и т. п.).

A05:2025 — Injection (инъекции)

Суть: внедрение вредоносного кода через пользовательский ввод: SQL, команды ОС, LDAP, XPath и другие. Самая «тестируемая» категория — от частого XSS до редкой, но тяжёлой SQL-инъекции.

Уязвимый PHP:

PHP
$username = $_POST['username'];
$password = $_POST['password'];
$query  = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
$result = mysqli_query($conn, $query);

Если ввести admin' --, запрос «обрезается» комментарием:

SQL
SELECT * FROM users WHERE username = 'admin' --' AND password = '...'

И вход проходит без пароля. (Канонический payload именно с комментарием — он не зависит от приоритета AND/OR.)

Защита — параметризованные запросы. Хеш пароля сверяем в PHP, а не в SQL (пароли уже хешированы — см. A04):

PHP
$stmt = $pdo->prepare("SELECT id, password_hash FROM users WHERE username = ? LIMIT 1");
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

// Сравнение выполняем ВСЕГДА — даже если пользователя нет (против timing-разведки «логин существует?»)
$hash = $user['password_hash']
    ?? '$2y$10$C6UzMDM.H6dfI/f/IKcEeO7ZBpBGVpZ3mB1t6p7dOz1k7mYyGQxG'; // dummy-хеш

if ($user && password_verify($password, $hash)) {
    session_regenerate_id(true);            // фиксируем новую сессию после входа (см. A07)
    $_SESSION['user_id'] = (int) $user['id'];
} else {
    // Одно сообщение и для «нет такого логина», и для «неверный пароль»
    throw new RuntimeException('Неверный логин или пароль');
}

Для командных инъекций оборачивайте аргументы в escapeshellarg() (а не весь запрос через escapeshellcmd()):

PHP
$file = $_GET['file'] ?? '';
$cmd  = 'convert ' . escapeshellarg($file) . ' ' . escapeshellarg('out.png');
exec($cmd, $output, $exitCode);   // ; и | внутрь кавычек не пролезают
  • Никогда не подставляйте ввод прямо в запрос — только prepared statements / ORM.
  • Применяйте контекстную экранировку вывода (XSS — часть этой категории).
  • Меньше всего — фильтрация «по чёрному списку»: она обходится.

A06:2025 — Insecure Design (небезопасный проект)

Суть: архитектурные ошибки, которые нельзя залатать точечным фиксом: нет лимита попыток входа, сброс пароля без подтверждения, небезопасные бизнес-процессы. В 2025 сместилась на 6-е место.

Пример: восстановление пароля, которое просто меняет пароль по email — без токена.

PHP
// ТАК НЕЛЬЗЯ: любой, кто знает чужой email, сменит пароль
$email       = $_POST['email'];
$newPassword = $_POST['new_password'];
$query = "UPDATE users SET password = '$newPassword' WHERE email = '$email'";

Защита:

  • Продумывайте безопасность на этапе проектирования (threat modeling).
  • Сброс пароля — через одноразовый подписанный токен с коротким TTL, привязанный к сессии, а не «по email в форме».
  • Ограничивайте количество попыток; внедряйте MFA.
  • Проектируйте по принципу наименьших привилегий и отказ по умолчанию (fail closed).

A07:2025 — Authentication Failures (сбои аутентификации)

Суть: слабая аутентификация: простые пароли, нет блокировки после неудачных попыток, небезопасные сессии, кража учётных данных. Название короче прежнего («Identification and …» убрано), смысл тот же.

Пример: сессия в cookie без флагов HttpOnly и Secure.

PHP
session_start(); // уязвимо к XSS-краже cookie и перехвату по HTTP

Защита:

PHP
session_set_cookie_params([
    'lifetime' => 0,
    'path'     => '/',
    'domain'   => '',
    'secure'   => true,          // только по HTTPS
    'httponly' => true,          // недоступно из JS — против кражи через XSS
    'samesite' => 'Lax',         // 'Strict'/'Lax' — против CSRF
]);
session_start();
session_regenerate_id(true);     // новый ID после входа — против session fixation
  • Требуйте стойкие пароли и сверяйте их с базами утёкших.
  • Ограничивайте попытки входа (rate limiting) и логируйте неудачи (см. A09).
  • Внедряйте 2FA; не храните «запомненные» токены в открытом виде.

A08:2025 — Software or Data Integrity Failures (нарушения целостности ПО и данных)

Суть: непроверенные обновления и зависимости, небезопасная десериализация, отсутствие проверки подписей — на более низком уровне, чем вся цепочка поставок (A03).

Пример: десериализация недоверенных данных.

PHP
$data = $_COOKIE['user_data'];
$user = unserialize($data);   // объект атакующего может выполнить код (POP-цепочки)

Защита:

  • Не десериализуйте пользовательские данные. Вместо serialize()/unserialize() — JSON:$user = json_decode($_COOKIE['user_data'], true, 512, JSON_THROW_ON_ERROR); // ассоц. массив, не объект
  • Если без сериализации не обойтись — разрешайте конкретные классы: unserialize($data, ['allowed_classes' => [User::class]]).
  • Проверяйте целостность обновлений (контрольные суммы, подписи) и используйте подписанные коммиты/артефакты.

A09:2025 — Security Logging & Alerting Failures (логирование и оповещения)

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

Пример проблемы: запись ввода в лог сырым — это CRLF/лог-инъекция, строку лога можно подделать:

PHP
// ТАК НЕЛЬЗЯ: $username может содержать \r\n и «нарисовать» поддельную строку лога
error_log("Неудачная попытка входа: " . $username . " с IP " . $_SERVER['REMOTE_ADDR']);

Защита — санитизация лога:

PHP
function log_login_failure(string $username, string $ip): void {
    // одна строка = одна запись: режем управляющие символы (CR/LF и проч.)
    $safeUser = preg_replace('/[\x00-\x1F\x7F]/', '', $username);
    $safeIp   = filter_var($ip, FILTER_VALIDATE_IP) ? $ip : 'unknown';
    error_log(sprintf('LOGIN_FAILED user="%s" ip=%s at=%s', $safeUser, $safeIp, date('c')));
}
log_login_failure($_POST['username'] ?? '', $_SERVER['REMOTE_ADDR'] ?? '');

И оповещение — не ждём ручного разбора логов (в WordPress вместо сессии используйте get_transient()/set_transient()):

PHP
session_start();
$key   = 'failed_login_' . ($_SERVER['REMOTE_ADDR'] ?? 'na');
$count = (int) ($_SESSION[$key] ?? 0) + 1;
$_SESSION[$key] = $count;

if ($count === 10) {
    @mail(getenv('SECURITY_ALERT_EMAIL') ?: 'admin@example.com',
          'Возможный брутфорс',
          "10 неудачных входов с {$_SERVER['REMOTE_ADDR']}");
}
  • Логируйте входы/выходы, сбои аутентификации, контроль доступа и финансовые события.
  • Храните логи в целостном, защищённом, централизованном виде (без PII и секретов внутри).
  • Настраивайте алерты и реакция на аномалии; регулярно пересматривайте правила.

A10:2025 — Mishandling of Exceptional Conditions (нештатные ситуации)

Новая категория 2025. Суть: приложение неверно обрабатывает ошибки и необычные условия — отдаёт стек-трейс наружу, «отказывает открыто» (при сбое доступ разрешается), глотает security-исключения пустым catch, выводит display_errors на проде.

Плохо — сырая ошибка наружу:

PHP
try {
    $row = $pdo->query("SELECT * FROM orders WHERE id = $id")->fetch();
} catch (Throwable $e) {
    echo $e->getMessage();   // ← пути к файлам, SQL и версии ПО уходят клиенту
}

Хорошо — подробно логируем, наружу нейтрально (fail closed):

PHP
try {
    $stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?');
    $stmt->execute([(int) $id]);
    $row = $stmt->fetch();
} catch (PDOException $e) {
    error_log(sprintf('[%s] %s in %s:%d', get_class($e), $e->getMessage(), $e->getFile(), $e->getLine()));
    http_response_code(500);
    exit('Произошла ошибка. Повторите позже.');   // без деталей для клиента
}

Отдельный класс ошибки — «fail open» при сбое: пустой catch или дефолт на ошибку выдаёт лишние права:

PHP
// ПЛОХО: сбой БД = пользователь получил «admin»
$roles = @fetchRoles($user) ?: ['admin'];

// ХОРОШО: сбой = отказ (пусто), дальше проверка прав сама запретит доступ
try {
    $roles = fetchRoles($user);
} catch (Throwable $e) {
    error_log('role fetch failed: ' . $e->getMessage());
    $roles = [];
}

Защита:

  • Обрабатывайте исключения осознанно, никогда не «проглатывайте» их молча.
  • Проектируйте отказ по умолчанию (fail closed): при сбое — запрет, а не допуск.
  • На проде display_errors = Off, log_errors = On — стек не должен дублироваться в HTML (связь с A02).
  • Возвращайте клиенту обезличенные сообщения, подробности — только в логи.

Бонус: SSRF — почему его убрали из Top-10 и как защищаться

Server-Side Request Forgery в 2025 не занимает отдельную строку — риск включён в A01 (несанкционированный доступ к внутренним ресурсам). Но техника осталась крайне частой, поэтому — отдельный разбор.

Пример: загрузка изображения по пользовательскому URL.

PHP
// ТАК НЕЛЬЗЯ
$url   = $_GET['url'];
$image = file_get_contents($url);
file_put_contents('/tmp/image.jpg', $image);

Злоумышленник укажет http://localhost/admin или http://169.254.169.254/latest/meta-data/ (метаданные облака) и получит доступ к внутренним сервисам.

Почему «проверка хоста по белому списку» недостаточна. Типичный сниппет защищается так:

PHP
$parsed = parse_url($url);
if (!in_array($parsed['host'], ['example.com'])) { exit('Запрещённый хост'); }

Но это обходится: редирект с разрешённого хоста (Location: http://169.254.169.254/) уходит «внутрь», IP-литерал и DNS-rebinding дают внутренний адрес под «безопасным» именем, а file_get_contents по умолчанию следует редиректам. Нужна проверка реально разрешённого адреса и запрет редиректов.

Корректная защита:

PHP
function safe_remote_url_check(string $url, array $allowedHosts): bool {
    $p = parse_url($url);
    if (!$p || empty($p['scheme']) || empty($p['host'])) return false;
    if (!in_array(strtolower($p['scheme']), ['http', 'https'], true)) return false; // не file://, gopher://

    $host = strtolower($p['host']);
    if (!in_array($host, $allowedHosts, true)) return false;   // жёсткий allow-list имён

    // Резолвим имя и отсекаем приватные/зарезервированные адреса:
    // localhost, 169.254.169.254 (метаданные), 10/8, 127/8, ::1 и DNS-rebinding.
    $ips = [];
    foreach (@dns_get_record($host, DNS_A | DNS_AAAA) ?: [] as $r) {
        if (!empty($r['ip']))   $ips[] = $r['ip'];
        if (!empty($r['ipv6'])) $ips[] = $r['ipv6'];
    }
    if (!$ips && ($ip = gethostbyname($host)) !== $host) $ips[] = $ip;
    if (!$ips) return false;                                   // имя не разрешилось — отказ
    foreach ($ips as $ip) {
        $public = filter_var($ip, FILTER_VALIDATE_IP,
                    FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)
              ?: filter_var($ip, FILTER_VALIDATE_IP,
                    FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
        if (!$public) return false;                            // хоть один адрес приватный — отказ
    }
    return true;
}

$url = $_GET['url'] ?? '';
if (!safe_remote_url_check($url, ['example.com', 'cdn.example.com'])) {
    http_response_code(400);
    exit('Разрешённый источник недоступен');
}

// Загрузка: без слепого следования редиректам, с таймаутом и лимитом размера
$ctx = stream_context_create(['http' => [
    'follow_location' => 0,     // редирект на внутренний хост не пройдёт повторную проверку
    'timeout'         => 5,
    'max_redirects'   => 0,
]]);
$image = @file_get_contents($url, false, $ctx, 0, 5 * 1024 * 1024); // ≤ 5 МБ
if ($image === false || strlen($image) === 0) {
    http_response_code(502);
    exit('Не удалось получить файл');
}
// Имя файла НЕ берём от пользователя — генерируем своё
$saved = '/tmp/' . bin2hex(random_bytes(8)) . '.img';
file_put_contents($saved, $image);

WordPress-альтернатива одной строкой (делает часть guard’ов сама — валидирует схему, отсекает приватные IP, блокирует внешние хосты): wp_safe_remote_get($url, ['timeout' => 5, 'redirection' => 0]) при включённых WP_HTTP_BLOCK_EXTERNAL и allow-list в WP_ACCESSIBLE_HOSTS.


Актуальные версии PHP (на октябрь 2026)

«Устаревшие компоненты» из A03/A06 — в первую очередь про сам интерпретатор. Держите PHP в поддерживаемой ветке:

ВеткаСтатус (окт. 2026)
PHP 8.5актуальная (релиз ноябрь 2025) — лучший выбор для нового
PHP 8.4активная поддержка до конца 2026, безопасности — до конца 2028
PHP 8.3только безопасность до конца 2027
PHP 8.2безопасность заканчивается 31.12.2026 — пора уходить
PHP 8.1 и нижеEOL — без обновлений безопасности

Минимум для продакшена сегодня — 8.3, оптимально — 8.4/8.5. Цикл поддержки PHP: 2 года активных исправлений + 2 года критических правок безопасности.


Чек-лист для самопроверки

  • Права доступа проверяются на сервере на каждом запросе; ввод приведён к нужному типу (A01).
  • Нет внешних редиректов к внутренним адресам; исходящие запросы — по allow-list с проверкой реального IP (A01/SSRF).
  • Конфигурация проде: display_errors = Off, дефолтные аккаунты удалены, лишние порты закрыты, заголовки безопасности проставлены (A02).
  • Зависимости зафиксированы (lock-файлы), проходит composer audit/npm audit, артефакты проверяются на подпись (A03).
  • Пароли — только password_hash()/password_verify(); HTTPS везде (A04).
  • Все обращения к БД — prepared statements; вывод контекстно экранируется; аргументы команд — через escapeshellarg() (A05).
  • Безопасность продумана на этапе дизайна: токены сброса, лимит попыток, fail closed (A06).
  • Сессии защищены (HttpOnly, Secure, SameSite), ID регенерируется после входа, есть MFA (A07).
  • Нет unserialize() на пользовательских данных; целостность обновлений проверяется (A08).
  • Логируются security-события, ввод в лог санитизирован, настроены оповещения (A09).
  • Исключения обрабатываются осознанно, наружу уходят обезличенные сообщения, при сбое — отказ, а не допуск (A10).

Инструмент под задачу

Разбор .htaccess/nginx-правил на предмет «что реально блокирует, а что создаёт иллюзию защиты» — по тем же A05/A10, что прошли выше. Попробуйте инструмент прямо здесь, на своих данных:

AI Htaccess Analyzer
Гость
Токены: 0 / 50 000
Гостевой лимит — 50 000 токенов. Зарегистрируйтесь — для пользователей лимит + 200 000 токенов.
0 / 20 000 символов

Результат анализа
Вставьте .htaccess слева и нажмите «Анализировать».
ИИ найдёт уязвимости и предложит исправления.

Источники

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.