Title: Non-deterministic `?.()` miscompile drops short-circuit on captured variable in nested closure · Issue #1408 · javascript-obfuscator/javascript-obfuscator · GitHub
Open Graph Title: Non-deterministic `?.()` miscompile drops short-circuit on captured variable in nested closure · Issue #1408 · javascript-obfuscator/javascript-obfuscator
X Title: Non-deterministic `?.()` miscompile drops short-circuit on captured variable in nested closure · Issue #1408 · javascript-obfuscator/javascript-obfuscator
Description: With controlFlowFlattening: true, the obfuscator non-deterministically miscompiles optional-call expressions of the shape captured?.(arg) — where captured is an outer-scope variable referenced inside a nested closure. On some random seed...
Open Graph Description: With controlFlowFlattening: true, the obfuscator non-deterministically miscompiles optional-call expressions of the shape captured?.(arg) — where captured is an outer-scope variable referenced insi...
X Description: With controlFlowFlattening: true, the obfuscator non-deterministically miscompiles optional-call expressions of the shape captured?.(arg) — where captured is an outer-scope variable referenced insi...
Opengraph URL: https://github.com/javascript-obfuscator/javascript-obfuscator/issues/1408
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"Non-deterministic `?.()` miscompile drops short-circuit on captured variable in nested closure","articleBody":"With `controlFlowFlattening: true`, the obfuscator non-deterministically miscompiles optional-call expressions of the shape `captured?.(arg)` — where `captured` is an outer-scope variable referenced inside a nested closure. On some random seeds the `?.` short-circuit is dropped, and the obfuscated code calls `undefined` as a function, throwing `TypeError: \u003cX\u003e is not a function`. The bug reproduces on roughly 5% of obfuscation runs with identical input and identical options, on both `5.4.1` and `5.4.2`. Four other shapes of optional call exercised in the attached reproducer (object property, awaited, `this.member`, chained `?.`) never reproduce — only the captured-variable-in-nested-closure shape is affected.\n\n## Expected Behavior\nThe obfuscated output should preserve the runtime semantics of the input. For `fn?.(arg)`, that means evaluating to `undefined` when `fn` is `undefined` (the `?.` short-circuit), with no call performed.\n\n## Current Behavior\nOn some random seeds, the obfuscator rewrites `captured?.(arg)` — where `captured` is an outer-scope variable referenced inside a nested closure — in a way that drops the `?.` short-circuit and invokes the undefined value as a function. The obfuscated code throws `TypeError: \u003cobfuscated-name\u003e is not a function` at runtime instead of short-circuiting to `undefined`.\n\nThe miscompile is non-deterministic across runs with identical input and identical options because the obfuscator picks a fresh random seed per invocation. Only some seeds trigger it. Across 200 iterations on the attached reproducer, the failure rate is **10/200 (5.0%)** on both `5.4.1` and `5.4.2`.\n\nThe bug is narrowly shape-specific: across the same 200 iterations, four other optional-call shapes (`obj.handler?.()`, `await cb?.()`, `this.cb?.()`, chained `obj.inner?.handler?.()`) **never** reproduce. Only the captured-variable-in-nested-closure shape is affected.\n\n\n## Steps to Reproduce\n1. Save the file in [Minimal working example](#minimal-working-example-that-will-help-to-reproduce-issue) below as `standalone.mjs`.\n2. `npm install javascript-obfuscator`\n3. `node standalone.mjs 200`\n\nExpected output: all 200 iterations pass.\nActual output:\n\n```\n=== Summary over 200 iterations ===\nruns with any failure: 10/200 (5.0%)\n shape1: 10/200 fail (5.0%) — example: THROW: _0x40002d is not a function\n shape2: 0/200 fail (0.0%)\n shape3: 0/200 fail (0.0%)\n shape4: 0/200 fail (0.0%)\n shape5: 0/200 fail (0.0%)\n```\n\nThe script prints the path to a tmp directory at the end; failing iterations' obfuscated bytes are kept there for inspection.\n\n## JavaScript Obfuscator Edition\n- [x] JavaScript Obfuscator Open Source\n- [ ] JavaScript Obfuscator Pro via API or [http://obfuscator.io](http://obfuscator.io)\n\n## Your Environment\n* Obfuscator version used: **5.4.2** (also reproduces identically on `5.4.1`)\n* Node version used: **23.x**\n* OS: macOS (darwin ARM64), but the bug is not platform-specific — the obfuscator is fed deterministic input bytes and produces non-deterministic output regardless of host.\n\n# Stack trace\nThe throw site as it appears in the obfuscated output of a failing iteration (function names are seed-dependent, but the shape is consistent):\n```\nTypeError: _0x40002d is not a function\n at \u003canonymous\u003e (source.17.mjs:1:NNNN) // inner closure body, where `captured?.(msg)` should have short-circuited\n at main (harness.17.mjs:1:NNN)\n```\nNote: with `controlFlowFlattening: true` the surrounding call stack is flattened through dispatch-table dispatchers, so additional frames may appear depending on the seed. The throw itself is always at the optional-call site.\n\n## Minimal working example that will help to reproduce issue\nAlso available as a gist: \u003chttps://gist.github.com/Thorsten-Kd/5b2a0b33932dc7534f827bd6dd5b41c5\u003e\n`standalone.mjs` — self-contained, only depends on `javascript-obfuscator`:\n\n```js\nimport { spawnSync } from 'node:child_process';\nimport * as fs from 'node:fs';\nimport * as os from 'node:os';\nimport * as path from 'node:path';\n\nimport JavaScriptObfuscator from 'javascript-obfuscator';\n\nconst ITERATIONS = parseInt(process.argv[2] ?? '200', 10);\n\nconst OBFUSCATION_OPTIONS = {\n compact: true,\n controlFlowFlattening: true,\n controlFlowFlatteningThreshold: 1.0,\n deadCodeInjection: true,\n deadCodeInjectionThreshold: 1.0,\n identifierNamesGenerator: 'hexadecimal',\n log: false,\n numbersToExpressions: true,\n selfDefending: false,\n simplify: false,\n splitStrings: true,\n splitStringsChunkLength: 5,\n stringArray: true,\n stringArrayCallsTransformThreshold: 1.0,\n stringArrayEncoding: ['base64'],\n stringArrayIndexShift: true,\n stringArrayRotate: true,\n stringArrayShuffle: true,\n stringArrayThreshold: 1,\n stringArrayWrappersChainedCalls: true,\n stringArrayWrappersCount: 5,\n target: 'node',\n unicodeEscapeSequence: true,\n};\n\nconst SOURCE = `\n// Shape 1: captured outer-scope variable called via \\`?.()\\` inside a\n// nested closure. This is the shape that reliably reproduces the bug.\nexport function shape1() {\n const captured = undefined;\n const wrap = (msg) =\u003e {\n captured?.(msg);\n return 'ok';\n };\n return wrap('payload');\n}\n\nexport function shape2() {\n const obj = {};\n obj.handler?.('arg');\n return 'ok';\n}\n\nexport async function shape3() {\n const cb = undefined;\n await cb?.();\n return 'ok';\n}\n\nexport class Foo { run() { this.cb?.('arg'); return 'ok'; } }\nexport function shape4Run() { return new Foo().run(); }\n\nexport function shape5() {\n const obj = { inner: undefined };\n obj.inner?.handler?.();\n return 'ok';\n}\n\n// Padding so controlFlowFlattening has enough basic blocks to do\n// non-trivial rewiring. Tiny inputs sometimes don't trigger the\n// miscompile because the dispatch table CFF builds is too small.\nfunction noise(n) {\n let acc = 0;\n for (let i = 0; i \u003c n; i++) {\n if (i % 2 === 0) acc += i;\n else if (i % 3 === 0) acc -= i;\n else acc ^= i;\n if (acc \u003e 1000000) acc = 0;\n }\n return acc;\n}\nfunction processItems(items) {\n const out = [];\n for (const item of items) {\n const tag = item.tag ?? 'untagged';\n const value = item.value ?? 0;\n if (tag === 'a') out.push(value * 2);\n else if (tag === 'b') out.push(value + 10);\n else out.push(value);\n }\n return out;\n}\nclass Store {\n constructor() { this.data = new Map(); }\n set(k, v) { this.data.set(k, v); }\n get(k) { return this.data.get(k); }\n has(k) { return this.data.has(k); }\n *values() { for (const v of this.data.values()) yield v; }\n}\nexport function exercisePadding() {\n noise(100);\n processItems([{ tag: 'a', value: 1 }, { tag: 'b', value: 2 }, { value: 3 }]);\n const s = new Store();\n s.set('x', 1);\n return s.has('x');\n}\n`;\n\nconst HARNESS_TEMPLATE = `\nimport { shape1, shape2, shape3, shape4Run, shape5, exercisePadding } from './SOURCE_PLACEHOLDER';\nasync function main() {\n exercisePadding();\n const results = { shape1: 'unset', shape2: 'unset', shape3: 'unset', shape4: 'unset', shape5: 'unset' };\n try { results.shape1 = shape1(); } catch (e) { results.shape1 = 'THROW: ' + e.message; }\n try { results.shape2 = shape2(); } catch (e) { results.shape2 = 'THROW: ' + e.message; }\n try { results.shape3 = await shape3(); } catch (e) { results.shape3 = 'THROW: ' + e.message; }\n try { results.shape4 = shape4Run(); } catch (e) { results.shape4 = 'THROW: ' + e.message; }\n try { results.shape5 = shape5(); } catch (e) { results.shape5 = 'THROW: ' + e.message; }\n console.log(JSON.stringify(results));\n}\nmain();\n`;\n\nconst tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'obf-repro-'));\nconsole.log(`workdir: ${tmpDir}`);\nconsole.log(`node: ${process.version}`);\n\nconst SHAPES = ['shape1', 'shape2', 'shape3', 'shape4', 'shape5'];\nconst failsByShape = Object.fromEntries(SHAPES.map((s) =\u003e [s, 0]));\nlet runsWithAnyFail = 0;\nconst examples = {};\n\nfor (let i = 1; i \u003c= ITERATIONS; i++) {\n const obfPath = path.join(tmpDir, `source.${i}.mjs`);\n const harnessPath = path.join(tmpDir, `harness.${i}.mjs`);\n const obfuscated = JavaScriptObfuscator.obfuscate(SOURCE, OBFUSCATION_OPTIONS).getObfuscatedCode();\n await fs.promises.writeFile(obfPath, obfuscated);\n const harness = HARNESS_TEMPLATE.replace('SOURCE_PLACEHOLDER', `./source.${i}.mjs`);\n await fs.promises.writeFile(harnessPath, harness);\n\n const proc = spawnSync(process.execPath, [harnessPath], { encoding: 'utf-8', timeout: 10_000 });\n let results;\n try { results = JSON.parse(proc.stdout?.trim() ?? ''); }\n catch {\n runsWithAnyFail++;\n console.log(`[${i}/${ITERATIONS}] FAIL (no JSON) stderr=${(proc.stderr ?? '').slice(0, 200)}`);\n continue;\n }\n const failed = SHAPES.filter((s) =\u003e results[s] !== 'ok');\n if (failed.length \u003e 0) {\n runsWithAnyFail++;\n for (const s of failed) {\n failsByShape[s]++;\n examples[s] ??= results[s];\n }\n console.log(`[${i}/${ITERATIONS}] FAIL ${failed.join(',')}`);\n } else {\n console.log(`[${i}/${ITERATIONS}] PASS`);\n await fs.promises.unlink(obfPath);\n await fs.promises.unlink(harnessPath);\n }\n}\n\nconsole.log(`\\n=== Summary over ${ITERATIONS} iterations ===`);\nconsole.log(`runs with any failure: ${runsWithAnyFail}/${ITERATIONS} (${((runsWithAnyFail / ITERATIONS) * 100).toFixed(1)}%)`);\nfor (const s of SHAPES) {\n const n = failsByShape[s];\n console.log(` ${s}: ${n}/${ITERATIONS} fail (${((n / ITERATIONS) * 100).toFixed(1)}%)${n \u003e 0 ? ` — example: ${examples[s].slice(0, 120)}` : ''}`);\n}\nconsole.log(`\\nbroken artifacts (if any) kept in: ${tmpDir}`);\n```\n\n## Suspected cause (speculation)\n\nTypeScript and modern JS engines lower `f?.(arg)` to a guarded call shape roughly equivalent to:\n\n```js\n(f === null || f === void 0) ? void 0 : f.call(undefined, arg)\n```\n\n`controlFlowFlattening` rewrites the ternary into a dispatch-table-keyed control flow graph. Under some random keyings, the dispatch branch corresponding to the `=== void 0` guard appears to collapse into the unguarded call — possibly during a dead-branch elimination pass that proves one arm \"unreachable\" using the wrong invariant. The fact that `simplify: false` does *not* prevent the miscompile rules out the simplify pass as the cause; the problem is in (or interacts with) `controlFlowFlattening` itself.\n","author":{"url":"https://github.com/Thorsten-Kd","@type":"Person","name":"Thorsten-Kd"},"datePublished":"2026-05-19T18:00:54.000Z","interactionStatistic":{"@type":"InteractionCounter","interactionType":"https://schema.org/CommentAction","userInteractionCount":3},"url":"https://github.com/1408/javascript-obfuscator/issues/1408"}
| route-pattern | /_view_fragments/issues/show/:user_id/:repository/:id/issue_layout(.:format) |
| route-controller | voltron_issues_fragments |
| route-action | issue_layout |
| fetch-nonce | v2:f4314c7a-40d5-bec6-49f7-4a3a818aaee8 |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | 8D82:19CEC6:882978:C2A61D:6A633730 |
| html-safe-nonce | 0ee86aca5227b38fd411c339bf85a59085b53a3a666d346fc200e22982e65a78 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiI4RDgyOjE5Q0VDNjo4ODI5Nzg6QzJBNjFEOjZBNjMzNzMwIiwidmlzaXRvcl9pZCI6IjE3OTU4ODExOTMyNzM1NjcyMSIsInJlZ2lvbl9lZGdlIjoiaWFkIiwicmVnaW9uX3JlbmRlciI6ImlhZCJ9 |
| visitor-hmac | ec2f999c95977638f0f4f1ec09bb24dbda68bb5946cf32e373ea4e5b4fd102dd |
| hovercard-subject-tag | issue:4479782460 |
| github-keyboard-shortcuts | repository,issues,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/_view_fragments/issues/show/javascript-obfuscator/javascript-obfuscator/1408/issue_layout |
| twitter:image | https://opengraph.githubassets.com/8cc8ed12989737e08ec377e87c74a1ea2fc720c364158e105c10ff8e4f206476/javascript-obfuscator/javascript-obfuscator/issues/1408 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/8cc8ed12989737e08ec377e87c74a1ea2fc720c364158e105c10ff8e4f206476/javascript-obfuscator/javascript-obfuscator/issues/1408 |
| og:image:alt | With controlFlowFlattening: true, the obfuscator non-deterministically miscompiles optional-call expressions of the shape captured?.(arg) — where captured is an outer-scope variable referenced insi... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | Thorsten-Kd |
| hostname | github.com |
| expected-hostname | github.com |
| None | 59e55daad7174ca59d63c6974d58276ccb5477442e550bebb3c035e1bef11c94 |
| turbo-cache-control | no-preview |
| go-import | github.com/javascript-obfuscator/javascript-obfuscator git https://github.com/javascript-obfuscator/javascript-obfuscator.git |
| octolytics-dimension-user_id | 23015672 |
| octolytics-dimension-user_login | javascript-obfuscator |
| octolytics-dimension-repository_id | 58360147 |
| octolytics-dimension-repository_nwo | javascript-obfuscator/javascript-obfuscator |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 58360147 |
| octolytics-dimension-repository_network_root_nwo | javascript-obfuscator/javascript-obfuscator |
| turbo-body-classes | logged-out env-production page-responsive |
| disable-turbo | false |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | 990295d92a4cc7b63fbbd83a046217cd7d77d49c |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
Links:
Viewport: width=device-width