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.4verified: 2026-07-275---67# Auditing PHP Applications89This is the PHP-specific layer on top of `auditing-code-for-vulnerabilities`.10Take the methodology from that skill — context → attack surface → bug-class11hunt → variant analysis → the four-question verification gate — and apply the12PHP sink catalog below to it. This skill exists because PHP has footguns no13language-agnostic methodology carries: features that turn a file read into14remote code execution, and comparison rules that turn `==` into an auth bypass.1516The collection deliberately has no per-language audit skills except this one.17PHP earns the exception because it dominates the legacy-web and18WordPress/Magento space where critical bugs actually live, and because its19dynamic features fail in ways a reviewer must be primed for specifically.2021## When to Use2223- Reviewing a PHP codebase or plugin for exploitable vulnerabilities24- Auditing a WordPress, Magento, Joomla, or Laravel application's source25- Tracing a suspected RCE, LFI, SQLi, or object-injection path in PHP26- Deciding whether a `unserialize`, `include`, or `==` on user input is exploitable2728## When NOT to Use2930- **General audit methodology, or a non-PHP language** — use31 `auditing-code-for-vulnerabilities`; this skill assumes it32- **Finding a planted webshell or backdoor** rather than a vulnerability — use33 `hunting-web-backdoors`34- **Building the deserialization exploit chain** once you have the sink — use35 `exploiting-deserialization`36- **Black-box testing a running app** — use `testing-web-applications`37- **Reviewing only the diff of a change** — use `reviewing-code-changes`3839## Sinks by Bug Class4041Trace user input (superglobals, headers, uploaded filenames, DB values that42were once user input) to each of these. A sink is only a bug when a source43reaches it.4445### Remote code execution4647- **Direct eval sinks:** `eval`, `assert` (string arg, pre-8.0),48 `preg_replace` with `/e` (pre-7.0), `create_function` (pre-8.0),49 `mb_ereg_replace`/`mb_eregi_replace` with the `e` option.50- **Command execution:** `system`, `exec`, `passthru`, `shell_exec`, backticks,51 `proc_open`, `popen`, `pcntl_exec`. Watch `escapeshellarg` vs52 `escapeshellcmd` misuse — `escapeshellcmd` does **not** prevent argument53 injection.54- **Dynamic dispatch:** `$func()` where `$func` derives from input,55 `call_user_func`/`call_user_func_array`, `array_map`/`usort`/56 `preg_replace_callback` with a user-influenced callback.5758### Object injection (the PHP-defining bug)5960- `unserialize()` on any user-controlled data is object injection. Exploitation61 is a **POP chain**: existing classes with `__wakeup`, `__destruct`,62 `__toString`, or `__call` magic methods that do something dangerous when the63 object is materialized. The vulnerable file often contains no dangerous code64 at all — the gadget lives in a framework or dependency.65- **`phar://` is unserialize in disguise — but version-gated.** On **PHP66 before 8.0**, any file operation (`file_exists`, `fopen`, `include`,67 `getimagesize`, …) on a `phar://` path automatically deserializes the Phar's68 metadata, so a path-traversal or LFI that points a file function at an69 uploaded `.phar` (or any file carrying Phar metadata) is object injection70 with no `unserialize` call in sight. **PHP 8.0 removed that automatic71 unserialize** — only explicit `Phar::getMetadata()` deserializes now. So the72 bug is critical on the huge installed base of PHP 7.x and a non-issue on 8.x73 unless the code calls `getMetadata()` itself. Check the runtime version74 before ruling it in or out.75- Prefer `json_decode` in remediation; flag every `unserialize` on non-trusted76 data regardless of an obvious gadget — the gadget may arrive with the next77 dependency update.7879### LFI / RFI and stream wrappers8081- `include`, `require`, `include_once`, `require_once` with user input.82- **Wrappers that change everything:** `php://filter/...` (read source, and a83 known RCE chain via `convert.iconv`/compression filters),84 `php://input` (RFI-style body inclusion), `data://` (inline payload),85 `phar://` (object injection), `expect://`, `zip://`. `allow_url_include`86 being on turns LFI into remote RFI.87- File-read sinks (`file_get_contents`, `readfile`, `fopen`, `highlight_file`)88 with traversal give source and secret disclosure.8990### SQL injection9192- Legacy `mysql_query`/`mysqli_query` with string-concatenated input.93- `$wpdb->query("... $var ...")` **without** `$wpdb->prepare()` — a `->prepare`94 that is present but interpolates outside its placeholders is still injectable.95- PDO/mysqli used with string building instead of bound parameters. Placeholder96 usage on the *table/column* name (which cannot be bound) is a common real hole.9798### Type juggling and magic-hash auth bypass99100- **`==` and `!=` are loose.** `strcmp`/`hash_equals`/`===` are the safe forms.101 The classic bypass: `if (md5($input) == $stored)` where two inputs both hash102 to a `0e[0-9]+` "magic hash" compare equal, because two numeric strings103 compare as numbers (`0e123 == 0e456` → `0.0 == 0.0`).104- **Version matters and it is a trap.** In PHP 8.0 the number-vs-string rules105 changed: `0 == "foo"` was `true` in PHP 7 and is `false` in PHP 8, so a106 `in_array(0, $userStrings)` or `0 == $token` bypass that worked on PHP 7107 fails on PHP 8. **But the `0e` magic-hash bypass survives PHP 8**, because108 both sides are numeric strings and still compare as numbers. Check the target109 PHP version before ruling a juggling bug in or out.110- `strcmp($_GET['x'], $secret)` returned `NULL` (loosely `== 0`) when passed an111 array in PHP 7 — an auth bypass; in PHP 8 it throws a `TypeError`. Same code,112 different verdict by version.113114### Superglobal trust and variable variables115116- `extract($_GET/$_POST/$_REQUEST)` lets a request set arbitrary local117 variables — overwrites auth flags, config, anything not yet initialized.118- `$$var` / `${$key}` variable variables, `parse_str` without a result array119 (writes into scope), `import_request_variables` (removed 5.4) — all120 register_globals-flavoured variable injection.121122### SSRF and file upload123124- `file_get_contents`/`curl`/`fopen` on a user-supplied URL is SSRF — route the125 exploitation reasoning through `exploiting-ssrf`.126- Uploads: check the destination is outside the web root, the extension is not127 attacker-chosen (double extensions, `.phtml`, `.php5`, null bytes on old128 PHP), and the server will not execute the type. A validated MIME with an129 attacker-controlled extension in a web-served directory is RCE.130131## Ecosystem Context132133- **WordPress:** nonces are **CSRF** protection, not authorization — a valid134 nonce does not mean the user is allowed to act; look for the missing135 `current_user_can()` check. `$wpdb->prepare` is mandatory for interpolation.136 `unfiltered_html`, `edit_*` capabilities, and unauthenticated AJAX/REST137 (`wp_ajax_nopriv_*`, `permission_callback => '__return_true'`) are the usual138 holes.139- **Magento:** heavy `unserialize` history, layout-XML injection, and the admin140 path/patch level matter for known-CVE reachability.141- **Laravel:** mass assignment (`$fillable`/`$guarded`), Blade `{!! !!}`142 (unescaped) vs `{{ }}` (escaped), and a leaked `APP_KEY` enabling143 cookie/`decrypt` object injection.144145## Verify Before You Report146147Apply `auditing-code-for-vulnerabilities`' gate: trace the source to the sink,148confirm the path is reachable (auth, routing, and — critically — the **PHP149version** for juggling and removed-function bugs), and demonstrate impact150rather than asserting it. A `unserialize` with no reachable gadget, or a `/e`151regex on PHP 8, is a hardening note, not a critical finding — say which.152153## Rationalizations to Reject154155- *"There's no `unserialize`, so no object injection."* `phar://` reaches156 unserialize through ordinary file functions. Check every file operation whose157 path a user can influence.158- *"It uses `==`, that's fine for a hash check."* `==` on hashes is the159 magic-hash bypass. Only `hash_equals`/`===` are safe, and the bypass survives160 PHP 8.161- *"The comparison bug works, I confirmed it."* On which PHP version? `0 == "x"`162 and `strcmp(array)` flipped verdict at PHP 8.0. State the version.163- *"`prepare()` is called, so the query is safe."* Only for values inside164 placeholders. Interpolated table/column names and text spliced around the165 placeholders are still injectable.166- *"The nonce check passes, so it's authorized."* WordPress nonces are CSRF167 tokens. Authorization is a separate `current_user_can()` you must find.168- *"The upload validates the MIME type."* MIME is spoofable and irrelevant if169 the attacker controls the extension and the directory executes PHP.170- *"It's a framework class, the gadget isn't here."* POP-chain gadgets live in171 dependencies by design. The absence of dangerous code in the vulnerable file172 is normal, not exculpatory.173174## Reading External Sources175176Fetch public advisories, specifications, and vendor reports as Markdown:177178```bash179curl -sL "https://defuddle.md/<url>" # scheme in the path is optional180```181182This strips page boilerplate — roughly 78% fewer tokens on a prose page — and183returns the full text rather than a summary, so you can grep it and trust a184negative result.185186Three things it is not for. Fetch JSON and API responses raw, because187readability extraction mangles structured data. Fetch authenticated or188JavaScript-rendered pages directly, because it retrieves them anonymously. And189never route **adversary infrastructure** (phishing links, C2, malware hosting),190**client-owned hosts**, or **engagement URLs** through it — the request leaves191your machine to a third party, and for live adversary infrastructure it also192tips off the operator.193194Some sites block the extractor and return an error blob rather than the page —195`{"error":"Failed to fetch: 418 I'm a teapot"}` from freedesktop.org, for196instance. That is the fetch being refused, **not** the source saying the thing197does not exist. Re-fetch the URL directly before drawing any conclusion from198it.199200## References201202- `auditing-code-for-vulnerabilities` — the general methodology and gate this203 skill sits on top of204- `hunting-web-backdoors` — planted malice rather than vulnerabilities205- `exploiting-deserialization` — building the POP chain once you find the206 `unserialize`/`phar://` sink207- `exploiting-ssrf` — the exploitation side of a user-controlled URL fetch208- `testing-web-applications` — black-box testing of the running application