CWE-94: Code Injection - PHP
Overview
Code Injection in PHP most commonly occurs via eval(), the preg_replace() /e modifier (removed in PHP 7), create_function() (removed in PHP 8), 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. Even PHP 7+ retains eval() and string-form assert().
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
// VULNERABLE - string-form assert() behaves like eval() in PHP 7
function checkCondition(string $userCondition): bool {
return assert($userCondition); // Equivalent to eval("return $userCondition;")
}
Why this is vulnerable:
assert($string)is deprecated in PHP 7 and evaluates the string as PHP code. This is indistinguishable fromeval()in terms of attack surface.
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()with a strict check ensures only 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
matchis a strict, exhaustive expression. It does not coerce types, does not fall through, and requires an explicitdefault. Unknown inputs throw an exception.
Remediation Steps
Locate the Finding
- Source: User-controlled input -
$_GET,$_POST,$_REQUEST,$_COOKIE, file uploads, database values read back later - Sink:
eval(),assert($string),include($variable),require($variable),preg_replace(.../e...),create_function()
Apply the Fix
- PRIORITY 1: Remove
eval()entirely. Replace with a named-callable array ormatchexpression. - PRIORITY 2: Replace
include/requirewith an allowlist of permitted page identifiers. - PRIORITY 3: Disable unsafe PHP settings:
assert.active = Offinphp.ini;allow_url_include = Off.
Verify the Fix
- Submit
system('id')as a value that previously reachedeval()and confirm it is not executed - Attempt path traversal:
page=../../../../etc/passwdand confirm it returns a 400 or the default page - Rescan with the security scanner to confirm the finding is resolved
Check for Similar Issues
Search for: eval(, assert(, preg_replace.*\/e, create_function(, include\s*\$, require\s*\$
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.