DEV Community

Stefan
Stefan

Posted on

Auditing PHP Code for Hidden Security Flaws

Imagine you are reviewing an API endpoint that checks a shared secret:

$payload = json_decode(file_get_contents('php://input'));

if ($payload->token == 'SECRET_API_TOKEN') {
    $session->grantAdmin();
}
Enter fullscreen mode Exit fullscreen mode

At first glance, this looks like normal authentication code. But because it uses the loose == operator, an attacker who sends {"token": true} will pass the check. In PHP, any non-empty string other than '0' loosely evaluates to boolean true Zend Engine type rules. The caller gains administrative access without ever knowing the secret.

Spotting vulnerabilities in a PHP codebase requires understanding how the engine handles data types, superglobals, and input streams.

Type Juggling Across PHP Versions

Loose comparisons have caused authentication bypasses for years. Developers often assume that upgrading to PHP 8 eliminates every comparison flaw, but that is only partly true.

In PHP 7.x, comparing a non-numeric string to zero evaluated to true type juggling notes. That meant "admin" == 0 succeeded. PHP 8.0 changed this behaviour by casting the number to a string instead PHP 8.0 migration guide. Under PHP 8, "admin" == 0 evaluates to false.

Boolean type juggling remains unchanged across PHP versions Laravel comparison notes. If your code checks a string against user input with ==, a boolean value in JSON will still match.

Magic hashes also persist in modern runtimes. When two strings formatted in scientific notation are compared loosely, the engine treats both as float 0.0 and returns true magic hash behavior. However, this PHP 8 behaviour strictly requires both strings to consist exclusively of 0e followed solely by digits. If a digest contains letters other than the exponent e, PHP 8 classifies it as a non-numeric string and the comparison fails.

To prevent comparison bypasses, always verify that security checks use strict comparison (===) or constant-time comparison:

if (!is_string($payload->token) || !hash_equals('SECRET_API_TOKEN', $payload->token)) {
    throw new \InvalidArgumentException('Unauthorized');
}
Enter fullscreen mode Exit fullscreen mode

You can study more comparison traps and input validation flaws in the CodeReviewLab learning hub.

Where Untrusted Input Starts

Every audit requires tracing data from input boundaries to dangerous sinks. In PHP, inputs arrive through superglobals such as $_GET, $_POST, and $_COOKIE.

A frequent review finding is the use of $_REQUEST. The superglobal $_REQUEST combines GET, POST, and COOKIE data based on the engine's request_order setting PHP superglobals. If a handler expects a token in a POST body but inspects $_REQUEST, an attacker can often supply that value in a GET query string instead.

File uploads present a similar pitfall. The values in $_FILES['upload']['name'] and $_FILES['upload']['type'] come directly from client HTTP request headers Safeguard upload notes. They do not reflect server-side MIME detection. Treat both values as untrusted strings until verified by an extension allowlist and a server-side inspection tool such as finfo.

Command Execution and Argument Injection

When code runs operating system commands via exec(), shell_exec(), or system(), developers often try to sanitise parameters using escapeshellcmd().

The escapeshellcmd() function escapes shell metacharacters like pipes and semicolons, but it leaves spaces and command switches untouched PHP escapeshellcmd manual. If untrusted input represents command flags rather than a fixed argument, an attacker can pass arbitrary switches.

// Insecure: escapeshellcmd allows space-separated parameter switches
$extraOptions = escapeshellcmd($_GET['options']);
system("tar -czf /backups/archive.tar.gz {$extraOptions} /var/data");
Enter fullscreen mode Exit fullscreen mode

In this example, an attacker can supply --checkpoint=1 --checkpoint-action=exec=sh to achieve arbitrary code execution.

Passing an array of arguments to proc_open() bypasses the system shell, which prevents shell metacharacter injection proc_open documentation. However, proc_open() does not prevent argument injection if the binary receives user strings starting with dashes.

To handle dynamic command arguments safely, enforce a strict allowlist of permitted options or delimit positional file targets with --:

// Safe: strict allowlist for options and positional delimiters
$allowedFormats = ['gzip' => '-z', 'bzip2' => '-j'];
$formatFlag = $allowedFormats[$_GET['format'] ?? ''] ?? '-z';
$userFile = basename($_GET['file']);

$descriptors = [
    0 => ['pipe', 'r'],
    1 => ['pipe', 'w'],
    2 => ['pipe', 'w'],
];

// The '--' tells tar that subsequent arguments are file paths, not flags
$process = proc_open(
    ['tar', '-c', $formatFlag, '-f', '/backups/archive.tar', '--', $userFile],
    $descriptors,
    $pipes
);
Enter fullscreen mode Exit fullscreen mode

Whenever possible, avoid invoking shell binaries entirely and use native PHP extensions like PharData or ZipArchive.

Stream Wrappers and File Sinks

PHP file functions like include, require, and file_get_contents() process custom URL schemes called stream wrappers.

If user-controlled input reaches a file read function, an attacker can prefix the path with php://filter. For example, an attacker can pass php://filter/convert.base64-encode/resource=config.php to dump application credentials without executing PHP tags SonarSource filter analysis.

Always validate user-provided file keys against an allowlist before passing them to file functions. Reject any input containing protocol prefixes or path traversal sequences.

Variable Scope Poisoning

Another pattern to watch for is dynamic variable extraction. Calling extract($_POST) or extract($_GET) imports request variables directly into the current symbol table variable scope extraction.

This behaviour lets an external request overwrite existing variables, including flags such as $isAdmin or database connection parameters. Flag any unconstrained call to extract() as a high-severity finding.

If you are auditing legacy PHP 7 codebases, watch out for single-argument parse_str(), which behaved similarly. In PHP 8.0, calling parse_str() without the second result array parameter was removed and throws an ArgumentCountError PHP 8 changelog.

How to Verify Findings

When you suspect a type juggling or validation flaw during an audit, confirm the mechanism with a focused test before requesting a fix:

public function testAuthRejectsBooleanAndArrayTokens(): void
{
    $auth = new TokenAuthenticator();
    $this->assertFalse($auth->validate(true));
    $this->assertFalse($auth->validate(0));
    $this->assertFalse($auth->validate([]));
}
Enter fullscreen mode Exit fullscreen mode

If you want to practice finding vulnerabilities across real application snippets, test your skills on CodeReviewLab.

What is the most subtle PHP runtime quirk you have encountered during a code audit?

Top comments (0)