Hack Any Webapp

The onreset Trick

The single most reusable primitive across all three codebases. It appears, essentially verbatim, in each one.

What it is

inject.js
document.documentElement.setAttribute("onreset", codeString); // 1. stage code
document.documentElement.dispatchEvent(new CustomEvent("reset")); // 2. detonate
document.documentElement.removeAttribute("onreset");           // 3. clean up

An HTML event-handler content attribute (onreset, onclick, …) is, per the HTML spec, compiled by the browser into a function and executed in the page's own JavaScript realm when its event fires. Assign the attribute programmatically, fire a synthetic event, and an arbitrary code string runs synchronously, in the page's MAIN world, with full access to the page's globals and module system.

Why it's the right tool under MV3

AlternativeWhy it fails
eval / new Function (in the extension)MV3 forbids it in the extension's own contexts; CSP 'unsafe-eval' is typically absent.
Insert <script src='blob:…'> or inline <script>Trips the page's script-src/script-src-elem CSP; trips SRI heuristics; observable by DOM watchers the bundle or anti-tamper code may install; and is exactly the insertion path being intercepted for the vendor script.
chrome.scripting.executeScript with a fileCan't run a dynamically fetched remote string as code under MV3, only packaged files or MAIN-world functions.
onreset attribute + dispatch (the winner)Executes a string as page-realm code without eval in the extension, without inserting a script element, and without a network fetch of a script resource. It is an attribute write and an event dispatch.

Five properties make this the right tool:

  1. Page-realm execution: The injected bundle runs as if it were the page's own inline script. It can define the SPA's globals, satisfy its module loader, and so on.
  2. No extension-side eval: The string is handed to the browser's HTML event-handler compiler, not to the extension's JS engine. This sidesteps the MV3 prohibition that applies to the extension's own contexts.
  3. No <script> element: Nothing for SRI, script-src-elem, or a DOM MutationObserver to catch. The payload never exists as a script node.
  4. Synchronous and ordered: dispatchEvent runs the handler inline, so by the time control returns, the bundle has executed. This matters for beating the SPA's own boot sequence.
  5. Self-cleaning: removeAttribute immediately after avoids leaving the source in the DOM where it could re-fire, be read by anti-tamper code, or bloat the DOM by ~19 MB.

reset is chosen specifically because it is a benign event that nothing else dispatches against &lt;html>, so the handler fires exactly once, on command.

The URL-shadowing tell

Every implementation prepends one line:

js
const payload = "var URL=window.URL;\n" + js;

Per the HTML spec, an event-handler content attribute runs with the element and the document on its scope chain (the legacy with(document) semantics). Inside such a handler, the bare identifier URL resolves to document.URL (a string, the page's address) rather than the global URL constructor. The vendor bundles call new URL(...); without the shim, that becomes new "<page-url-string>"(...), a TypeError, and the whole bundle dies on boot.

Prepending var URL=window.URL; rebinds URL to the constructor for the duration of the handler.

How Play Origin extends the primitive

Play Origin appends a second line to the payload to settle a load-order semaphore the game uses:

js
const semaphore =
`if(window.SW&&window.SW.Load&&typeof window.SW.Load.decrementLoadSemaphore==='function')`
+ `window.SW.Load.decrementLoadSemaphore();`;
setAttribute("onreset", "var URL=window.URL;\n" + js + "\n" + semaphore);

This is the point where Model A's "inject the whole file" approach creates a subtle bug the patcher must defend against. The old extension build appended its own SW.Load.decrementLoadSemaphore() after the game code and the patcher suffix called it too. createGame() fired twice, every Inversify bind() ran twice on the same container, and the game crashed with "Ambiguous match found for MathTower." The generated prefix defends against this with idempotency guards, making all binds safe to re-run. For the full double-fire analysis, see the case studies page.

The primitive, three ways

gizmo-v2.0.1.js
// Gizmo v2.0.1 (Model A): fetch the patched file, inject
fetch(PATCHED_URL).then(r=>r.text()).then(js=>{
document.documentElement.setAttribute("onreset","var URL=window.URL;\n"+js);
document.documentElement.dispatchEvent(new CustomEvent("reset"));
document.documentElement.removeAttribute("onreset");
});
gizmo-v2.2.x.js
// Gizmo v2.2.x (Model B): bridge → background builds it, then inject (same primitive)
requestPatchedBundle(originalUrl).then(js=>{
const payload="var URL=window.URL;\n"+js;
document.documentElement.setAttribute("onreset",payload);
document.documentElement.dispatchEvent(new CustomEvent("reset"));
document.documentElement.removeAttribute("onreset");
});
play-origin.js
// Play Origin (Model A + semaphore line)
fetch(gameUrl).then(r=>r.text()).then(js=>{
const semaphore="if(window.SW&&window.SW.Load&&typeof window.SW.Load.decrementLoadSemaphore==='function')window.SW.Load.decrementLoadSemaphore();";
document.documentElement.setAttribute("onreset","var URL=window.URL;\n"+js+"\n"+semaphore);
document.documentElement.dispatchEvent(new CustomEvent("reset"));
document.documentElement.removeAttribute("onreset");
});