Auditing PHP Applications
This is the PHP-specific layer on top of auditing-code-for-vulnerabilities.
Take the methodology from that skill — context → attack surface → bug-class
hunt → variant analysis → the four-question verification gate — and apply the
PHP sink catalog below to it. This skill exists because PHP has footguns no
language-agnostic methodology carries: features that turn a file read into
remote code execution, and comparison rules that turn == into an auth bypass.
The collection deliberately has no per-language audit skills except this one.
PHP earns the exception because it dominates the legacy-web and
WordPress/Magento space where critical bugs actually live, and because its
dynamic features fail in ways a reviewer must be primed for specifically.
When to Use
- Reviewing a PHP codebase or plugin for exploitable vulnerabilities
- Auditing a WordPress, Magento, Joomla, or Laravel application's source
- Tracing a suspected RCE, LFI, SQLi, or object-injection path in PHP
- Deciding whether a
unserialize, include, or == on user input is exploitable
When NOT to Use
- General audit methodology, or a non-PHP language — use
auditing-code-for-vulnerabilities; this skill assumes it
- Finding a planted webshell or backdoor rather than a vulnerability — use
hunting-web-backdoors
- Building the deserialization exploit chain once you have the sink — use
exploiting-deserialization
- Black-box testing a running app — use
testing-web-applications
- Reviewing only the diff of a change — use
reviewing-code-changes
Sinks by Bug Class
Trace user input (superglobals, headers, uploaded filenames, DB values that
were once user input) to each of these. A sink is only a bug when a source
reaches it.
Remote code execution
- Direct eval sinks:
eval, assert (string arg, pre-8.0),
preg_replace with /e (pre-7.0), create_function (pre-8.0),
mb_ereg_replace/mb_eregi_replace with the e option.
- Command execution:
system, exec, passthru, shell_exec, backticks,
proc_open, popen, pcntl_exec. Watch escapeshellarg vs
escapeshellcmd misuse — escapeshellcmd does not prevent argument
injection.
- Dynamic dispatch:
$func() where $func derives from input,
call_user_func/call_user_func_array, array_map/usort/
preg_replace_callback with a user-influenced callback.
Object injection (the PHP-defining bug)
unserialize() on any user-controlled data is object injection. Exploitation
is a POP chain: existing classes with __wakeup, __destruct,
__toString, or __call magic methods that do something dangerous when the
object is materialized. The vulnerable file often contains no dangerous code
at all — the gadget lives in a framework or dependency.
phar:// is unserialize in disguise — but version-gated. On PHP
before 8.0, any file operation (file_exists, fopen, include,
getimagesize, …) on a phar:// path automatically deserializes the Phar's
metadata, so a path-traversal or LFI that points a file function at an
uploaded .phar (or any file carrying Phar metadata) is object injection
with no unserialize call in sight. PHP 8.0 removed that automatic
unserialize — only explicit Phar::getMetadata() deserializes now. So the
bug is critical on the huge installed base of PHP 7.x and a non-issue on 8.x
unless the code calls getMetadata() itself. Check the runtime version
before ruling it in or out.
- Prefer
json_decode in remediation; flag every unserialize on non-trusted
data regardless of an obvious gadget — the gadget may arrive with the next
dependency update.
LFI / RFI and stream wrappers
include, require, include_once, require_once with user input.
- Wrappers that change everything:
php://filter/... (read source, and a
known RCE chain via convert.iconv/compression filters),
php://input (RFI-style body inclusion), data:// (inline payload),
phar:// (object injection), expect://, zip://. allow_url_include
being on turns LFI into remote RFI.
- File-read sinks (
file_get_contents, readfile, fopen, highlight_file)
with traversal give source and secret disclosure.
SQL injection
- Legacy
mysql_query/mysqli_query with string-concatenated input.
$wpdb->query("... $var ...") without $wpdb->prepare() — a ->prepare
that is present but interpolates outside its placeholders is still injectable.
- PDO/mysqli used with string building instead of bound parameters. Placeholder
usage on the table/column name (which cannot be bound) is a common real hole.
Type juggling and magic-hash auth bypass
== and != are loose. strcmp/hash_equals/=== are the safe forms.
The classic bypass: if (md5($input) == $stored) where two inputs both hash
to a 0e[0-9]+ "magic hash" compare equal, because two numeric strings
compare as numbers (0e123 == 0e456 → 0.0 == 0.0).
- Version matters and it is a trap. In PHP 8.0 the number-vs-string rules
changed:
0 == "foo" was true in PHP 7 and is false in PHP 8, so a
in_array(0, $userStrings) or 0 == $token bypass that worked on PHP 7
fails on PHP 8. But the 0e magic-hash bypass survives PHP 8, because
both sides are numeric strings and still compare as numbers. Check the target
PHP version before ruling a juggling bug in or out.
strcmp($_GET['x'], $secret) returned NULL (loosely == 0) when passed an
array in PHP 7 — an auth bypass; in PHP 8 it throws a TypeError. Same code,
different verdict by version.
Superglobal trust and variable variables
extract($_GET/$_POST/$_REQUEST) lets a request set arbitrary local
variables — overwrites auth flags, config, anything not yet initialized.
$$var / ${$key} variable variables, parse_str without a result array
(writes into scope), import_request_variables (removed 5.4) — all
register_globals-flavoured variable injection.
SSRF and file upload
file_get_contents/curl/fopen on a user-supplied URL is SSRF — route the
exploitation reasoning through exploiting-ssrf.
- Uploads: check the destination is outside the web root, the extension is not
attacker-chosen (double extensions,
.phtml, .php5, null bytes on old
PHP), and the server will not execute the type. A validated MIME with an
attacker-controlled extension in a web-served directory is RCE.
Ecosystem Context
- WordPress: nonces are CSRF protection, not authorization — a valid
nonce does not mean the user is allowed to act; look for the missing
current_user_can() check. $wpdb->prepare is mandatory for interpolation.
unfiltered_html, edit_* capabilities, and unauthenticated AJAX/REST
(wp_ajax_nopriv_*, permission_callback => '__return_true') are the usual
holes.
- Magento: heavy
unserialize history, layout-XML injection, and the admin
path/patch level matter for known-CVE reachability.
- Laravel: mass assignment (
$fillable/$guarded), Blade {!! !!}
(unescaped) vs {{ }} (escaped), and a leaked APP_KEY enabling
cookie/decrypt object injection.
Verify Before You Report
Apply auditing-code-for-vulnerabilities' gate: trace the source to the sink,
confirm the path is reachable (auth, routing, and — critically — the PHP
version for juggling and removed-function bugs), and demonstrate impact
rather than asserting it. A unserialize with no reachable gadget, or a /e
regex on PHP 8, is a hardening note, not a critical finding — say which.
Rationalizations to Reject
- "There's no
unserialize, so no object injection." phar:// reaches
unserialize through ordinary file functions. Check every file operation whose
path a user can influence.
- "It uses
==, that's fine for a hash check." == on hashes is the
magic-hash bypass. Only hash_equals/=== are safe, and the bypass survives
PHP 8.
- "The comparison bug works, I confirmed it." On which PHP version?
0 == "x"
and strcmp(array) flipped verdict at PHP 8.0. State the version.
- "
prepare() is called, so the query is safe." Only for values inside
placeholders. Interpolated table/column names and text spliced around the
placeholders are still injectable.
- "The nonce check passes, so it's authorized." WordPress nonces are CSRF
tokens. Authorization is a separate
current_user_can() you must find.
- "The upload validates the MIME type." MIME is spoofable and irrelevant if
the attacker controls the extension and the directory executes PHP.
- "It's a framework class, the gadget isn't here." POP-chain gadgets live in
dependencies by design. The absence of dangerous code in the vulnerable file
is normal, not exculpatory.
Reading External Sources
Fetch public advisories, specifications, and vendor reports as Markdown:
curl -sL "https://defuddle.md/<url>" # scheme in the path is optional
This strips page boilerplate — roughly 78% fewer tokens on a prose page — and
returns the full text rather than a summary, so you can grep it and trust a
negative result.
Three things it is not for. Fetch JSON and API responses raw, because
readability extraction mangles structured data. Fetch authenticated or
JavaScript-rendered pages directly, because it retrieves them anonymously. And
never route adversary infrastructure (phishing links, C2, malware hosting),
client-owned hosts, or engagement URLs through it — the request leaves
your machine to a third party, and for live adversary infrastructure it also
tips off the operator.
Some sites block the extractor and return an error blob rather than the page —
{"error":"Failed to fetch: 418 I'm a teapot"} from freedesktop.org, for
instance. That is the fetch being refused, not the source saying the thing
does not exist. Re-fetch the URL directly before drawing any conclusion from
it.
References
auditing-code-for-vulnerabilities — the general methodology and gate this
skill sits on top of
hunting-web-backdoors — planted malice rather than vulnerabilities
exploiting-deserialization — building the POP chain once you find the
unserialize/phar:// sink
exploiting-ssrf — the exploitation side of a user-controlled URL fetch
testing-web-applications — black-box testing of the running application
1---2name: auditing-php-applications3description: Audit PHP web application source for critical vulnerabilities using PHP's specific sink and footgun catalog — object injection via unserialize and phar:// POP chains, type-juggling and magic-hash auth bypass, LFI/RFI through php:// and phar:// wrappers, dynamic includes and extract()/superglobal trust, SQL injection in string-built and legacy mysql_* queries, command-execution sinks, and SSRF. Use when reviewing a PHP codebase, a WordPress/Magento/Laravel app, or a plugin for exploitable bugs. Defers general audit methodology to auditing-code-for-vulnerabilities.4---56# Auditing PHP Applications78This is the PHP-specific layer on top of `auditing-code-for-vulnerabilities`.9Take the methodology from that skill — context → attack surface → bug-class10hunt → variant analysis → the four-question verification gate — and apply the11PHP sink catalog below to it. This skill exists because PHP has footguns no12language-agnostic methodology carries: features that turn a file read into13remote code execution, and comparison rules that turn `==` into an auth bypass.1415The collection deliberately has no per-language audit skills except this one.16PHP earns the exception because it dominates the legacy-web and17WordPress/Magento space where critical bugs actually live, and because its18dynamic features fail in ways a reviewer must be primed for specifically.1920## When to Use2122- Reviewing a PHP codebase or plugin for exploitable vulnerabilities23- Auditing a WordPress, Magento, Joomla, or Laravel application's source24- Tracing a suspected RCE, LFI, SQLi, or object-injection path in PHP25- Deciding whether a `unserialize`, `include`, or `==` on user input is exploitable2627## When NOT to Use2829- **General audit methodology, or a non-PHP language** — use30 `auditing-code-for-vulnerabilities`; this skill assumes it31- **Finding a planted webshell or backdoor** rather than a vulnerability — use32 `hunting-web-backdoors`33- **Building the deserialization exploit chain** once you have the sink — use34 `exploiting-deserialization`35- **Black-box testing a running app** — use `testing-web-applications`36- **Reviewing only the diff of a change** — use `reviewing-code-changes`3738## Sinks by Bug Class3940Trace user input (superglobals, headers, uploaded filenames, DB values that41were once user input) to each of these. A sink is only a bug when a source42reaches it.4344### Remote code execution4546- **Direct eval sinks:** `eval`, `assert` (string arg, pre-8.0),47 `preg_replace` with `/e` (pre-7.0), `create_function` (pre-8.0),48 `mb_ereg_replace`/`mb_eregi_replace` with the `e` option.49- **Command execution:** `system`, `exec`, `passthru`, `shell_exec`, backticks,50 `proc_open`, `popen`, `pcntl_exec`. Watch `escapeshellarg` vs51 `escapeshellcmd` misuse — `escapeshellcmd` does **not** prevent argument52 injection.53- **Dynamic dispatch:** `$func()` where `$func` derives from input,54 `call_user_func`/`call_user_func_array`, `array_map`/`usort`/55 `preg_replace_callback` with a user-influenced callback.5657### Object injection (the PHP-defining bug)5859- `unserialize()` on any user-controlled data is object injection. Exploitation60 is a **POP chain**: existing classes with `__wakeup`, `__destruct`,61 `__toString`, or `__call` magic methods that do something dangerous when the62 object is materialized. The vulnerable file often contains no dangerous code63 at all — the gadget lives in a framework or dependency.64- **`phar://` is unserialize in disguise — but version-gated.** On **PHP65 before 8.0**, any file operation (`file_exists`, `fopen`, `include`,66 `getimagesize`, …) on a `phar://` path automatically deserializes the Phar's67 metadata, so a path-traversal or LFI that points a file function at an68 uploaded `.phar` (or any file carrying Phar metadata) is object injection69 with no `unserialize` call in sight. **PHP 8.0 removed that automatic70 unserialize** — only explicit `Phar::getMetadata()` deserializes now. So the71 bug is critical on the huge installed base of PHP 7.x and a non-issue on 8.x72 unless the code calls `getMetadata()` itself. Check the runtime version73 before ruling it in or out.74- Prefer `json_decode` in remediation; flag every `unserialize` on non-trusted75 data regardless of an obvious gadget — the gadget may arrive with the next76 dependency update.7778### LFI / RFI and stream wrappers7980- `include`, `require`, `include_once`, `require_once` with user input.81- **Wrappers that change everything:** `php://filter/...` (read source, and a82 known RCE chain via `convert.iconv`/compression filters),83 `php://input` (RFI-style body inclusion), `data://` (inline payload),84 `phar://` (object injection), `expect://`, `zip://`. `allow_url_include`85 being on turns LFI into remote RFI.86- File-read sinks (`file_get_contents`, `readfile`, `fopen`, `highlight_file`)87 with traversal give source and secret disclosure.8889### SQL injection9091- Legacy `mysql_query`/`mysqli_query` with string-concatenated input.92- `$wpdb->query("... $var ...")` **without** `$wpdb->prepare()` — a `->prepare`93 that is present but interpolates outside its placeholders is still injectable.94- PDO/mysqli used with string building instead of bound parameters. Placeholder95 usage on the *table/column* name (which cannot be bound) is a common real hole.9697### Type juggling and magic-hash auth bypass9899- **`==` and `!=` are loose.** `strcmp`/`hash_equals`/`===` are the safe forms.100 The classic bypass: `if (md5($input) == $stored)` where two inputs both hash101 to a `0e[0-9]+` "magic hash" compare equal, because two numeric strings102 compare as numbers (`0e123 == 0e456` → `0.0 == 0.0`).103- **Version matters and it is a trap.** In PHP 8.0 the number-vs-string rules104 changed: `0 == "foo"` was `true` in PHP 7 and is `false` in PHP 8, so a105 `in_array(0, $userStrings)` or `0 == $token` bypass that worked on PHP 7106 fails on PHP 8. **But the `0e` magic-hash bypass survives PHP 8**, because107 both sides are numeric strings and still compare as numbers. Check the target108 PHP version before ruling a juggling bug in or out.109- `strcmp($_GET['x'], $secret)` returned `NULL` (loosely `== 0`) when passed an110 array in PHP 7 — an auth bypass; in PHP 8 it throws a `TypeError`. Same code,111 different verdict by version.112113### Superglobal trust and variable variables114115- `extract($_GET/$_POST/$_REQUEST)` lets a request set arbitrary local116 variables — overwrites auth flags, config, anything not yet initialized.117- `$$var` / `${$key}` variable variables, `parse_str` without a result array118 (writes into scope), `import_request_variables` (removed 5.4) — all119 register_globals-flavoured variable injection.120121### SSRF and file upload122123- `file_get_contents`/`curl`/`fopen` on a user-supplied URL is SSRF — route the124 exploitation reasoning through `exploiting-ssrf`.125- Uploads: check the destination is outside the web root, the extension is not126 attacker-chosen (double extensions, `.phtml`, `.php5`, null bytes on old127 PHP), and the server will not execute the type. A validated MIME with an128 attacker-controlled extension in a web-served directory is RCE.129130## Ecosystem Context131132- **WordPress:** nonces are **CSRF** protection, not authorization — a valid133 nonce does not mean the user is allowed to act; look for the missing134 `current_user_can()` check. `$wpdb->prepare` is mandatory for interpolation.135 `unfiltered_html`, `edit_*` capabilities, and unauthenticated AJAX/REST136 (`wp_ajax_nopriv_*`, `permission_callback => '__return_true'`) are the usual137 holes.138- **Magento:** heavy `unserialize` history, layout-XML injection, and the admin139 path/patch level matter for known-CVE reachability.140- **Laravel:** mass assignment (`$fillable`/`$guarded`), Blade `{!! !!}`141 (unescaped) vs `{{ }}` (escaped), and a leaked `APP_KEY` enabling142 cookie/`decrypt` object injection.143144## Verify Before You Report145146Apply `auditing-code-for-vulnerabilities`' gate: trace the source to the sink,147confirm the path is reachable (auth, routing, and — critically — the **PHP148version** for juggling and removed-function bugs), and demonstrate impact149rather than asserting it. A `unserialize` with no reachable gadget, or a `/e`150regex on PHP 8, is a hardening note, not a critical finding — say which.151152## Rationalizations to Reject153154- *"There's no `unserialize`, so no object injection."* `phar://` reaches155 unserialize through ordinary file functions. Check every file operation whose156 path a user can influence.157- *"It uses `==`, that's fine for a hash check."* `==` on hashes is the158 magic-hash bypass. Only `hash_equals`/`===` are safe, and the bypass survives159 PHP 8.160- *"The comparison bug works, I confirmed it."* On which PHP version? `0 == "x"`161 and `strcmp(array)` flipped verdict at PHP 8.0. State the version.162- *"`prepare()` is called, so the query is safe."* Only for values inside163 placeholders. Interpolated table/column names and text spliced around the164 placeholders are still injectable.165- *"The nonce check passes, so it's authorized."* WordPress nonces are CSRF166 tokens. Authorization is a separate `current_user_can()` you must find.167- *"The upload validates the MIME type."* MIME is spoofable and irrelevant if168 the attacker controls the extension and the directory executes PHP.169- *"It's a framework class, the gadget isn't here."* POP-chain gadgets live in170 dependencies by design. The absence of dangerous code in the vulnerable file171 is normal, not exculpatory.172173## Reading External Sources174175Fetch public advisories, specifications, and vendor reports as Markdown:176177```bash178curl -sL "https://defuddle.md/<url>" # scheme in the path is optional179```180181This strips page boilerplate — roughly 78% fewer tokens on a prose page — and182returns the full text rather than a summary, so you can grep it and trust a183negative result.184185Three things it is not for. Fetch JSON and API responses raw, because186readability extraction mangles structured data. Fetch authenticated or187JavaScript-rendered pages directly, because it retrieves them anonymously. And188never route **adversary infrastructure** (phishing links, C2, malware hosting),189**client-owned hosts**, or **engagement URLs** through it — the request leaves190your machine to a third party, and for live adversary infrastructure it also191tips off the operator.192193Some sites block the extractor and return an error blob rather than the page —194`{"error":"Failed to fetch: 418 I'm a teapot"}` from freedesktop.org, for195instance. That is the fetch being refused, **not** the source saying the thing196does not exist. Re-fetch the URL directly before drawing any conclusion from197it.198199## References200201- `auditing-code-for-vulnerabilities` — the general methodology and gate this202 skill sits on top of203- `hunting-web-backdoors` — planted malice rather than vulnerabilities204- `exploiting-deserialization` — building the POP chain once you find the205 `unserialize`/`phar://` sink206- `exploiting-ssrf` — the exploitation side of a user-controlled URL fetch207- `testing-web-applications` — black-box testing of the running application