GraphNode
Back to all guides
SAST

Static Analysis for PHP: Common Vulnerability Patterns and How to Catch Them

| 10 min read |GraphNode Research

In March 2019 the Magento security team shipped SUPEE-11086, an emergency patch bundle for a pre-authentication SQL injection in the e-commerce platform's catalog filtering endpoint. The vulnerable code lived in a controller that took an attacker-supplied product filter, concatenated it into a raw SQL fragment, and passed the fragment to the database adapter without parameter binding. Within days of the patch, public proof-of-concept exploits weaponized the bug into a credential-dumping primitive that walked the admin user table and lifted password hashes off any unpatched store. The flaw was a textbook string-concatenation SQL injection in a codebase that had every reason to know better, shipped by one of the most-deployed PHP applications on the internet, and it followed the same pattern as Drupalgeddon two (CVE-2014-3704) five years earlier — an unprepared statement in a framework helper that everyone trusted to be safe. The reason these bugs persist in PHP is the same reason they persist in every dynamic language with a long history: the dangerous shape is one keystroke away from the safe shape and looks identical at code review.

PHP carries the unique burden of running a large share of the public web through WordPress, with a plugin ecosystem of more than sixty thousand extensions whose long tail receives security-advisory traffic on a near-daily basis. Wordfence, Patchstack, and the WPScan database collectively publish hundreds of WordPress plugin CVEs every month — XSS in unescaped admin pages, SQL injection in custom database queries, broken access control on AJAX handlers. Laravel and Symfony are far less prone to the same classes because their query builders and templating engines are safe by default, but the moment an engineer drops down to DB::raw or echoes a variable without htmlspecialchars, the dangerous shape returns. This guide walks the bug classes that PHP static analysis catches reliably and what vulnerable and fixed code looks like.

The PHP Vulnerability Landscape

SQL injection lives under mysqli_query, PDO::query, and $wpdb->query calls that interpolate request data into the SQL string instead of binding parameters; despite a decade of prepared-statement availability, raw concatenation against $_GET, $_POST, and $_REQUEST remains the most common high-severity finding on PHP code reviews. Cross-site scripting concentrates on echo and short-tag <?= output of request data without htmlspecialchars — Blade's autoescapes, but {!! !!} and Twig's |raw filter both opt out and ship the same XSS primitive.

Local and remote file inclusion live under include, require, and their _once variants with an attacker-controllable path argument; the LFI variant turns ?page=../../../../etc/passwd into a disclosure or, paired with a log-poisoning trick, into RCE. Command injection collects under system, exec, shell_exec, passthru, proc_open, and the backtick operator — every variant invokes /bin/sh and splits on shell metacharacters. PHP object injection sits under unserialize on attacker-controllable data (CWE-502) and pairs with class-loaded gadget chains to ship arbitrary code execution; WordPress, Magento, and Joomla have all shipped headline-grade unserialize bugs in the last decade. Server-side request forgery hides under curl_exec and file_get_contents on user-supplied URLs. Weak cryptography appears as md5 and sha1 for password storage in legacy code that pre-dates password_hash; hardcoded credentials show up in wp-config.php and PHP files that assign API keys directly to constants.

SQL Injection: String Concatenation in a Prepared-Statement World

The most common shape is a developer who reaches for the simplest API call available and concatenates the request value directly into the SQL fragment, often inside a custom WordPress AJAX handler or a Laravel controller that escaped the query builder for what looked like a quick lookup:

<?php
// VULNERABLE
function get_user_orders(mysqli $db) {
    $user_id = $_GET['user_id'];
    $sql = "SELECT id, total, status FROM orders WHERE user_id = " . $user_id;
    $result = mysqli_query($db, $sql);
    return mysqli_fetch_all($result, MYSQLI_ASSOC);
}

A request carrying ?user_id=1 UNION SELECT user_login, user_pass, 0 FROM wp_users walks the password hash table out of the database. The fix is the prepared statement that mysqli has shipped since PHP 5.0 and that the language has documented as the correct approach for fifteen years:

<?php
// FIXED
function get_user_orders(mysqli $db) {
    $user_id = filter_input(INPUT_GET, 'user_id', FILTER_VALIDATE_INT);
    if ($user_id === false || $user_id === null) {
        http_response_code(400);
        return [];
    }
    $stmt = $db->prepare(
        "SELECT id, total, status FROM orders WHERE user_id = ?"
    );
    $stmt->bind_param('i', $user_id);
    $stmt->execute();
    return $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
}

The bind call separates the SQL grammar from the data values, which is the entire point of the prepared-statement abstraction. The same pattern applies to PDO::prepare with named or positional placeholders, and to $wpdb->prepare in WordPress code, where the helper takes a sprintf-style format string and quotes each substituted argument. SAST tools flag the concatenation cleanly because the dataflow from $_GET to mysqli_query is short and the dangerous sink is named explicitly.

File Inclusion: The Dynamic Include That Ships Local File Read

Older PHP applications and a depressing number of contemporary plugin codebases route page rendering through a single front controller that includes a template file based on a request parameter. The shape is recognizable in any codebase old enough to remember register_globals:

<?php
// VULNERABLE
$page = $_GET['page'] ?? 'home';
include __DIR__ . '/templates/' . $page . '.php';

A request of ?page=../../../../etc/passwd%00 on PHP versions that honored the null-byte truncation read the password file; on modern PHP the same payload without the null byte still reads any file with a .php extension on the filesystem, including wp-config.php with the database credentials. The fix is a whitelist of permitted page names and a hard refusal to include anything that does not match:

<?php
// FIXED
$allowed_pages = ['home', 'about', 'contact', 'pricing'];
$page = $_GET['page'] ?? 'home';
if (!in_array($page, $allowed_pages, true)) {
    http_response_code(404);
    exit;
}
include __DIR__ . '/templates/' . $page . '.php';

The whitelist is the only durable defense; basename, realpath, and string-replacement filters on the path have all been bypassed at one point or another by encoding tricks, alternate path separators, and PHP stream wrappers like php://filter. SAST tools flag the unsafe include the moment they see request data reaching an include or require statement; the false-positive rate is low because the dangerous sink is unambiguous.

Unserialize on User Data: PHP Object Injection

The unserialize function deserializes a PHP value from its string representation and, in the process, instantiates any class named in the payload and invokes magic methods like __wakeup and __destruct on the resulting object. When an attacker controls the input, they pick which classes the application instantiates and which destructor side effects fire, and any class in the loaded codebase that does something interesting in a magic method becomes a gadget:

<?php
// VULNERABLE
function restore_session(): void {
    if (isset($_COOKIE['session_state'])) {
        $state = unserialize(base64_decode($_COOKIE['session_state']));
        apply_session_state($state);
    }
}

A crafted cookie payload that names a logger class with a __destruct that writes attacker-supplied content to an attacker-supplied path turns a session-restore handler into an arbitrary file write. The fix is the same fix as in every other language with a serialization sink — switch to a structured format that does not instantiate code, and validate the structure on the application side:

<?php
// FIXED
function restore_session(): void {
    if (!isset($_COOKIE['session_state'])) {
        return;
    }
    $state = json_decode($_COOKIE['session_state'], true, 16, JSON_THROW_ON_ERROR);
    if (!is_array($state) || !isset($state['version'])) {
        return;
    }
    apply_session_state($state);
}

json_decode with the associative-array flag returns plain arrays and scalars and never instantiates a class, which closes the gadget surface entirely. Codebases that genuinely need PHP-native serialization can use unserialize with the allowed_classes option introduced in PHP 7.0 to restrict the deserializer to a specific set of safe classes, but the JSON path is the cleaner default. SAST tools flag unserialize on request data, cookies, and database fields populated from request data with high confidence because the dangerous sink is finite and the dataflow is direct.

Detection: Phan, Psalm, PHPStan, and Where SAST Earns Its Keep

The PHP ecosystem ships three serious open-source static analyzers — Phan, Psalm, and PHPStan — all worth wiring into pre-commit on day one. Their primary mission is type checking and dead-code analysis. Psalm and PHPStan both ship security-oriented plugins that flag a subset of the dangerous APIs, but the depth of the security analysis is not their core focus. The closest dedicated taint-tracking equivalent is progpilot, which is solid but covers a smaller rule surface than the commercial alternatives.

Commercial SAST tools — GraphNode, Checkmarx, Fortify, Semgrep with PHP rule packs — extend the analysis with inter-procedural data flow that traces a request parameter through controllers, models, helper traits, and template includes to the dangerous sink. WordPress-specific scanners like WPScan focus on known-CVE matching against the installed plugin and theme inventory, which is complementary to source-level SAST rather than a substitute; a custom plugin written in-house has no CVE and only source analysis will find its bugs. PHP's permissive runtime, magic methods, and global state make conservative analysis produce either false positives or false negatives on heavily metaprogrammed code; declare(strict_types=1) and full property typing tighten the analyzer's conclusions noticeably.

Where GraphNode SAST Fits for PHP

GraphNode SAST ships first-class PHP support as one of the 13+ languages it analyzes, with inter-procedural data flow that traces taint from Laravel, Symfony, WordPress, and raw $_GET/$_POST entry points to the SQL, file-inclusion, command-injection, unserialize, and template-XSS sinks. Findings appear on the diff that introduced the unsafe call, with WordPress and Laravel framework-aware rules that cut the false-positive rate on idiomatic plugin and controller code. PHP's injection classes overlap with the broader A03 Injection family; the SAST tools guide covers tool selection in more depth.

Closing

PHP's security bugs are concentrated in a small number of features that look harmless until they are not. mysqli_query with a concatenated $_GET, include with a path parameter, unserialize on a cookie, echo on an unescaped request value, shell_exec on a hostname — each is one or two lines a junior engineer can write in a minute, that passes every functional test, and that ships an RCE or data disclosure primitive the moment the input is attacker-controlled. The teams that stop shipping these bugs gate the dangerous calls in CI, run static analysis on every diff, and treat SAST findings as hard build failures rather than backlog items. The fix patterns are well documented and library-supported; the discipline is in catching the regression before the deploy.

GraphNode SAST analyzes PHP alongside 13+ other languages with inter-procedural data flow — request a demo.

Request Demo