Documentation
/
Recipes
/

Hiding Function Names from LLM Analysis

Hiding Function Names from LLM Analysis

Pro

The problem

You enabled vmObfuscation: true, ran it over a file that contains a function like validateLicense, and noticed that the obfuscated output still contains the literal text validateLicense - the body is gone, replaced by bytecode, but the name itself is sitting there in plain sight.

JavaScript

Multiply that across a real codebase and you get a list of function names like validateLicense, decryptPayload, processPayment, checkSubscription. An LLM does not need to crack the bytecode to understand what the program does - the names alone are enough for it to produce a confident, accurate summary of the module's behavior. The bytecode is opaque; the table of contents is not.

Why VM obfuscation keeps these names

With vmTargetFunctionsMode: 'root' (the default), the obfuscator transforms the body of every root-level function into VM bytecode but deliberately leaves the name alone. A root-level function declaration is, semantically, a binding on the surrounding scope - for a script that means the global object, and for a module it means module scope (a non-exported top-level function is module-scoped, not global, but it is still root-level in the file). The obfuscator can't safely rename it because it has no way to know who else references it: another bundle, an inline <script>, an HTML onclick="validateLicense(...)" attribute, a dynamic window['validateLicense'] lookup, etc.

So the trade-off the default makes is: protect the implementation, preserve the public surface. That keeps integration unbroken, but it also means an LLM gets a free index of every entry point. When this happens, the obfuscation result reports a VMGlobalFunctionNamesNotRenamed warning listing the names that stayed readable.

Why this matters for LLM-assisted reverse engineering

A human attacker faced with a few hundred lines of bytecode dispatch will usually give up. An LLM given the same file will not bother attacking the bytecode at all - it will read the names, cross-reference the few string literals it can see, and emit something like:

"This module gates a paid feature. validateLicense verifies a signed token, checkExpiry rejects expired licenses, and activateFeature unlocks the UI once the check passes. The decryption helper is in decryptPayload."

That summary is enough for an attacker to plan a targeted bypass without ever touching the VM. The names are the leak.

The fix: wrap your code in an IIFE

The simplest, most robust way to remove this leak is to push your sensitive functions one level deeper into the scope tree. Functions declared inside another function are not root-level, so the obfuscator is free to rename them and absorb their declarations into bytecode like any other statement.

An IIFE (Immediately-Invoked Function Expression) is the lightest-weight way to do that - it adds a single wrapping function that runs once and exposes nothing by name.

Before - names exposed

JavaScript

After VM obfuscation, both validateLicense and checkExpiry survive by name in the output.

After - names hidden behind an IIFE

JavaScript

Now both function declarations live inside the IIFE's body. The IIFE itself is the only root-level construct, and an anonymous IIFE has no name to leak. After VM obfuscation the function names are renamed and the bodies are converted to bytecode.

What changed. The functions are no longer reachable as globals (which is what was leaking their names). They're still callable - just from inside the same IIFE. If something genuinely needed to call validateLicense from outside, it can no longer reach it; see the trampoline pattern below. If nothing did, you've lost nothing.

Exported names, reserved identifiers, strings, and observable behavior may remain visible. Test integrations after wrapping, especially globals, module exports, and code that inspects function names.

One trade-off to know: if the IIFE body contains a direct eval, or a new Function(...) / Function(...) call with a dynamic body, VM obfuscation skips that whole function and everything nested inside it (a VMDynamicCodeSkipped warning), because the runtime-built source might reference identifiers the obfuscator renamed. The code you just moved inside the IIFE then falls back to ordinary obfuscation and loses its bytecode protection. Use indirect eval - (0, eval)(...) - or see direct eval under VM obfuscation for the options.

What if a function genuinely needs to be a global?

Sometimes a function really is a public entry point - an inline event handler, a JSONP callback, a third-party SDK hook. You have two choices:

  • Expose a thin trampoline, keep the logic inside the IIFE. Declare a small global wrapper whose only job is to call into the IIFE-scoped implementation. The trampoline name still leaks, but it carries no semantic information - name it __entry1 or similar - and all the meaningful logic stays hidden.

    JavaScript

  • Rewrite the call site. If the global only exists because an inline onclick="validateLicense(...)" needs it, replace the inline handler with addEventListener from inside the IIFE. The HTML stops naming the function, the function stops needing to be global, and the leak disappears entirely.

Top-level variable initializers: vmWrapTopLevelInitializers

Function declarations are not the only thing that lives at the root of a file. Top-level variable initializers - string constants, configuration objects, lookup tables - are equally readable in the output when they stay plain JavaScript. A line like const API_BASE = '/api/v2/license' tells an LLM as much as function validateLicense does.

The vmWrapTopLevelInitializers option (boolean, default false; current VM presets enable it) wraps eligible top-level initializers in an IIFE so the value itself is computed by VM bytecode at runtime instead of sitting in the source as a literal. In root mode, initializers that stay in plain JavaScript are reported with a VMTopLevelInitializerNotVirtualized warning.

Without the option

JavaScript

With vmWrapTopLevelInitializers: true

JavaScript

The binding name (MY_STRING) is still root-level for the same reason function names are - something outside the file might reference it - but the value it holds is now produced by the VM and no longer appears as readable text.

Only effective when vmTargetFunctionsMode is 'root' (the default) and vmAsyncExecutor is off. In comment mode the option has no effect and no warning is reported. In root mode under vmAsyncExecutor (which virtualizes only async functions) it also has no effect, and the build reports VMTopLevelInitializerNotVirtualized for the initializers left in plain JavaScript.

If a file has no function for the VM to virtualize, the result reports VMNoFunctionsToVirtualize; wrap the code you want to protect in a function, or use comment mode to select sensitive functions explicitly.

When this isn't enough

  • Imported names from other modules. If you bundle multiple files and one module exports validateLicense for another to import, the bundler will keep that name visible in the bundled output the same way root-level functions are visible. Wrap the bundle itself in an IIFE (most bundlers can do this), or move the export inside an IIFE and re-expose it via a meaningless trampoline.
  • The runtime is still observable. Obfuscation increases the effort needed to understand and modify code; it cannot guarantee that reverse engineering is impossible. Keep secrets and authoritative security decisions on the server.

Don't reach for renameGlobals first. It exists, and it will rename root-level identifiers - but it has no way to know which of those identifiers are referenced from outside the file (other bundles, inline HTML, dynamic lookups). Turning it on often breaks integration in subtle ways. The IIFE wrap is safer: it doesn't rename anything global, it just stops creating globals you didn't need.