Hack Any Webapp

How It Works

All three codebases solve one shape of problem: a third-party SPA enforces some behavior (a paywall, a hearts/hints limiter, a membership gate) inside a large, minified bundle. We alter that behavior on the user's own machine, durably, and keep working as the vendor redeploys on an unknown cadence.

The two-subsystem pattern

The architecture is a self-healing pipeline with a hard separation between deriving the patch and applying it.

Server side (CI, every 2h) · Client side (MV3 extension, per load)

The patcher runs in CI, where it may fetch the vendor bundle, run arbitrary Node, and fail loudly. It is the only place that understands the vendor's minified shape. The extension is dumb and durable: it never hard-codes vendor identifiers. It knows only (a) the URL pattern to block and (b) how to inject a blob of JavaScript into the page. When the vendor redeploys, CI re-derives within ≤2 hours and the extension self-heals.

Subsystem A: the patcher

Two concrete patchers exist. They share a skeleton but diverge on fail-policy and output shape.

Probe and anti-bot

The Gizmo patcher opens by fetching a specific quiz URL rather than the site root (which redirects to login). A bare fetch of that URL returns a "We think you're a bot" HTML stub, so the patcher sends a realistic Chrome User-Agent:

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36

The HTML is then scraped for the entry URL (/_expo/static/js/web/entry-<hash>.js), and the ~19.3 MB pristine bundle is downloaded.

Ordered rules with a minMatches floor

Each rule is a tightly-anchored regex find/replace pair annotated with a minMatches floor. Rules are applied in declaration order. The Gizmo patcher carries exactly two rules, the entire functional payload:

patcher/src/patches.ts
// Force isSubscribedStore reads to return true (paywall UI)
{ id: "is-subscribed",
find: "\\br\\(_?d\\[\\d+\\]\\)\\.isSubscribedStore\\.get\\(\\)",
flags: "g", replace: "(!0)", minMatches: 1 }

// Force the game-state subscription status check to 'subscribed'
// (unlimited hearts/hints, no cooldown, no life/hint consumption)
{ id: "subscription-status",
find: "'subscribed'===\\(0,[\\w$]+\\.default\\)\\(this,[\\w$]+\\)\\[[\\w$]+\\]\\.state\\.snapshot\\?\\.subscription\\?\\.status",
flags: "g", replace: "(!0)", minMatches: 1 }

Fail-closed vs fail-open

The two patchers take opposite positions on what to do when a rule under-matches.

Gizmo, fail-closed: If any rule returns fewer matches than minMatches, the patcher throws and refuses to write output:

const degraded = RULES.filter((r) => (perRuleCounts[r.id] ?? 0) < r.minMatches);
if (degraded.length > 0) {
  throw new Error(`Refusing to write patched bundle: rule(s) below minMatches …`);
}

CI fails, opens a deduplicated GitHub issue, and leaves the last-known-good artifact in place. Users keep working on the previous patch until a human re-anchors the regex.

Prodigy P-NP, fail-open: Under-matched rules set patchDegraded: true in metadata but the manifest is published anyway:

const patchDegraded = degraded.length > 0;  // does NOT throw
// "Extension will still apply the manifest as-is."

Only a hard failure (network error, non-JS response) throws. The trade-off is graceful degradation vs. strict correctness guarantees.

Three artifacts, content-stable by design

The Gizmo patcher publishes to patcher/dist/:

  • patches.json: the rules recipe plus a content hash. Deliberately omits any timestamp so it is byte-stable across runs where nothing actually changed.
  • entry.min.js: the full patched bundle (verification copy in Model B).
  • metadata.json: per-run telemetry: timestamp, per-rule counts, byte sizes, source URLs.

The CI commit step exploits the split to avoid churning a commit every two hours:

git add patcher/dist/entry.min.js patcher/dist/patches.json
if git diff --cached --quiet; then echo "No patch changes"; exit 0; fi
git add patcher/dist/metadata.json   # only staged if the real artifacts changed
git commit -m "chore: refresh patched Gizmo bundle"

The repo only records a commit when substance changes. This also keeps Model B's cache-invalidation hash (patchesHash) stable across no-op runs, a property the client depends on.

Scheduling and resilience

  • cron: "0 */2 * * *": every 2 hours, plus workflow_dispatch for manual runs.
  • concurrency: { group: patch-workflow, cancel-in-progress: false }: serialize runs, never cancel a running job.
  • permissions: { contents: write, issues: write }.
  • 30-second per-fetch timeout via AbortController.
  • On failure: create or re-open a deduplicated GitHub issue.

Subsystem B: the extension

Network-layer block (declarativeNetRequest)

A dynamic DNR rule BLOCKs the vendor bundle at the network layer, a defense-in-depth layer beneath the DOM overrides:

background.ts
// gizmo background.ts
{ id: 1, priority: 1,
action: { type: BLOCK },
condition: { urlFilter: "*://app.gizmo.ai/_expo/static/js/web/entry-*.js",
             resourceTypes: [SCRIPT] } }

Two facts make this the right tool. First, DNR urlFilter supports leading scheme wildcards (*://) and bare * path wildcards, so there is no need for the more constrained regexFilter. Second, and this is the linchpin of Model B, an extension's own service-worker fetch() is not subject to that extension's own DNR rules. The background worker can still pull the pristine bundle from the vendor even though the page is forbidden from loading it.

Play Origin goes further: it additionally uses DNR MODIFY_HEADERS to strip Content-Security-Policy and X-Frame-Options, plus two cosmetic REDIRECT rules, a broader use of the same primitive.

SRI neutralization (MAIN world)

Before any vendor script can register an integrity attribute, the content script neutralizes SRI globally by:

  • redefining the integrity accessor on HTMLScriptElement.prototype and HTMLLinkElement.prototype to a no-op getter/setter,
  • overriding Document.prototype.createElement to lock integrity on new script/link nodes,
  • dropping integrity in setAttribute,
  • stripping it from any node observed by the MutationObserver.

Universal script interception (MAIN world)

The vendor bundle can enter the DOM by many routes, so all are intercepted:

  • Node.prototype.appendChild, Node.prototype.insertBefore, and Element.prototype.append are wrapped. If the inserted node is the target script, the original is replaced with an inert "nop" script (data-…-patched="1") and injection is triggered.
  • A MutationObserver catches parser-inserted <script> tags (which never pass through the prototype methods), neutralizes src/textContent, and triggers injection.
  • <link rel="preload"> elements pointing to the bundle URL are removed so the browser wastes no request on the blocked URL.
  • An early scanExistingDom() handles the race where the script is already present when the content script runs.

The original URL is captured at interception (captureSrc) and threaded through to the patch request. This is how Model B knows which pristine bundle to fetch.

The MAIN ⇄ ISOLATED bridge

DOM overrides and injection must run in the page's JS realm (MAIN world), which has no access to chrome.*. The cache and privileged fetch live in the background service worker, reachable only from the ISOLATED-world content script. The two worlds share the DOM, so they relay via CustomEvent on document:

bridge sequence
MAIN  ──dispatch "__gizmo_patch_request__" {originalUrl}──►  ISOLATED bridge
ISOLATED ──chrome.runtime.sendMessage("GET_PATCHED_BUNDLE")──►  background SW
background ──(fetch rules + original, apply, cache)──►  returns patched JS
ISOLATED ──dispatch "__gizmo_patch_response__" {ok, patchedBundle}──►  MAIN
MAIN  ──inject via onreset──►  page realm

A registration-order subtlety: both scripts run at document_start, but the ISOLATED bridge registers its listener synchronously at top of file, while MAIN only dispatches after it detects a script, so the listener is always ready first.

Background worker cache (Model B core)

The GET_PATCHED_BUNDLE handler in background.ts runs this sequence:

  1. fetchPatchesJson(): GET patches.json (browser HTTP cache OK), validate rules[] + hash.
  2. getCachedBundle(): read chrome.storage.local.
  3. Cache key = { bundleFilename, patchesHash }: On a hit (both match) return the stored ~18 MB patched bundle directly.
  4. On miss: fetchOriginalBundle(originalUrl)applyPatchRules(original, rules)wrapWithMarkers(version)setCachedBundle(...).

This invalidation scheme is simple: bundleFilename changes when the vendor redeploys (entry-<newhash>.js); patchesHash changes when the rules change. A mismatch on either axis forces exactly one rebuild, then steady-state cache hits.

The injection itself, the onreset trick, gets its own page.

Read the onreset trick →