DEV Community

VEILVM
VEILVM

Posted on

Self-defending JavaScript and checksums fail once the attacker can patch the checker

A popular last line of defense in client JavaScript is an integrity check. The script hashes its own source, compares the result to a baked-in constant, and if the bytes have changed it refuses to run, wipes state, or phones home. Related tricks include "self-defending" wrappers that break when someone pretty-prints the file, and runtime checks that Function.prototype.toString() still matches a known snapshot.

These ideas sound like tamper detection. They are not a trust boundary. The checker is itself JavaScript in the same download as the program it is trying to protect. Anyone who can edit the bundle can edit the checker.

This article is about why checksums, self-hashing, and toString snapshots fail against a determined reader, and what is actually worth doing instead.

What a self-check usually looks like

A simplified checksum gate:

function assertIntact(src) {
  const expected = "a1b2c3d4..."; // baked in at build time
  const actual = sha256(src);
  if (actual !== expected) {
    location.href = "/blocked";
    throw new Error("tampered");
  }
}

assertIntact(document.currentScript.text);
bootApp();
Enter fullscreen mode Exit fullscreen mode

A self-defending variant tries to notice formatting changes:

function assertNotBeautified(fn) {
  const text = Function.prototype.toString.call(fn);
  if (text.includes("\n") || text.includes("  ")) {
    throw new Error("reformatted");
  }
}
Enter fullscreen mode Exit fullscreen mode

In the build, a post-step computes the hash of the emitted file, or snapshots toString of selected functions, and injects the expected value. On a casual copy that changes whitespace or a string, the app "breaks." That can slow down someone who only knows how to hit a beautifier button. It does not slow down someone who can search for assertIntact and delete the call.

The checker is in the file

The attacker does not have to produce a new bundle whose hash still matches. They have to stop the check from mattering. Common moves, all targeting the same fact — the policy is enforced by code the user already has:

  • Comment out or skip assertIntact(...).
  • Replace sha256 or the comparison with a function that always returns the expected value.
  • Hook Function.prototype.toString so the snapshot always looks original.
  • Patch location.href / fetch so the "phone home" does not run, then continue.
  • Load the module in a debugger, set the check to never throw, and dump the rest.

A teaching sketch of the first two:

// conceptual: the check is just another function
assertIntact = () => {};
// or
sha256 = () => "a1b2c3d4...";
Enter fullscreen mode Exit fullscreen mode

If you try to hide the expected hash with string encoding, you have only moved the constant. The comparison still has to happen, and the comparison can still be skipped. Obfuscating the checker raises the cost of finding it. It does not change the fact that once it is found, it can be disabled, because nothing outside the page is confirming the result.

toString snapshots are the same shape

Function.prototype.toString returns source text the engine is willing to show. It is useful for debugging. It is not a sealed seal.

Host hooks, a proxy on Function.prototype, a different engine, or a bundler that already rewrote the function will all change what toString returns — or let an analyst feed the snapshot back. Some "self-defending" templates also break under legitimate minifier or browser differences, which means you spend time debugging your own defense.

Treat toString comparisons as a weak speed bump against a beautifier, not as proof that a function is unmodified.

What is actually worth doing

Assume the client can be patched. Design from that.

  1. Do not put secrets, private API keys, or authoritative license or pricing decisions only in client JavaScript. The server has to re-check anything that matters.
  2. If you want telemetry when a bundle looks wrong, treat it as a signal, not as enforcement. A client that reports "I was tampered with" can also report nothing.
  3. Turn production source maps off the public CDN.
  4. Minify production bundles.
  5. For proprietary client logic that must run in the browser, raise the cost of wholesale reuse with real code protection — renaming and string encoding as a floor, and VM-style protection for the small set of functions that would hurt you if they were copied. A checker around unprotected source does not substitute for that.
  6. Do not rely on a self-hash as the reason those functions are safe to ship in plaintext.

A JS VM obfuscator compiles selected functions to a custom bytecode and ships a small interpreter, so a pretty-printed chunk no longer contains the original control flow waiting to be copied. That still does not make the logic a secret, and it still does not replace a server check. It makes casual copy-paste more expensive, which is the honest goal. A checksum that can be deleted does not do that work for you.

VEILVM is a browser-based JSVMP workspace you can point at one extracted function before you wire protection into CI. Interpreted code is slower, so protect the sensitive function and leave the rest of the app alone.

Checklist

  • [ ] No secret or authoritative check lives only in the client.
  • [ ] A self-hash, toString snapshot, or self-defending wrapper is not treated as a trust boundary.
  • [ ] Production source maps are private.
  • [ ] You can name the few client functions that deserve more than minification.
  • [ ] End-to-end tests run against the production build, including protected functions, and do not depend on a brittle self-check firing in QA.

If you want to try VM protection on a single extracted function, VEILVM runs in the browser with no sign-up: 3 free runs a day, up to 5 KB per file (resets at 00:00 UTC). A free account raises the input limit to 30 KB. Pro is $9/month or $79/year and adds an API so the protection step can run in CI. Standard requests are processed in memory and are not written to logs. Start at https://vm.ldvmp.com.

Top comments (1)

Collapse
 
mp3sss31 profile image
mp3sss •

Exactly the point that the checker lives in the same attack surface as the code it guards: once the bundle is editable, the hash constant, the toString snapshot, and the abort branch are all editable too, so these checks raise the bar against casual readers but can't define a trust boundary. The defense that survives is moving the decision to something the client can't rewrite — server-side authorization, signed requests, or validation in a protected environment. In packaged desktop apps the same gap shows up: an unpacked asar or patched main-process file can neutralize a local integrity check the same way, so any entitlement or sensitive operation still has to be confirmed by the backend rather than trusted from the local binary.