Files
mmis1000andClaude Opus 5.5 36b24f846d SECURITY: userscript reaches cross-origin frames again, over GM storage (E30)
Follow-up to E29. When the userscript runs in an isolated world
(Tampermonkey @sandbox DOM, Violentmonkey @inject-into content, checked
via GM_info), frame commands travel through the manager's per-script
storage (GM_setValue + GM_addValueChangeListener on random per-instance
keys), which pages can't write. Without isolation it stays same-origin
only: in page mode the attacker page can fake navigator.getGamepads for
SofaNav in its own top frame (shown by a negative control).

Also: activate() passes document.defaultView as the event view, since
userscript managers may wrap window (PointerEvent threw in VM page mode).

test:security now installs the userscript in a pinned, sha256-checked
Violentmonkey and adds a fake-controller attack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:47:29 +08:00

58 lines
2.8 KiB
HTML

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Attacker</title>
</head>
<body>
<p>An unrelated site that frames the victim invisibly and talks to SofaNav inside it.</p>
<iframe id="victim" style="width:400px;height:200px;opacity:0"></iframe>
<script>
// Victim on the other origin (localhost <-> 127.0.0.1).
const ext = new URLSearchParams(location.search).has('ext');
const otherHost = location.hostname === 'localhost' ? '127.0.0.1' : 'localhost';
const frame = document.getElementById('victim');
frame.src = `${location.protocol}//${otherHost}:${location.port}/test/attack/victim.html${ext ? '?ext' : ''}`;
// Forge SofaNav's frame protocol. Tries the protocol tag and every command shape the
// client has accepted, with and without element ids learned from a snapshot.
const PROTO = 'sofanav.mmis1000.me/1';
window.__attackLog = [];
addEventListener('message', (e) => {
if (e.source !== frame.contentWindow || e.data?.sofanav !== PROTO) return;
window.__attackLog.push(e.data.type);
if (e.data.type === 'snapshot-reply') for (const it of e.data.items) {
frame.contentWindow.postMessage({ sofanav: PROTO, type: 'focus', id: it.id }, '*');
frame.contentWindow.postMessage({ sofanav: PROTO, type: 'activate', id: it.id }, '*');
}
});
frame.onload = () => {
let n = 0;
const timer = setInterval(() => {
// Probe forms of every transport, pointing replies at an inbox the attacker made up.
frame.contentWindow.postMessage({ sofanavProbe: 'attacker-' + n, reply: 'sofanav.inbox.attacker' }, '*');
frame.contentWindow.postMessage({ sofanav: PROTO, type: 'snapshot', req: ++n, vpArea: innerWidth * innerHeight }, '*');
for (let id = 1; id <= 50; id++) frame.contentWindow.postMessage({ sofanav: PROTO, type: 'activate', id }, '*');
if (n > 10) clearInterval(timer);
}, 200);
};
// Fake a controller for whatever SofaNav runs in this (top) page: if it can see this
// getGamepads, it would move into the victim frame and press A there for real.
const buttons = Array.from({ length: 17 }, () => ({ pressed: false, touched: false, value: 0 }));
const pad = { id: 'fake pad', index: 0, connected: true, mapping: 'standard', axes: [0, 0, 0, 0], buttons, timestamp: 0 };
Navigator.prototype.getGamepads = function () { return [pad, null, null, null]; };
const steps = [15, 13, 0]; // right, down, A; each pressed then released
let step = 0;
setInterval(() => {
const b = steps[Math.floor(step / 2) % steps.length], down = step % 2 === 0;
buttons[b] = { pressed: down, touched: down, value: down ? 1 : 0 };
pad.timestamp = performance.now();
step++;
}, 120);
dispatchEvent(new Event('gamepadconnected'));
setTimeout(() => dispatchEvent(new Event('gamepadconnected')), 1500); // after a late-loading SofaNav
</script>
</body>
</html>