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 callsystem(),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 fromeval()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 returnstrueand runs nothing. Verified on PHP 8.5 withzend.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
includeexecutes the included file as PHP code. Withallow_url_includeenabled, 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
/eflag evaluated the replacement string as PHP code. Any user-controlled$replacementcould inject arbitrary PHP.
Secure Patterns
Array of Named Callables (Recommended)
<?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()withstrict: trueensures 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
matchcompares with===and does not fall through, so a tier of0or'bulk 'reaches no arm rather than matching one loosely. An unmatched value throwsUnhandledMatchErrorwhere there is nodefault; 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: Removingeval()while leavingcall_user_func($_GET['fn'], $arg)in place lets an attacker invoke any function by name - includingsystem,exec, orassert- without ever touchingeval()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_functionsto disableeval:evalis a language construct, not a function, sodisable_functions=evalinphp.inihas 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,systemevaluateseval("return 1+1;")and returns2. The directive is genuinely useful againstsystem,exec,passthru,proc_openandpopen, which limits what injected code can reach out to, and that is worth configuring - but it cannot close theeval()sink itself, and a hardening checklist that listsevalamong the disabled functions is recording a control that was never applied. Removing theeval()call is the only fix. - Trusting a fixed prefix to make
include/requiresafe: Building an include path asinclude('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.