November 5, 2026
The Bouncer Who Only Read IPv4: SSRF in WordPress Playgroundβs CORS Proxy
An errand-boy proxy, a split-personality hostname, and a delivery to the proxyβs own mailroom.

By Burrito
4 min read
The Setup
Inside the WordPress Playground ecosystem lives a CORS proxy β a server whose entire job description is:
"Give me a URL and I'll fetch it for you."
A universal errand boy. Historically speaking, "fetch whatever URL you're given" is exactly the job where somebody eventually asks the errand boy to knock on doors he really, truly should not.
Naturally, this proxy has a bouncer at the door: before fetching your URL, it validates the destination is not a private/internal IP. You don't want strangers using your errand boy to knock on your own back doors.
Here's the bouncer, paraphrased from url_validate_and_resolve() in the open-source repo:
$ips = gethostbynamel($host); // β resolve hostname to IPs
foreach ($ips as $ip) {
if (is_private($ip)) reject(); // β no internal deliveries!
}
// then pin the validated IP so DNS can't swap mid-flight:
curl_setopt($ch, CURLOPT_RESOLVE, array(
"$host:80:$resolvedIp",
"$host:443:$resolvedIp",
));$ips = gethostbynamel($host); // β resolve hostname to IPs
foreach ($ips as $ip) {
if (is_private($ip)) reject(); // β no internal deliveries!
}
// then pin the validated IP so DNS can't swap mid-flight:
curl_setopt($ch, CURLOPT_RESOLVE, array(
"$host:80:$resolvedIp",
"$host:443:$resolvedIp",
));Spot the problems? No? Good β that's the whole bug. Let me point at them.
Problem 1: The Bouncer Is Monolingual
gethostbynamel() is a PHP function that returns IPv4 addresses only. A records. That's it. That's the whole vocabulary.
The bouncer checks the English side of the business card and has never flipped it over.
Every hostname on the internet has two sides:
- The A record (IPv4) β what the bouncer checks β
- The AAAA record (IPv6) β what the bouncer has never heard of β
So I gave a free DuckDNS hostname a split personality:
A = 1.1.1.1 β public, innocent, passes the check β
AAAA = ::1 β "the proxy server's own localhost" πA = 1.1.1.1 β public, innocent, passes the check β
AAAA = ::1 β "the proxy server's own localhost" πSetting it up took exactly two clicks. Behold, the split-personality hostname factory:
The bouncer reads the IPv4 side: "1.1.1.1? Totally public. Come on in." Then curl β which, like every modern piece of software, speaks both languages β happily dials the IPv6 address: ::1. The proxy's own loopback.
The errand boy just delivered a package to his own house's mailroom. With the boss's blessing. In writing.
Problem 2: The VIP Pin Only Covers Two Doors
Even the IP-pinning β meant to stop DNS from changing between validation and fetch β is only applied to ports 80 and 443. Any other port? No pin at all, so curl re-resolves the hostname itself and picks whichever address family it likes.
The validated IP isn't a rule. It's a suggestion. And the bouncer takes suggestions from nobody.
The Proof
Three requests against the production proxy:
# Baseline: literal private IP? Blocked. Good bouncer.
GET /?http://127.0.0.1:8080/ β 400 "Private IPs are forbidden"
# Baseline 2: public IPv4 on an internal port β validation covers this
GET /?http://1.1.1.1:3306/probe β 500 "Failed to connect to 1.1.1.1:3306"
# The bypass: my dual-stack hostname, same port
GET /?http://<redacted>.duckdns.org:3306/probe
β 500 "curl_exec error: Received HTTP/0.9
when not allowed"# Baseline: literal private IP? Blocked. Good bouncer.
GET /?http://127.0.0.1:8080/ β 400 "Private IPs are forbidden"
# Baseline 2: public IPv4 on an internal port β validation covers this
GET /?http://1.1.1.1:3306/probe β 500 "Failed to connect to 1.1.1.1:3306"
# The bypass: my dual-stack hostname, same port
GET /?http://<redacted>.duckdns.org:3306/probe
β 500 "curl_exec error: Received HTTP/0.9
when not allowed"That last error is the money shot: curl connected to something non-HTTP on the proxy's own loopback β consistent with the internal MySQL the proxy's rate-limiter uses, at [::1]:3306. Connected. Reply received.
A second probe on port 11211: connection established, silent close. Two internal services reachable with one DNS trick and zero rebinding β the A and AAAA records coexist permanently; no TTL games needed.
And because AAAA could be set to ::ffff:<any IPv4> (IPv4-mapped IPv6), the proxy turned into a port scanner for its own internal network β open, closed, and filtered states were all distinguishable from its error messages:
- AAAA =
::1β proxy says "Received HTTP/0.9" β port OPEN, service replied - AAAA =
::1, port 49999 β "Failed to connect after 1003 ms" β port CLOSED - AAAA =
::ffff:192.0.2.1β "Failed to connect after 538 ms" β dialed the IPv4 I chose
That last one is the sneaky one: an otherwise-unreachable IPv4 got dialed because I asked the proxy to. Full destination control, one DNS record at a time.
What a Real Attacker Gets
- Protocol-speaking access to internal services β the proxy relays arbitrary methods and bodies, so an attacker can hold conversations with internal daemons
- A free internal port scanner with three distinguishable states
- No authentication, no user interaction β the attacker fires the requests directly
The Verdict πΈ
"This is a duplicate of an existing report we have already received."
Duplicate β an earlier report by another researcher, a DNS rebinding bypass of the same pin, submitted four days before mine. Different technique (rebinding vs. dual-stack), same root cause.
Four. Days. Earlier.
Lessons
- "It's always DNS" applies to validations too. If your security check runs on DNS, it inherits all of DNS β both address families, or it protects half the internet.
- Pins must cover the actual port. Pinning
host:80andhost:443while allowing any port is checking the lock on two doors of a thousand-door building. - Read the source. It's open source. That's the whole cheat code. The bouncer's monolingual brain was sitting in a file, waving.
- Speed is a skill. Someone read the same file and hit submit 96 hours before I did. The bounty wasn't for finding it β it was for finding it first.
TL;DR
- Bug: SSRF β IPv4-only validation (
gethostbynamel) ignores AAAA records;CURLOPT_RESOLVEpin only covers ports 80/443 - One-liner: The bouncer only reads the English side of the business card
- Severity: Medium
- Live proof: Proxy's own MySQL at
[::1]:3306reached + built-in internal port-scanning via::ffff:mapped IPv4 - Result: Duplicate β someone beat me by four days
- Bounty: $0
- Lesson: It's always DNS β even when it isn't DNS rebinding