Skip to content

Identifiers inside functions nested in class field initializers are neither renamed nor preserved (stringArray produces X is not a function) #1462

Open

Description

@NotYoojun

Expected Behavior

Identifiers declared in a function nested inside a class field initializer, such as the parameter b in static staticField = register({ onChange: (b) => { ... } }), are renamed like identifiers in any other function, and the obfuscated code runs correctly. The minimal example below should print foobar.

Current Behavior

Those identifiers are neither renamed nor preserved, so a generated name can take the same name. In the example below, the string array calls wrapper is also named b. The string array scope calls wrapper j inserted into the nested function therefore receives the shadowing parameter instead of the calls wrapper, and the code throws TypeError: j is not a function:

class Foo
{
    static [i(0xc0)] = register
    ({ 
        'onChange': (b) => 
        { 
            var j = b; 
            return b[j(0xb6)]+b[j(0xb5)]; 
        } 
    }); 
}

// ...
function b(c,d){c=c-0xb4;var e=a();var f=e[c];return f;}

The same function written inside a class method instead of a class field initializer has its parameter renamed correctly.

This is easy to hit with minified input: minifiers commonly name the first parameter e, and with mangled or mangled-shuffled identifier names the string array calls wrapper can get the same single-letter name. Our production build crashed this way on code shaped like static someProperty = register({ onChanged: e => { ... } }).

Steps to Reproduce

  1. npm install javascript-obfuscator@5.9.0
  2. Save the minimal working example below as repro.js
  3. Run node repro.js

JavaScript Obfuscator Edition

  • JavaScript Obfuscator Open Source

Your Environment

  • Obfuscator version used: 5.9.0 (also reproduced with 5.7.0)
  • Node version used: 24.18.0

Stack trace

TypeError: j is not a function
    at Object.onChange (eval at <anonymous> (repro.js:28:13), <anonymous>:1:758)
    at eval (eval at <anonymous> (repro.js:28:13), <anonymous>:1:804)
    at Object.<anonymous> (repro.js:28:13)
    at Module._compile (node:internal/modules/cjs/loader:1871:14)
    at Object..js (node:internal/modules/cjs/loader:2002:10)
    at Module.load (node:internal/modules/cjs/loader:1594:32)
    at Module._load (node:internal/modules/cjs/loader:1396:12)
    at wrapModuleLoad (node:internal/modules/cjs/loader:255:19)
    at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
    at node:internal/main/run_main_module:33:47

Minimal working example that will help to reproduce issue

const JavaScriptObfuscator = require('javascript-obfuscator');

const code = `
function register (options) {
    return options;
}

class Foo {
    static staticField = register({
        onChange: (b) => {
            return b['value'] + b['suffix'];
        }
    });
}

Foo.staticField.onChange({ value: 'foo', suffix: 'bar' });
`;

const obfuscatedCode = JavaScriptObfuscator.obfuscate(code, {
    identifierNamesGenerator: 'mangled',
    stringArray: true,
    stringArrayThreshold: 1,
    stringArrayWrappersCount: 1,
    stringArrayWrappersChainedCalls: false
}).getObfuscatedCode();

console.log(obfuscatedCode);
console.log(eval(obfuscatedCode));

Cause

ScopeIdentifiersTraverser.traverseScopeIdentifiersRecursive and traverseScopeThroughIdentifiersRecursive return early when currentScope.variableScope.block is not a node with a block lexical scope:

if (!variableLexicalScopeNode) {
return;
}

eslint-scope 8 creates a class-field-initializer scope for every class field value. That scope is its own variable scope, and its block is the initializer expression itself. When the initializer is not a function, the early return also skips all child scopes, including functions nested inside the initializer. class-static-block scopes are not affected because StaticBlock is accepted by isNodeWithBlockLexicalScope.

I have a fix with tests ready and will open a PR referencing this issue.

Workaround

identifiersPrefix avoids the collision with the string array calls wrapper because generated global names get the prefix, but the identifiers inside the initializer are still not renamed, and generated local names can still collide with them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions