| name | wp-security-deep |
| description | Deep security audit for WordPress plugin/theme PHP code, covering issues beyond the basic sanitize/escape/nonce checklist — PHP object injection (unserialize), SSRF in remote requests, CSRF on state-changing GET handlers, mass assignment via $_POST loops, insecure file include / template injection, mail header injection, ZipSlip in archive extraction, type-juggling in auth comparisons, and TOCTOU race patterns in option/meta locks. Use after or alongside wp-security-audit when reviewing complex plugins, REST APIs, integrations that fetch remote URLs, file processors, or any code that handles uploads, archives, or self-rolled auth tokens. |
| author | Soczó Kristóf |
| contact | mailto:lonsdale201@hotmail.com |
| plugin | wordpress |
| plugin-version-tested | 6.0 - 6.9 |
| php-min | 7.4 |
| last-updated | "2026-04-28T00:00:00.000Z" |
| source | https://github.com/Lonsdale201/wp-agent-skills/tree/8684fef5b4c33bc0cd783f9fff7770b1f7f59c57/wordpress/wp-security-deep |
| source-license | MIT |
| license | MIT |
WordPress security audit — deep checks
Run AFTER wp-security-audit covers the basics (sanitize, escape,
nonce, capability, SQL prepare, AJAX nopriv, REST permission). This
skill catches the second-tier issues that static-analysis tools miss
and that a hurried review skips.
When to use this skill
Trigger when the basic audit is clean but the code does any of:
- Calls
unserialize, maybe_unserialize on stored or transmitted data.
- Calls
wp_remote_*, file_get_contents, curl_*, fopen with a
URL that could be influenced by input.
- Has admin pages or AJAX/REST handlers driven by
?action= GET params
that perform writes.
- Loops
$_POST / $_REQUEST keys into update_post_meta,
update_user_meta, wp_update_user, wp_insert_post, or similar.
- Includes/requires a path that contains any input-derived component.
- Sends mail with user-controlled
From, Cc, Bcc, or custom headers.
- Extracts uploaded archives (
ZipArchive, PharData, tar).
- Compares tokens, hashes, or secrets with
== / === instead of
hash_equals.
- Implements its own lock / counter via
get_option + update_option.
Audit checks
1. PHP object injection via unserialize
$data = unserialize( $_POST['payload'] );
Even with [ 'allowed_classes' => false ], prefer json_decode for
network/user input. maybe_unserialize on get_option is generally
safe (admin wrote it), but flag it on user-meta keys that any user
can write (custom registration forms, profile editors).
Phar deserialization: on PHP < 8.0, filesystem functions
(file_exists, is_dir, filesize, fopen, etc.) on a path using
the phar:// stream wrapper would auto-unserialize the archive's
metadata, enabling object-injection gadget chains. PHP 8.0+ removed
this auto-unserialization (RFC: phar_stop_autoloading_metadata) — the
risk now requires an explicit Phar::getMetadata() call. So the
finding severity depends on the deployment's minimum PHP version:
- PHP 7.x supported: HIGH if any filesystem function is called with
user-influenced paths. Strip
phar:// from input.
- PHP 8.0+ only: still flag explicit
Phar::getMetadata() over
user-controlled archives, plus any unserialize() of binary blobs.
Fix: JSON for transport, allowlist classes if unserialize is
unavoidable, never accept phar:// from input on PHP 7.x.
2. SSRF in outbound requests
$response = wp_remote_get( $_POST['webhook_url'] );
Preferred defense — host allowlist. If the integration only ever
talks to a known set of hosts (Stripe, Slack, an internal API),
allowlist them:
$url = esc_url_raw( wp_unslash( $_POST['webhook_url'] ?? '' ) );
$parts = wp_parse_url( $url );
$allowed_hosts = [ 'api.stripe.com', 'hooks.slack.com' ];
if ( empty( $parts['host'] )
|| ! in_array( strtolower( $parts['host'] ), $allowed_hosts, true )
|| ! in_array( $parts['scheme'] ?? '', [ 'https' ], true ) ) {
wp_die( 'Forbidden host', 403 );
}
$response = wp_remote_post( $url, [
'timeout' => 5,
'redirection' => 2,
'reject_unsafe_urls' => true,
] );
Fallback — generic URL with IP-range filtering. Only use this when
allowlisting is impossible (e.g. user-submitted webhook URLs). The
naive gethostbyname() check is not enough: it returns a single
IPv4 A record, missing AAAA records (IPv6 ::1, fc00::/7),
multi-record DNS responses, and post-redirect destinations. A correct
generic check needs:
$host = strtolower( wp_parse_url( $url, PHP_URL_HOST ) );
if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
if ( ! filter_var( $host, FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
wp_die( 'Forbidden host', 403 );
}
}
$records = array_merge(
dns_get_record( $host, DNS_A ) ?: [],
dns_get_record( $host, DNS_AAAA ) ?: []
);
foreach ( $records as $r ) {
$ip = $r['ip'] ?? $r['ipv6'] ?? '';
if ( ! filter_var( $ip, FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
wp_die( 'Forbidden host', 403 );
}
}
$response = wp_remote_get( $url, [
'timeout' => 5,
'redirection' => 2,
'reject_unsafe_urls' => true,
] );
Even this is incomplete — DNS rebinding can return safe records to
the resolver and unsafe records to the actual fetch. For full
protection you need to resolve once, fetch by IP with a Host:
header. WordPress's reject_unsafe_urls request arg does some
filtering via the http_request_host_is_external /
http_request_host_is_allowed filters, but is opt-in and not
sufficient on its own.
Flag when:
- No host allowlist when the integration only needs known endpoints.
gethostbyname()-only check (audit ourselves: this skill's earlier
versions had this same bug — IPv4-only, single-record, no redirect
reverification).
redirection not capped (default follows up to 5 — can chain into
internal services after passing the initial check).
timeout missing — DoS vector.
reject_unsafe_urls not set on user-influenced URLs.
3. CSRF on state-changing GET handlers
WP plugins commonly wire admin pages with ?action=delete&id=42
links. These bypass check_admin_referer if the dev only added it
to POST handlers.
if ( isset( $_GET['action'] ) && $_GET['action'] === 'delete' ) {
delete_post( (int) $_GET['id'] );
}
Rule: any handler that writes MUST verify a nonce regardless of
HTTP method. Action links must be built with wp_nonce_url( $url, 'delete_post_' . $id ) and verified with
check_admin_referer( 'delete_post_' . $id ).
4. Mass assignment
wp_update_user( $_POST );
wp_insert_post( $_POST );
foreach ( $_POST as $key => $value ) {
update_user_meta( $user_id, $key, $value );
}
Fix: explicit allowlist of accepted keys. Never spread
$_POST / $_REQUEST into a write function whose schema includes
privileged fields (role, user_pass, post_status, post_author,
meta_input, tax_input).
5. File include / template injection
include $template_dir . '/' . $_GET['view'] . '.php';
locate_template( $_GET['t'] . '.php' );
Fix: allowlist:
$allowed = [ 'list', 'edit', 'settings' ];
$view = isset( $_GET['view'] ) && in_array( $_GET['view'], $allowed, true )
? $_GET['view'] : 'list';
include $template_dir . '/' . $view . '.php';
Even with sanitize_file_name, .. and null bytes can survive. Only
allowlist is safe.
6. Mail header injection
wp_mail( 'admin@site.com', 'Hi', $body, "From: " . $_POST['email'] );
Fix: sanitize email + reject CRLF:
$from = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
if ( ! $from || preg_match( '/[\r\n]/', $from ) ) {
wp_die( 'Invalid sender', 400 );
}
wp_mail( 'admin@site.com', 'Hi', $body, [ 'From: ' . $from ] );
Pass headers as an array, not a concatenated string.
7. ZipSlip / archive extraction
$zip->extractTo( $target_dir );
Fix: reject suspicious entries explicitly, then containment-check
the resolved path with a trailing separator:
$base_real = realpath( $target_dir );
if ( $base_real === false ) {
wp_die( 'Invalid target', 500 );
}
$base_with_sep = rtrim( $base_real, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR;
for ( $i = 0; $i < $zip->numFiles; $i++ ) {
$name = $zip->getNameIndex( $i );
if ( $name === false
|| $name === ''
|| strpos( $name, "\0" ) !== false
|| preg_match( '#(^|[/\\\\])\.\.([/\\\\]|$)#', $name )
|| preg_match( '#^([a-zA-Z]:|/|\\\\)#', $name )
) {
wp_die( 'Malicious archive entry', 400 );
}
$candidate = $base_with_sep . $name;
$normalized = $base_with_sep
. ltrim( str_replace( '\\', '/', $name ), '/' );
if ( strncmp( $normalized, $base_with_sep, strlen( $base_with_sep ) ) !== 0 ) {
wp_die( 'Archive escape attempt', 400 );
}
}
$zip->extractTo( $target_dir );
Also: never use realpath() for not-yet-existing paths — it
returns false and the common fallback $base . '/' . $name is
exactly the unvalidated string the attacker controls. Always
normalize manually and prefix-check against base + DIRECTORY_SEPARATOR.
Reject symlinks, hardlinks, and entries whose archive metadata
indicates non-regular file types if your archive format exposes them.
8. Timing-safe comparison
if ( $_GET['token'] == $stored_token ) { }
Fix: if ( hash_equals( $stored_token, (string) $_GET['token'] ) ).
Use for: API keys, password reset tokens, signed URLs, any secret
comparison. Not needed for IDs / user-visible values.
9. TOCTOU race on options/meta
if ( ! get_option( 'myplugin_processing' ) ) {
update_option( 'myplugin_processing', 1 );
do_expensive_thing();
update_option( 'myplugin_processing', 0 );
}
WP options have no atomic CAS. Mitigations:
- Use a transient with a short TTL as a soft lock — not perfect, but
better than the above.
- For real exclusion:
$wpdb->query( "SELECT GET_LOCK('myplugin', 0)" )
and RELEASE_LOCK. MySQL-level, atomic.
- If correctness matters (billing, idempotency), use a unique-key
insert as the gate: insert fails → another worker is in flight.
Flag the pattern, propose the lock approach. Don't claim certainty
about race windows without dynamic testing.
10. Direct file access guard
Top of every PHP file that has side effects on load:
if ( ! defined( 'ABSPATH' ) ) { exit; }
Missing guard is LOW unless the file actually executes work at
top-level (most class files are fine). Flag explicitly for files in
includes/ or admin/ that do procedural work.
Severity guide
Same as wp-security-audit. Object injection, SSRF on internal
network, RCE via include, ZipSlip → HIGH. CSRF on admin GET
typically HIGH (any logged-in admin clicking a link). TOCTOU,
timing → MEDIUM unless directly exploitable.
Report format
Reuse the format from wp-security-audit. If both skills run, merge
findings into a single report grouped by severity, not by skill.
Cross-references
- Run
wp-security-audit first for the basic checklist
(sanitize, escape, nonce, capability, SQL prepare).
- Run
wp-security-secrets for hardcoded credentials, weak
randomness in tokens, and password-storage issues — these are
adjacent but distinct findings.
What this skill does NOT cover
- Cryptographic protocol correctness (custom JWT, signing schemes).
- Business-logic IDOR beyond capability/ownership checks.
- Third-party dependency CVEs (run
composer audit).
- Concurrency analysis beyond surface TOCTOU patterns.
- Server hardening (open_basedir, disable_functions, file perms).
References