Skip to content

deadCodeInjection intermittently drops a positional argument across a rest/spread forward (non-deterministic, minified input) #1423

Description

@julianmesa-gitkraken

Summary

With deadCodeInjection: true on already-minified input (bundler output where locals are 1-char identifiers, i.e. effectively the same as identifierNamesGenerator: 'mangled'), a positional argument is intermittently lost across a rest/spread forward ((...args) => fn(...args)): the callee receives one argument as undefined even though the caller passed a defined value.

The corruption is non-deterministic across builds from identical source, because the default seed: 0 re-randomises which AST fragments deadCodeInjection clones each run. The same source "works" in some builds and breaks in others.

This looks related to the historically-fixed scope/clone issues (#708, and the NodeUtils.cloneRecursive / range scope-resolution fix in v5.2.0), and to #262 (non-deterministic undefined / "is not a function" with deadCodeInjection + mangled + seed: 0), but I could not find an open issue with this exact signature (positional arg silently undefined through a spread forward).

Version

  • javascript-obfuscator: 5.4.3 (latest on npm)
  • Node: v24.15.0
  • Input: ESM chunk emitted by a bundler (rolldown/esbuild), already minified

Options

{
  compact: true,
  controlFlowFlattening: true,
  controlFlowFlatteningThreshold: 0.75,
  deadCodeInjection: true,
  deadCodeInjectionThreshold: 0.4,
  stringArray: true,
  stringArrayEncoding: ['base64'],
  stringArrayThreshold: 0.75,
  identifierNamesGenerator: 'mangled',
  selfDefending: false,
  debugProtection: false,
  // seed: 0 (default) — non-deterministic
}

What we observed

Our app dispatches IPC calls through a generic rest/spread forwarder. Source:

// for each registered method:
handleIpc(state, `${group}:${method}`, (e, ...args) =>
  runWithRoute(routeFor(e.sender.id), searchParamsFor(e.sender.id), () =>
    methods[method](...args),   // <-- args forwarded via spread
  ),
);

Minified input handed to the obfuscator (unbroken, semantically correct):

(v,...b)=>qi(o(v.sender.id),a(v.sender.id),()=>p[h](...b))

Obfuscated output from a broken build (deadCodeInjection: true):

(H,...I)=>qi(w(H[auH(0x3932)]['id']),x(H[auH(0x3932)]['id']),()=>F[G](...I))

The structure looks fine, yet at runtime a call made with two defined arguments arrives at F[G] (p[h]) with the second argument undefined.

Concretely, we verified the boundary on both sides in the same broken build:

  • Caller side (before the boundary), logged args: ["/path/to/repo", "5c09f59b-…"] — both defined.
  • Callee side (after the spread forward): arg0 = "/path/to/repo", arg1 = undefined.

Flipping deadCodeInjection: false (everything else unchanged) makes the problem disappear across every build we tried; pinning a non-zero seed makes the outcome reproducible per seed.

Why we can't attach a minimal standalone repro (yet)

We tried hard to reduce it. Obfuscating the isolated chunk that contains the forwarder — byte-for-byte the real bundler output — and executing it across 200 seeds produced zero corruptions. The failure only appears when the whole large chunk (~950 KB, our Electron main-process bundle) is obfuscated as one unit. That strongly suggests the corruption is an emergent property of deadCodeInjection cloning fragments from elsewhere in the same chunk (it "steals a part of code" per #708), so a small extracted snippet has nothing to steal and never triggers it. The full bundle isn't runnable standalone (native requires), which is what has blocked a clean minimal repro.

We understand a non-reproducible report is hard to action. We're filing it because the signature (silent undefined through a spread forward, non-deterministic by seed, only with deadCodeInjection + mangled/minified input) seems like a real miscompile and may ring a bell given #262/#708/v5.2.0.

Offer to help

We can run any instrumentation / logging / debug build you suggest against our real bundle and report back, and we're happy to iterate to help you pin it down (e.g. bisecting seeds, dumping the pre/post AST for a specific node, trying a patched build). If a private channel would be easier for sharing a larger reproduction artifact, we can arrange that too. Just let us know what would be most useful.

Workaround (for anyone hitting this)

Disable deadCodeInjection (it adds little real reverse-engineering resistance) and/or pin a fixed non-zero seed so builds are reproducible and a latent miscompile can't surface only in some releases.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions