← back to writing

JavaScript Dissected: P1 - Trees!

Table of Contents

TL;DR

  • An engine never runs your text - it runs a tree it builds from your text.
  • The pipeline is: source → tokens → AST → bytecode → JIT-compiled machine code.
  • Filters and WAFs reason about strings; the engine reasons about the tree. The gap between the two is where a huge amount of web security lives.
  • Learn to read AST nodes and you can build scanners, bypass naive filters, and deobfuscate hostile code.

Why a hacker reads trees, not text

Text lies. The tree doesn’t. That one idea is why this topic earns the first slot in the series:

  • Filters and WAFs think in strings; engines think in trees. A blocklist that bans the substring alert( dies the moment you reach alert another way - because to the engine, how you spelled it never mattered. Only the tree it produced does.
  • Every SAST tool is an AST tool. Semgrep, CodeQL, ESLint, and Babel plugins don’t pattern-match raw text; they pattern-match tree shapes. Understand the tree and you can write rules that find bugs at scale - or explain exactly why a scanner missed one.
  • Deobfuscation is tree surgery. Obfuscated malware is just an ugly tree. Normalize it (fold constants, rename bindings, inline) and the logic falls out.
  • Engine bugs live below the tree. The AST is the on-ramp to bytecode and the JIT, where the highest-tier browser exploits (type confusion, out-of-bounds) come from. You can’t read those write-ups without this vocabulary.

Look at four lines that all do the same thing:

window.alert(1);
window"alert";
window"al" + "ert";
windowatob("YWxlcnQ=");

Four different strings. A naive filter sees four different things - and the last two contain no literal alert at all. The engine sees the same call, reached through a MemberExpression whose property is sometimes static and sometimes computed at runtime. That gap - between what a string looks like and what tree it produces - is the thing we keep exploiting for the rest of the series. Burn it into memory.

The big picture: from text to running code

Before zooming into any stage, here’s the whole trip your code takes inside a modern engine like V8 (Chrome, Edge, Node.js, Deno). The AST is one station on a longer line:

graph LR
  SRC["Source text"] --> SCAN["Scanner / Lexer"]
  SCAN --> TOK["Tokens"]
  TOK --> PARSE["Parser"]
  PARSE --> AST["AST"]
  AST --> SCOPE["Scope / semantic analysis"]
  SCOPE --> BC["Bytecode (Ignition)"]
  BC --> RUN["Interpret + collect type feedback"]
  RUN -->|gets hot| OPT["Optimizing JIT (TurboFan)"]
  OPT --> MC["Optimized machine code"]
  MC -->|assumption broken| BC

Notice the loop at the end: optimized code can be discarded and sent back to the interpreter (“deoptimization”). That round-trip is a recurring character later in the series. For now we live in the front half: text → tokens → AST.

The three words: Abstract, Syntax, Tree

The acronym maps onto ideas you already half-know:

LetterIdeaOne-line meaning
A - AbstractKeep the essence, drop the noiseA subway map: geographically wrong, structurally perfect.
S - SyntaxThe grammar rules of the languageBreak them and you get a SyntaxError before anything runs.
T - TreeA root, branches, and leavesRoot = whole program; branches = constructs; leaves = literals/names.

“Abstract” is the word people trip on. It just means a representation that keeps what matters and discards the rest. Your indentation, the exact spaces around =, the optional semicolon - all noise. The AST keeps the structure and throws the typography away. A useful contrast:

  • Concrete (what you typed): if (x) { y(); }
  • Abstract (what you meant): an IfStatement whose test is x and whose body calls y. The braces and spaces are implied by the shape; they don’t get their own nodes.

Stage 1 - from characters to tokens (lexing)

The scanner (lexer / tokenizer) walks the source one character at a time and groups characters into tokens: the smallest units that carry meaning. It does zero reasoning about whether your program makes sense - it only answers “what kind of thing is this lexeme?”

Tokens aren’t just labels; each carries a value and a source range. Acorn lets you watch them stream out:

// npm i acorn
const acorn = require("acorn");
for (const t of acorn.tokenizer("let n = 42;")) {
  console.log(t.type.label.padEnd(6), JSON.stringify(t.value ?? ""), `[${t.start},${t.end}]`);
}

Conceptually you get:

TokenTypeValueRange
letKeyword-[0,3]
nIdentifier”n”[4,5]
=Punctuator-[6,7]
42Numeric literal42[8,10]
;Punctuator-[10,11]

Things the lexer quietly handles that bite people later:

  • Whitespace and comments are usually discarded - they never reach the tree.
  • Context-sensitive tokens: a / can begin a division or a regular expression, and { can start a block or an object literal. The scanner resolves these with context, and getting it wrong is a classic source of parser bugs.
  • Automatic Semicolon Insertion (ASI): missing semicolons get “repaired” around this boundary, the root cause of several famously cursed bugs. We’ll revisit ASI under parser differentials.

Mindset: the lexer judges shape, never sense. let let let tokenizes perfectly - it just won’t survive the next stage.

Stage 2 - from tokens to a tree (parsing)

The parser consumes the token stream and assembles a tree using the language’s grammar (production rules describing what a valid program looks like). This is the heavy lifting.

Grammar and precedence

Grammar is why 2 + 3 * 4 is 14, not 20. The parser bakes operator precedence into the tree’s shape, so multiplication binds tighter than addition:

graph TD
  A["BinaryExpression (+)"] --> B["Literal: 2"]
  A --> C["BinaryExpression (*)"]
  C --> D["Literal: 3"]
  C --> E["Literal: 4"]

The tree is the precedence; evaluation just walks it bottom-up.

  • Parser flavors: recursive descent, Pratt, LL vs LR - expand

    These aren’t just academic trivia - they determine how the parser handles ambiguity, which directly affects what trees your exploit can produce.

    Recursive descent: one function per grammar rule, calling each other in a circle that mirrors the grammar’s production rules. Simple, fast, and easy to give great error messages - which is why most production JS engines (V8 included) hand-write one. The downside: left-recursive grammar rules (like Expr → Expr + Term) blow the call stack, so you must rewrite them as loops.

    Pratt parsing (a.k.a. operator-precedence / “top-down operator precedence”): a neat technique layered on top of recursive descent specifically for expressions, where each operator carries a binding power (a number). Higher power = tighter binding. 2 + 3 * 4 works because * has higher binding power than +, so the Pratt parser consumes * 4 as part of the 3 term before + gets a chance to grab it. No separate grammar rules for every precedence level - just a table of operators and their powers. This is the cleanest approach and is used inside V8’s expression parser.

    LL vs LR: academic parser families (top-down vs bottom-up). LL parsers predict which rule to apply based on the next token; LR parsers shift tokens onto a stack and reduce when a complete rule is matched. LR is more powerful (handles left-recursion and more grammars) at the cost of harder-to-read error messages and larger generated code. Tools like ANTLR generate LL parsers; yacc/Bison generate LR. You’ll rarely hand-roll one for JS, but understanding the difference matters when you encounter browser parser bugs that only trigger under LR-like behavior.

    Why this matters for security: A Pratt parser and an LL parser that disagree on binding power for a custom operator would produce different ASTs for the same source text - a parser differential at the grammar level. This is a real bug class (see CVEs in custom DSL parsers embedded in browsers).

Statements vs. expressions

This distinction will matter for the rest of your life as a JS hacker:

  • An expression produces a value: a + b, f(), x ? y : z.
  • A statement performs an action: if (...) {}, return ..., while (...) {}.

A surprising amount of payload craft is really about smuggling an expression into a place that expected something innocent, or forcing the parser into a mode you control.

CST vs. AST (the detail most tutorials skip)

The difference is clearest with a side-by-side example. Take x = (a + b) * c:

CST (Concrete Syntax Tree) - keeps every token:

Program
  AssignmentExpression
    Identifier "x"
    Punctuator "="
    BinaryExpression "*"
      Punctuator "("
      BinaryExpression "+"
        Identifier "a"
        Punctuator "+"
        Identifier "b"
      Punctuator ")"
      Punctuator "*"
      Identifier "c"
    Punctuator ";"

AST (Abstract Syntax Tree) - keeps only meaning:

Program
  AssignmentExpression (=)
    Identifier (x)
    BinaryExpression (*)
      BinaryExpression (+)
        Identifier (a)
        Identifier (b)
      Identifier (c)

The parentheses and operators are gone from the AST. The ( and ) have no node because the tree shape already encodes grouping. The + and * punctuators are gone because the operator is stored in the BinaryExpression.operator field. The semicolon is gone because the tree doesn’t need it.

CST is what you typed; AST is what you meant. Security analysis usually works at the AST level, dropping to the CST only when exact byte positions matter (precise patching, source maps).

What else the parser does

  • Groups tokens into nodes: Statement, Expression, BlockStatement, and so on.
  • Enforces grammar and throws on violations - an unbalanced ) dies here, before runtime.
  • Error recovery: the parsers in your editor try to keep going after an error so you still get autocomplete on the rest of the file.
  • Stays execution-independent: it checks that you wrote something grammatical, not that it’s logical. let x = x; parses fine; whether it’s meaningful is the next stage’s problem.

Anatomy of the tree

One node, dissected

Every node is an object with a type, type-specific fields, and (usually) position data so tools can map back to your source:

{
  "type": "Identifier",
  "name": "x",
  "start": 4,
  "end": 5,
  "loc": { "start": { "line": 1, "column": 4 },
           "end":   { "line": 1, "column": 5 } }
}

That loc/start/end data is gold for offensive tooling: it’s how a scanner points you at the exact byte of a sink, and how a deobfuscator rewrites one node without disturbing the rest.

The ESTree node catalog

Most of the JS ecosystem agrees on a node format called ESTree. Learn these and you can read almost any AST:

Node typeRepresents
Programthe root - the whole file
Identifiera name (x, alert, add)
Literala constant (5, "hi", true, /re/)
BinaryExpressiona + b, x > 5
CallExpressioncalling something: f(x)
NewExpressionnew Thing()
MemberExpressionproperty access: a.b (static) or a["b"] (computed)
AssignmentExpressionel.innerHTML = x
TaggedTemplateExpressiontag... - a function call without ()
FunctionDeclaration / FunctionExpression / ArrowFunctionExpressiondefining a function
BlockStatement / ReturnStatement / IfStatementstructure and control flow
ArrayExpression / ObjectExpression / Property[...] / {...} literals and their members
SpreadElementthe ... in [...x] or f(...args)

A full example

This function:

function add(a, b) { return a + b; }

becomes this tree (position data trimmed):

{
  "type": "FunctionDeclaration",
  "id": { "type": "Identifier", "name": "add" },
  "params": [
    { "type": "Identifier", "name": "a" },
    { "type": "Identifier", "name": "b" }
  ],
  "body": {
    "type": "BlockStatement",
    "body": [
      {
        "type": "ReturnStatement",
        "argument": {
          "type": "BinaryExpression",
          "operator": "+",
          "left":  { "type": "Identifier", "name": "a" },
          "right": { "type": "Identifier", "name": "b" }
        }
      }
    ]
  }
}

Drawn out:

graph TD
  A["FunctionDeclaration: add"] --> P1["param: a"]
  A --> P2["param: b"]
  A --> BODY["BlockStatement"]
  BODY --> R["ReturnStatement"]
  R --> BIN["BinaryExpression (+)"]
  BIN --> L["Identifier: a"]
  BIN --> RT["Identifier: b"]

Every brace, every space, the way you formatted it - gone. What survives is meaning.

Reading an AST like an attacker

Once node types are second nature, you can scan a tree the way you’d triage a target. Here’s the cheat-map I keep in my head:

If you see this node……start asking
CallExpression / NewExpressionWhat’s the callee? Is it eval, Function, setTimeout, import()? This is where execution happens.
MemberExpression with computed: trueThe property name is dynamic - a favorite for hiding eval/constructor from text filters.
AssignmentExpression into innerHTML, outerHTML, location, srcdocClassic DOM-XSS / open-redirect sinks.
TaggedTemplateExpressionA function is being called with no parentheses - easy to overlook.
Literal (string)Hidden code, URLs, or base64/hex-encoded payloads waiting to be decoded.
BinaryExpression (+) on stringsConcatenation building an identifier or payload at runtime ("al"+"ert").
ImportExpressionDynamic import() - remote code loading.

Stage 3 - making sense of it (semantic analysis)

Now the engine reasons about meaning over the tree. This is where the AST becomes more than a data structure - it becomes a map of who refers to what.

Scope chains: how identifiers find their values

Every Identifier node in the tree needs to be linked to the VariableDeclarator or FunctionDeclaration that created it. The engine builds a scope tree parallel to the AST, where each node in the AST (function, block, catch clause, with statement) spawns a new scope.

A simple example - when the parser finishes this:

let x = 1;
function f() {
  let x = 2;
  return x + 1;
}

It produces two scopes: the global scope has x (value 1) and f; the function scope has its own x (value 2). The Identifier x inside return x + 1 resolves to the inner scope’s x, not the global one. That resolution is the scope chain - walk from the current scope outward until you find a declaration for that name.

For a security tool, this is how you distinguish:

  • eval(userInput) where eval is the global function - an alert
  • function eval(){} then eval(userInput) - a local override, possibly intentional, higher false-positive threshold

If your scanner only matches the string eval(, it cannot tell these apart. If it walks the scope chain from the CallExpression and finds a local eval declaration, it knows the alert is lower confidence.

Hoisting and the Temporal Dead Zone

var and function declarations are hoisted - they are added to the scope at entry, before any code runs. let and const are hoisted too but sit in a Temporal Dead Zone (TDZ) from scope entry until the actual declaration line:

console.log(a); // undefined (var is hoisted, initialized as undefined)
console.log(b); // ReferenceError: Cannot access 'b' before initialization

var a = 1;
let b = 2;

The AST for both declarations looks nearly identical (VariableDeclarator). The difference is a flag or internal slot on the binding that the semantic analyzer checks during execution. For scanners, the takeaway: TDZ violations can be detected statically by checking control flow between a ReferenceExpression and its binding’s VariableDeclarator - no need to run the code.

with statements - the scope breaker

The with statement is the most scope-hostile construct in the language:

with (obj) {
  console.log(x); // Is 'x' obj.x, or a local variable?
}

with injects all properties of obj into the scope chain at runtime. The semantic analyzer cannot statically resolve any identifier inside a with block because obj’s shape isn’t known until the program runs. This means:

  • All identifiers inside with are ambiguous. A scanner cannot determine if eval(...) inside with refers to the real global eval or a property named eval on obj. This ambiguity is attack surface - obfuscators sometimes wrap code in with to defeat static analysis.
  • ESLint bans with by default for exactly this reason. If you’re building a scanner, treat any Identifier inside a WithStatement as an unknown - don’t flag it as a sink unless you can prove the scope is clean.

Closures and captured bindings

When a function references a variable from an outer scope, that variable is captured - the closure keeps it alive even after the outer function returns:

function makeCounter() {
  let count = 0;
  return function() { return ++count; };
}

The inner function’s AST has an Identifier node for count. The semantic analyzer marks this binding as closed-over because the Identifier resolves to a scope that outlives the inner function’s creation context. For security scanners, closure analysis matters for taint propagation: if count is captured and modified by attacker-controlled code, taint flows from the outer scope into the closure through the scope link, not through an explicit parameter.

Early errors

Some semantic violations are caught before any code executes:

  • Duplicate parameter names in strict mode
  • await outside an async function
  • let let as a variable name (reserved word in strict mode)
  • Accessing arguments or eval as identifiers in strict mode

These are syntax-like errors that the semantic analyzer raises by inspecting the tree’s binding structure. The AST itself is valid - the grammar allowed it - but the binding rules rejected it.

Why all this matters for taint analysis

A taint-tracking system (like CodeQL’s data-flow library) builds a graph where nodes are AST positions and edges represent “value flows from here to there.” The scope chain defines which edges exist:

  • Assignment edges: AssignmentExpression - the right side’s value flows to the left side’s binding
  • Call edges: arguments flow into parameters (requires matching CallExpression arguments to FunctionDeclaration params via the scope tree)
  • Return edges: ReturnStatement values flow back to the CallExpression that invoked the function
  • Capture edges: variables closed over by a nested function flow into the function’s body without appearing in the parameter list

Every one of these edges is built from the AST + the scope tree. Without understanding scope resolution, you cannot build a correct taint graph. That’s why regex-based scanners find innerHTML = x but miss innerHTML = sanitize(x) when sanitize is a no-op wrapper defined three scopes up.

Performance detail: V8’s lazy parsing / pre-parser

Parsing every function eagerly would waste time on code that may never run. So V8 first does a fast pre-parse: it skims function bodies just enough to find their boundaries and catch syntax errors, without building the full AST. A function gets fully parsed only when it’s actually called. This “lazy compilation” is why code structure affects startup performance - and it occasionally produces subtle, observable timing differences researchers have poked at.

The security implication: lazy parsing can hide malicious code from scanner heuristics. A function that is never called in the initial execution path may never have its full AST parsed, meaning any AST-walking scanner that triggers on page load won’t see the payload inside it.

Once the program checks out, the AST is lowered toward bytecode - the doorway to execution.

After the tree: bytecode, JIT, and deopt

We’ll spend whole posts here, so just the shape for now:

  1. Ignition (V8’s interpreter) lowers the AST into compact bytecode and starts running it immediately, recording type feedback (“this value has been a small integer 1,000 times in a row”).
  2. When a function gets hot, TurboFan (the optimizing JIT) uses that feedback to emit speculative, optimized machine code - fast code that assumes the patterns hold.
  3. If an assumption breaks (“surprise, now it’s a string”), the engine deoptimizes: it throws the fast code away and falls back to the interpreter.

That speculate-then-bail dance is exactly where a category of legendary browser exploits is born. You can watch it yourself:

# See the bytecode Ignition generates:
node --print-bytecode -e "function f(x){return x+1}; f(1)"

# Watch optimization / deoptimization decisions live:
node --trace-opt --trace-deopt your-script.js

Walking the tree: the visitor pattern

A tree is only useful if you can traverse it. Tools visit every node - typically depth-first, which we’ll formalize next time - using the visitor pattern: “whenever you see a node of type X, run this callback.”

Here’s a tiny program that flags every eval call and every innerHTML write in a snippet:

// npm i @babel/parser @babel/traverse
const { parse } = require("@babel/parser");
const traverse = require("@babel/traverse").default;

const code = `eval(userInput); el.innerHTML = data;`;
const ast = parse(code);

traverse(ast, {
  CallExpression(path) {
    if (path.node.callee.name === "eval") {
      console.log("eval() at offset", path.node.start);
    }
  },
  AssignmentExpression(path) {
    const left = path.node.left;
    if (left.type === "MemberExpression" && left.property.name === "innerHTML") {
      console.log("innerHTML write at offset", path.node.start);
    }
  },
});

That tiny program is, conceptually, the entire idea behind a static analyzer. Everything else is just more node types and smarter conditions.

Where this bites in security

Concrete payoffs, so the theory has teeth:

1. Static analysis at scale

Semgrep and CodeQL match tree shapes, not text. A one-line Semgrep rule catches a real DOM-XSS sink:

rules:
  - id: dangerous-innerhtml
    languages: [javascript]
    severity: WARNING
    message: Untrusted data may flow into innerHTML (possible DOM XSS)
    pattern: $EL.innerHTML = $X

This matches document.body.innerHTML = userInput but also el.innerHTML = data - same MemberExpression + AssignmentExpression shape regardless of the objects involved. A regex would need separate patterns for every variable name.

CodeQL takes this further. Its standard DOM-XSS query (DomBasedXss.ql) walks the AST backward from a sink (like innerHTML) through data-flow edges constructed from the tree’s binding relationships. When it finds a path to a source like location.search, you get an alert with the full path - something text search fundamentally cannot do because text has no concept of “this variable flows into that call.”

2. Taint tracking

The next step beyond “find the sink” is “prove attacker data reaches it.” CodeQL models the AST (plus data flow) as a queryable database so you can ask source → sink questions across an entire codebase. Under the hood, CodeQL constructs a flow graph from the AST by tracking which Identifier nodes refer to the same binding (via the scope tree) and which CallExpression arguments propagate to which parameters (via the CallExpressionFunctionDeclaration edges). The result is a directed graph that a reachability query traverses in seconds across millions of lines.

3. Parser differentials & WAF bypass

When a sanitizer’s parser and the browser’s parser disagree about the same bytes, you get mutation XSS. That disagreement is a difference in trees. Classic example - the so-called mXSS using <noscript>:

Input: <noscript><p title="</noscript><img src=x onerror=alert(1)>">

A sanitizer’s HTML parser (e.g., PHP’s strip_tags or a simple regex) tokenizes this sequentially and sees a <noscript> tag, then a <p> tag with a title attribute whose value is "</noscript><img src=x onerror=alert(1)>" - the quote closes at the end, so the content is safely inside an attribute. Safe, it thinks.

The browser’s parser, after the DOM is mutated by script or after innerHTML round-trips, re-parses the serialized output. Now the </noscript> inside the attribute is treated as a real end tag because noscript parsing modes differ across contexts. The result: <img onerror=alert(1)> lands in the DOM as an active element.

The root cause is never a regex mistake - it’s a grammar ambiguity that two different parsers resolve differently. This is exactly the same class of problem as the a["b"] vs a.b distinction in JS ASTs: the surface-level representation (bytes / text) underspecifies the tree.

4. Deobfuscation

Obfuscators transform the AST - they don’t touch your text. Common transforms and their AST-level signatures:

TransformAST signatureReversal
String-array encodingA VariableDeclarator with a Literal array, then every Literal string replaced with a MemberExpression accessing that array by indexConstant-fold by evaluating the array lookups, then replace each MemberExpression with the resolved Literal
Control-flow flatteningA SwitchStatement inside a WhileStatement with a dispatcher variable; basic blocks become CaseClause nodesReconstruct the original control edges by analyzing which BreakStatement leads to which CaseClause
Dead-code injectionBinaryExpression nodes whose operands are always Literal values (e.g., 1 + 2) followed by an IfStatement that never branchesFold constant expressions and prune unreachable IfStatement branches
Identifier renamingAll Identifier nodes replaced with short, nondeterministic namesReconstruct meaningful names from usage context or, for challenge crackmes, symbol recovery via cross-referencing

A real-world example: the JScript obfuscator used in Dridex malware. It stores all string literals in an array, then references them as Z[0x1a], Z[0x7f], etc. A deobfuscator walks the AST, finds the array declaration, evaluates each index access at build time, and substitutes the literal back in. After that, the actual logic - which was invisible behind the indirection - becomes readable source code.

// Before deobfuscation (what the malware ships):
var Z = ["eval", "doc" + "ument", "http://bad.com/payload"];
(function(){ return this[A[0]](A[1]) })();

// After AST-level constant folding:
(function(){ return this["eval"](document) })();

5. Tooling you own

Once you can parse and walk, you can build a scanner tuned to your targets instead of waiting on someone else’s rules. Here’s a 15-line scanner that finds every eval() call and innerHTML assignment in a codebase:

// npm i @babel/parser @babel/traverse glob
const { parse } = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { globSync } = require("glob");

for (const file of globSync("src/**/*.js")) {
  const ast = parse(require("fs").readFileSync(file, "utf-8"), { sourceType: "module" });
  traverse(ast, {
    CallExpression(p) {
      if (p.node.callee.name === "eval")
        console.log(`${file}:${p.node.loc.start.line} eval()`);
    },
    AssignmentExpression(p) {
      const lhs = p.node.left;
      if (lhs.type === "MemberExpression" && lhs.property.name === "innerHTML")
        console.log(`${file}:${p.node.loc.start.line} innerHTML write`);
    }
  });
}

This pattern extends to any sink or source. Want to find all postMessage() calls where the target origin is *? Add a visitor for CallExpression where the callee is postMessage and the second argument is a Literal("*"). Want to flag location.href = where the right side is a template literal with user-controlled variables? Walk the AssignmentExpression and check if the right side is a TemplateLiteral. Every security scanner you’ve ever used is just a larger collection of these tree-walking rules.

Five things that will surprise you

  • Gotchas worth internalizing now — each one maps to a real bypass:

    1. a.b and a["b"] are the same node type. Both are MemberExpression; only the computed flag differs. A WAF that blocks window.eval( but allows window["eval"]( is checking text, not trees. The AST is identical — the scanner must inspect the property node’s value, not the source bytes.

    2. Tagged templates call functions with no (). alert\x`is aTaggedTemplateExpression— a function call without parentheses or even a dot. A filter that looks for the patterneval(…)or.constructor(…)will never match this. Real payloads have used tagged templates to callFunction\return alert(1)“, which executes without a single parenthesis.

    3. The comma operator hides expressions. (evil(), innocent) evaluates evil() first, then yields innocent. A scanner that only looks at the outermost expression will flag innocent and completely miss evil(). This is how payloads smuggle eval() past single-statement scans — wrap it in a comma expression with a benign final operand.

    4. "use strict" is just a string — until it’s a directive. A bare string literal at the top of a function/file becomes a directive prologue that changes semantics. The AST node is the same Literal type as any other string. This means a scanner that counts literal strings to find “code” vs “data” must check the node’s position — is it the first statement in a function? — or it will misclassify directives as runtime strings. Defensively, this matters because strict mode changes which this is, which can invalidate scope-analysis assumptions.

    5. Optional chaining short-circuits. a?.b.c becomes an OptionalMemberExpression. If a is nullish, the whole chain bails to undefinedc is never evaluated. For a taint tracker, this means the path from source to sink may terminate early in a way that plain MemberExpression doesn’t. If your scanner treats a?.b.c the same as a.b.c, you’ll report flows that don’t actually exist at runtime.

Lab - do the work

Part A: get a local AST in two minutes

// npm i acorn
const acorn = require("acorn");
const ast = acorn.parse("var x = 5;", { ecmaVersion: 2020 });
console.log(JSON.stringify(ast, null, 2));

Prefer to click around? Use either tool and watch the tree update as you type:

Part B: deobfuscation warm-up

Can you spot the eval in this without running it?

[]["constructor"]["constructor"]("return 2 + 2")()

That’s three property lookups, one CallExpression, and zero mentions of eval or Function. Every text-based scanner misses it. The tree doesn’t.

Glossary / cheat sheet

TermMeaning
Lexeme / TokenRaw text chunk / its classified form (keyword, identifier, …)
Lexer / ScannerTurns characters into tokens
ParserTurns tokens into a tree using the grammar
CSTConcrete syntax tree - keeps every detail
ASTAbstract syntax tree - keeps only meaning
ESTreeThe community-standard AST node format for JS
NodeOne element of the tree (type plus fields)
VisitorA callback run on each node of a given type during traversal
Ignition / TurboFanV8’s interpreter / optimizing JIT
DeoptDiscarding optimized code when an assumption breaks
Source / SinkWhere attacker data enters / where it does damage

Coming in Part 2

We go deeper into the tree and formalize the DFS (depth-first search) traversal that engines and tools use to visit every node - then start building things that walk it. First we’ll lay down core JavaScript fundamentals and a little engine architecture so the deep dives land.