MidПрактика36 min

OWASP Top 10

Все 10 категорий уязвимостей OWASP с PHP-примерами уязвимого и защищённого кода

Что такое OWASP Top 10

OWASP Top 10 — список десяти наиболее критичных категорий уязвимостей веб-приложений. Обновляется каждые 3-4 года на основе данных из реальных приложений.

A01: Broken Access Control

Нарушение контроля доступа — пользователь может выполнять действия за пределами своих прав.

<?php

declare(strict_types=1);

// VULNERABLE: Direct Object Reference without authorization check
final class OrderControllerVulnerable
{
    public function show(int $orderId): array
    {
        // Any authenticated user can view ANY order
        return $this->repository->findById($orderId);
    }
}

// SECURE: Check ownership before returning data
final readonly class OrderControllerSecure
{
    public function __construct(
        private OrderRepository $repository,
        private SecurityContext $security,
    ) {}

    public function show(int $orderId): array
    {
        $order = $this->repository->findById($orderId);

        if ($order === null) {
            // Handle "missing" before "not yours": dereferencing null here
            // would surface a stack trace instead of a clean 404
            throw new NotFoundException('Order not found.');
        }

        $currentUser = $this->security->getCurrentUser();

        if ($order->getUserId() !== $currentUser->getId()
            && !$currentUser->hasRole('ROLE_ADMIN')
        ) {
            throw new AccessDeniedException('You cannot view this order.');
        }

        return $order->toArray();
    }
}
## A02: Cryptographic Failures

Неправильное использование криптографии или её отсутствие.

<?php

declare(strict_types=1);

// VULNERABLE: Weak hashing, sensitive data in plain text
final class UserServiceVulnerable
{
    public function register(string $email, string $password): void
    {
        $hash = md5($password); // MD5 is broken
        // Storing credit card without encryption
        $this->db->insert('users', [
            'email' => $email,
            'password' => $hash,
        ]);
    }
}

// SECURE: Strong hashing, proper encryption
final readonly class UserServiceSecure
{
    public function __construct(
        private Connection $db,
        private EncryptionService $encryption,
    ) {}

    public function register(string $email, string $password): void
    {
        $hash = password_hash($password, PASSWORD_ARGON2ID);

        $this->db->insert('users', [
            'email' => $email,
            'password' => $hash,
        ]);
    }

    public function storeSensitiveData(int $userId, string $ssn): void
    {
        // Encrypt sensitive data at rest
        $encrypted = $this->encryption->encrypt($ssn);

        $this->db->update('users', ['ssn_encrypted' => $encrypted], ['id' => $userId]);
    }
}
## A03: Injection

Внедрение вредоносного кода через пользовательский ввод.

SQL Injection

<?php

declare(strict_types=1);

// VULNERABLE: String concatenation in SQL
final class SearchVulnerable
{
    public function findUsers(string $name): array
    {
        // Attacker can input: ' OR '1'='1
        $sql = "SELECT * FROM users WHERE name = '{$name}'";

        return $this->db->query($sql)->fetchAll();
    }
}

// SECURE: Parameterized queries
final readonly class SearchSecure
{
    public function __construct(
        private Connection $db,
    ) {}

    public function findUsers(string $name): array
    {
        return $this->db->fetchAllAssociative(
            'SELECT id, name, email FROM users WHERE name = :name',
            ['name' => $name],
        );
    }
}
### Command Injection
<?php

declare(strict_types=1);

// VULNERABLE: Unsanitized input in shell command
function resizeImageVulnerable(string $filename, int $width): void
{
    // Attacker: filename = "img.jpg; rm -rf /"
    exec("convert {$filename} -resize {$width}x output.jpg");
}

// SECURE: Validate input, then pass arguments as an array — no shell involved
function resizeImageSecure(string $filename, int $width): void
{
    // Allow-list the filename: anything outside this pattern is rejected
    if (!preg_match('/^[a-zA-Z0-9._-]+\.(jpg|png|gif|webp)$/i', $filename)) {
        throw new \InvalidArgumentException('Invalid filename');
    }

    // An array command makes proc_open bypass the shell entirely, so ";",
    // "$(...)" and friends are passed through as literal argument text.
    // escapeshellarg + a string command still runs a shell, and one missed
    // escape is enough to lose the machine
    $process = proc_open(
        ['convert', $filename, '-resize', "{$width}x", 'output.jpg'],
        [],
        $pipes,
    );

    if ($process === false) {
        throw new \RuntimeException('Failed to start convert');
    }

    $exitCode = proc_close($process);

    if ($exitCode !== 0) {
        throw new \RuntimeException("convert exited with code {$exitCode}");
    }
}
## A04: Insecure Design

Недостатки в архитектуре, которые нельзя исправить одной реализацией.

<?php

declare(strict_types=1);

// INSECURE DESIGN: No rate limiting on password reset
final class PasswordResetVulnerable
{
    public function requestReset(string $email): void
    {
        $code = random_int(1000, 9999); // 4-digit code — brute-forceable
        $this->sendResetCode($email, $code);
    }
}

// SECURE DESIGN: Rate limiting + strong token
final readonly class PasswordResetSecure
{
    public function __construct(
        private RateLimiter $limiter,
        private TokenGenerator $tokens,
        private Mailer $mailer,
    ) {}

    public function requestReset(string $email): void
    {
        // Rate limit: max 3 requests per hour per email
        if (!$this->limiter->attempt("password_reset:{$email}", maxAttempts: 3, perSeconds: 3600)) {
            throw new TooManyRequestsException('Too many reset requests. Try again later.');
        }

        // Generate a long, unguessable token (not a 4-digit PIN)
        $token = $this->tokens->generate(length: 32);

        $this->storeToken($email, $token, expiresInMinutes: 30);
        $this->mailer->sendResetLink($email, $token);
    }
}
## A05: Security Misconfiguration

Неправильные настройки безопасности серверов, фреймворков, приложений.

<?php

declare(strict_types=1);

// VULNERABLE: Debug mode in production, verbose errors
// .env: APP_DEBUG=true, APP_ENV=dev

// SECURE: Production hardening checklist
final readonly class SecurityConfigChecker
{
    /**
     * @return array<string, array{status: string, message: string}>
     */
    public function check(): array
    {
        $checks = [];

        // Debug mode. A missing variable must never read as "secure",
        // so the default is the unsafe value and the check fails closed
        $checks['debug_mode'] = [
            'status' => ($_ENV['APP_DEBUG'] ?? 'true') === 'false' ? 'pass' : 'fail',
            'message' => 'Debug mode must be disabled in production',
        ];

        // Error display
        $checks['display_errors'] = [
            'status' => ini_get('display_errors') === '0' ? 'pass' : 'fail',
            'message' => 'display_errors must be Off',
        ];

        // Expose PHP
        $checks['expose_php'] = [
            'status' => ini_get('expose_php') === '0' ? 'pass' : 'fail',
            'message' => 'expose_php must be Off (hides X-Powered-By header)',
        ];

        // Session security
        $checks['session_cookie_secure'] = [
            'status' => ini_get('session.cookie_secure') === '1' ? 'pass' : 'fail',
            'message' => 'session.cookie_secure must be On for HTTPS',
        ];

        $checks['session_cookie_httponly'] = [
            'status' => ini_get('session.cookie_httponly') === '1' ? 'pass' : 'fail',
            'message' => 'session.cookie_httponly must be On',
        ];

        $checks['session_cookie_samesite'] = [
            'status' => ini_get('session.cookie_samesite') === 'Lax' ? 'pass' : 'fail',
            'message' => 'session.cookie_samesite should be Lax or Strict',
        ];

        return $checks;
    }
}
## A06: Vulnerable and Outdated Components

Использование компонентов с известными уязвимостями.

<?php

declare(strict_types=1);

// Check: composer audit (built-in since Composer 2.4)
// Check: Symfony Security Checker

/**
 * Dependency audit report generator.
 */
final readonly class DependencyAuditor
{
    /**
     * Parse composer audit output and categorize by severity.
     *
     * @param array<array{advisoryId: string, packageName: string, severity: string}> $advisories
     * @return array{critical: int, high: int, medium: int, low: int, packages: array<string>}
     */
    public function categorize(array $advisories): array
    {
        $result = ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0, 'packages' => []];

        foreach ($advisories as $advisory) {
            $severity = match (strtolower($advisory['severity'])) {
                'critical' => 'critical',
                'high' => 'high',
                'medium' => 'medium',
                'low' => 'low',
                // An unrecognised severity is escalated, never silently
                // dropped into a new key nobody reads
                default => 'critical',
            };

            ++$result[$severity];
            $result['packages'][] = $advisory['packageName'];
        }

        $result['packages'] = array_unique($result['packages']);

        return $result;
    }
}
## A07: Identification and Authentication Failures

Проблемы аутентификации: слабые пароли, неправильное управление сессиями.

<?php

declare(strict_types=1);

// SECURE: Proper authentication with brute-force protection
final readonly class AuthService
{
    public function __construct(
        private UserRepository $users,
        private RateLimiter $limiter,
        private SessionManager $sessions,
    ) {}

    public function login(string $email, string $password, string $ip): AuthResult
    {
        // Rate limiting by IP and email
        $ipKey = "login_ip:{$ip}";
        $emailKey = "login_email:{$email}";

        if (!$this->limiter->attempt($ipKey, maxAttempts: 20, perSeconds: 900)
            || !$this->limiter->attempt($emailKey, maxAttempts: 5, perSeconds: 900)
        ) {
            return AuthResult::rateLimited();
        }

        $user = $this->users->findByEmail($email);
        $storedHash = $user?->getPasswordHash() ?? self::dummyHash();

        // Always run the verifier, even for an unknown email. Skipping it
        // would make missing accounts answer measurably faster, which is a
        // free user-enumeration oracle
        $passwordValid = password_verify($password, $storedHash);

        if ($user === null || !$passwordValid) {
            // Don't reveal whether email exists
            return AuthResult::failed('Invalid credentials.');
        }

        if (!$user->isActive()) {
            return AuthResult::failed('Account is deactivated.');
        }

        // Regenerate session ID to prevent fixation
        $this->sessions->regenerate();

        // Reset rate limiters on success
        $this->limiter->reset($ipKey);
        $this->limiter->reset($emailKey);

        return AuthResult::success($user);
    }

    /**
     * Hash of a fixed dummy password, computed once per process and verified
     * against when the account does not exist, to equalize response time.
     */
    private static function dummyHash(): string
    {
        static $hash = null;

        return $hash ??= password_hash(
            'dummy-password-for-timing-equalization',
            PASSWORD_ARGON2ID,
        );
    }
}
## A08: Software and Data Integrity Failures

Нарушение целостности данных — непроверенные обновления, десериализация.

<?php

declare(strict_types=1);

// VULNERABLE: Unsafe deserialization
$data = unserialize($_POST['data']); // Remote Code Execution risk!

// SECURE: Use JSON instead of serialize
final readonly class SafeDeserializer
{
    /**
     * Safely decode user-provided JSON data.
     *
     * @return array<string, mixed>
     * @throws \JsonException
     */
    public function decode(string $json): array
    {
        // JSON is safe — no object instantiation
        $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);

        if (!is_array($data)) {
            throw new \InvalidArgumentException('Expected JSON object');
        }

        return $data;
    }

    /**
     * Verify webhook signature to ensure data integrity.
     */
    public function verifyWebhookSignature(
        string $payload,
        string $signature,
        string $secret,
    ): bool {
        $expected = hash_hmac('sha256', $payload, $secret);

        return hash_equals($expected, $signature);
    }
}
## A09: Security Logging and Monitoring Failures

Недостаточное логирование событий безопасности.

<?php

declare(strict_types=1);

namespace App\Security;

use Psr\Log\LoggerInterface;

final readonly class SecurityAuditLogger
{
    public function __construct(
        private LoggerInterface $logger,
    ) {}

    public function logLoginAttempt(string $email, bool $success, string $ip): void
    {
        $this->logger->info('Authentication attempt', [
            'event' => 'auth.login',
            'email' => $email,
            'success' => $success,
            'ip' => $ip,
            'timestamp' => (new \DateTimeImmutable())->format(\DATE_ATOM),
        ]);
    }

    public function logAccessDenied(string $userId, string $resource, string $action): void
    {
        $this->logger->warning('Access denied', [
            'event' => 'auth.access_denied',
            'user_id' => $userId,
            'resource' => $resource,
            'action' => $action,
        ]);
    }

    /**
     * @param array<string, mixed> $context Must not carry secrets or raw PII —
     *                                      audit logs outlive application logs
     */
    public function logSuspiciousActivity(string $description, array $context = []): void
    {
        $this->logger->error('Suspicious activity detected', [
            'event' => 'security.suspicious',
            'description' => $description,
            ...$context,
        ]);
    }

    public function logDataAccess(string $userId, string $dataType, string $action): void
    {
        $this->logger->info('Sensitive data access', [
            'event' => 'audit.data_access',
            'user_id' => $userId,
            'data_type' => $dataType,
            'action' => $action,
        ]);
    }
}
## A10: Server-Side Request Forgery (SSRF)

Сервер выполняет HTTP-запрос на URL, контролируемый злоумышленником.

<?php

declare(strict_types=1);

// VULNERABLE: Fetch arbitrary URL
function fetchUrlVulnerable(string $url): string
{
    // Attacker can access internal services: http://169.254.169.254/metadata
    return file_get_contents($url);
}

// SECURE: Validate and restrict URLs
final readonly class SafeHttpClient
{
    private const ALLOWED_SCHEMES = ['https'];

    /** @var array<string> */
    private const BLOCKED_IP_RANGES = [
        '10.0.0.0/8',
        '172.16.0.0/12',
        '192.168.0.0/16',
        '127.0.0.0/8',
        '169.254.0.0/16',  // AWS metadata
        '0.0.0.0/8',
    ];

    /**
     * Fetch URL with SSRF protection.
     */
    public function fetch(string $url): string
    {
        $parsed = parse_url($url);

        if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) {
            throw new \InvalidArgumentException('Invalid URL');
        }

        // Check scheme
        if (!in_array(strtolower($parsed['scheme']), self::ALLOWED_SCHEMES, true)) {
            throw new \InvalidArgumentException('Only HTTPS URLs are allowed');
        }

        // Resolve the hostname and check EVERY address. gethostbyname returns
        // only the first record, so a host that also resolves to an internal
        // IP would slip straight through
        $ips = gethostbynamel($parsed['host']);

        if ($ips === false || $ips === []) {
            throw new \InvalidArgumentException('Could not resolve host');
        }

        foreach ($ips as $ip) {
            if ($this->isBlockedIp($ip)) {
                throw new \InvalidArgumentException('Access to internal networks is forbidden');
            }
        }

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 10,
            CURLOPT_FOLLOWLOCATION => false,  // Don't follow redirects (SSRF bypass)
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
        ]);

        $result = curl_exec($ch);
        $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($result === false) {
            throw new \RuntimeException('Request failed');
        }

        if ($statusCode !== 200) {
            throw new \RuntimeException("HTTP {$statusCode}");
        }

        return $result;
    }

    private function isBlockedIp(string $ip): bool
    {
        // Covers IPv6 as well: ::1 and fe80::/10 are internal too, and the
        // IPv4 CIDR list below can never match them
        $public = filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
        );

        if ($public === false) {
            return true;
        }

        $ipLong = ip2long($ip);

        if ($ipLong === false) {
            return false; // Non-IPv4 address that already passed the filter
        }

        foreach (self::BLOCKED_IP_RANGES as $range) {
            [$subnet, $mask] = explode('/', $range);
            $subnetLong = ip2long($subnet);
            $maskLong = ~((1 << (32 - (int) $mask)) - 1);

            if (($ipLong & $maskLong) === ($subnetLong & $maskLong)) {
                return true;
            }
        }

        return false;
    }
}
## Итоги
# Категория Ключевая защита
A01 Broken Access Control Проверка прав на каждый ресурс
A02 Cryptographic Failures Argon2id, AES-256, TLS 1.3
A03 Injection Parameterized queries, escaping
A04 Insecure Design Threat modeling, rate limiting
A05 Security Misconfiguration Hardening checklist, no debug in prod
A06 Vulnerable Components composer audit, автоматические обновления
A07 Auth Failures Brute-force protection, session management
A08 Integrity Failures JSON вместо unserialize, HMAC
A09 Logging Failures Audit log для всех security-событий
A10 SSRF Whitelist URLs, block internal IPs