Skip to content

CWE-94: Code Injection - PHP

Overview

Code Injection in PHP most commonly occurs via eval(), the preg_replace() /e modifier, create_function(), assert() with a string argument, or include/require of a user-controlled path. PHP's eval() executes arbitrary PHP code in the current scope, giving an attacker full control of the server process including access to the file system, database connections, and environment variables.

PHP is particularly susceptible because several legacy functions were designed to accept and execute strings as code. Many older codebases still use these patterns for "flexible" configuration or template rendering. Most of those functions have since been withdrawn - the /e modifier in PHP 7.0, string-form assert() in PHP 8.0, create_function() in PHP 8.0 - which changes what a finding against them means: on a PHP 8 target they are dead code to delete, not live sinks. eval() and user-controlled include/require are the two that still execute, and they are where the effort belongs.

Primary Defence: Remove all eval() calls. There is no sanitization that makes eval($userInput) safe. Replace with a match statement, switch, or an allowlisted array of named callables.

Common Vulnerable Patterns

eval() with User Input

<?php
// VULNERABLE - eval() executes arbitrary PHP
$operation = $_GET['op']; // e.g., "system('id')" or "file_get_contents('/etc/passwd')"

echo eval("return $operation;");

Why this is vulnerable:

  • eval() has no sandboxing. An attacker who controls the string can call system(), file_get_contents(), or any PHP function in scope, including those that read the database credentials.

assert() with String Argument (PHP 7 and Earlier)

<?php
// VULNERABLE on PHP <= 7.x - string-form assert() behaves like eval()
function checkCondition(string $userCondition): bool {
    return assert($userCondition); // On PHP 7: eval("return $userCondition;")
}

Why this is vulnerable:

  • On PHP 7 and earlier, assert($string) evaluates the string as PHP code, which is indistinguishable from eval() in attack surface. It was deprecated in PHP 7.2 for that reason.
  • This stopped being a code-execution sink in PHP 8.0, where string evaluation was removed. assert("system('id')") on PHP 8 evaluates the string as a boolean - a non-empty string is truthy, so it returns true and runs nothing. Verified on PHP 8.5 with zend.assertions=1.
  • That makes the version the first thing to establish when a scanner reports this. On a PHP 8 codebase the finding is a false positive as a code-injection issue, and what remains is a correctness bug: the assertion passes unconditionally and has never tested anything. Fix it by passing an expression rather than a string, and record the security finding as not exploitable with the version as the reason.

include/require with User-Controlled Path

<?php
// VULNERABLE - user controls which file is included
$page = $_GET['page']; // attacker supplies: "../../etc/passwd" or a remote URL
include($page . '.php');

Why this is vulnerable:

  • PHP's include executes the included file as PHP code. With allow_url_include enabled, remote PHP scripts can be fetched and executed. Even without remote inclusion, path traversal can include sensitive files.

preg_replace with /e Modifier (PHP 5 Legacy)

<?php
// VULNERABLE - /e modifier evaluates the replacement as PHP (removed in PHP 7)
$output = preg_replace('/' . $pattern . '/e', $replacement, $input);

Why this is vulnerable:

  • The /e flag evaluated the replacement string as PHP code. Any user-controlled $replacement could inject arbitrary PHP.

Secure Patterns

<?php
// SECURE - replace eval() with a lookup of predefined callables
$operations = [
    'double' => fn(float $x): float => $x * 2,
    'square' => fn(float $x): float => $x ** 2,
    'negate' => fn(float $x): float => -$x,
    'abs'    => fn(float $x): float => abs($x),
];

$opName = $_GET['op'] ?? '';
if (!array_key_exists($opName, $operations)) {
    http_response_code(400);
    exit('Invalid operation');
}
$result = $operations[$opName](42.0);
echo json_encode(['result' => $result]);

Why this works:

  • The array maps string keys to PHP closures defined in source code. User input selects which closure to call - it cannot supply new code.
  • array_key_exists() matches the key exactly, so only the registered operations are callable.

Allowlist-Controlled include

<?php
// SECURE - allowlist replaces variable include path
$allowedPages = ['home', 'about', 'contact', 'faq'];

$page = $_GET['page'] ?? 'home';
if (!in_array($page, $allowedPages, true)) {
    $page = 'home'; // or http_response_code(400) and exit
}

// Construct path from the validated identifier only
include __DIR__ . '/pages/' . $page . '.php';

Why this works:

  • in_array() with strict: true ensures type-safe matching. The page name from the allowlist is concatenated, never the raw user input.
  • Path traversal sequences (../) are rejected because they will never match an allowlist entry.

match Expression for Dispatch

<?php
declare(strict_types=1);

function applyDiscount(string $tier, float $price): float {
    // SECURE - match with explicit arms - no dynamic code
    return match($tier) {
        'bulk'    => $price * 0.9,
        'loyalty' => $price * 0.95,
        'staff'   => $price * 0.7,
        default   => throw new \InvalidArgumentException("Unknown tier: $tier"),
    };
}

$price = applyDiscount($_POST['tier'] ?? '', 100.0);

Why this works:

  • PHP match compares with === and does not fall through, so a tier of 0 or 'bulk ' reaches no arm rather than matching one loosely. An unmatched value throws UnhandledMatchError where there is no default; the arm above makes that refusal explicit and names the value.

Testing

  • Normal input: call each allowed command, page include, or dispatch action and confirm expected behavior.
  • Boundary input: test unknown action names, empty values, long strings, and mixed-case page keys.
  • Malicious input: submit PHP code, shell calls, stream-wrapper paths, and traversal payloads; confirm no dynamic execution or unapproved include occurs.

Common Pitfalls

  • call_user_func()/call_user_func_array() with a user-controlled callable name: Removing eval() while leaving call_user_func($_GET['fn'], $arg) in place lets an attacker invoke any function by name - including system, exec, or assert - without ever touching eval() directly. The same risk applies to any function accepting a callback argument (array_map(), usort(), array_filter()) when the callback name comes from request input.
  • Expecting disable_functions to disable eval: eval is a language construct, not a function, so disable_functions=eval in php.ini has no effect whatsoever - eval() still runs, with no warning and no entry in the log. Verified on PHP 8.5: php -d disable_functions=eval,assert,system evaluates eval("return 1+1;") and returns 2. The directive is genuinely useful against system, exec, passthru, proc_open and popen, which limits what injected code can reach out to, and that is worth configuring - but it cannot close the eval() sink itself, and a hardening checklist that lists eval among the disabled functions is recording a control that was never applied. Removing the eval() call is the only fix.
  • Trusting a fixed prefix to make include/require safe: Building an include path as include('pages/' . $page . '.php') without an allowlist still permits path traversal ($page = '../../../etc/passwd%00' on older PHP, or a valid page name like ../config) - a fixed prefix narrows the attack surface but doesn't close it the way an explicit allowlist of permitted identifiers does.

Additional Resources