Obfuscatorx - #215
Draft
echo094 wants to merge 13 commits into
Draft
Conversation
Obfuscators pack a branch onto one line by rewriting it into an operator - `if (c) f()` becomes `c && f()`, a two-branch `if` becomes a ternary. That is semantics-preserving, but it destroys the statement boundary that every statement-level matcher navigates by, so a decoder has to put it back before anything else can read shape. Each visitor does one rewrite and carries its own position gate, which is the whole safety argument: the node must be the entire statement, never a value. Measured over 432 samples of javascript-obfuscator output, that gate matches 435 sites and declines 1090, of which 895 are conditionals in value position - an ungated pass is wrong on every one of them. They decline rather than stop. Halting on an unexpected parent aborts the entire Babel traversal, not the subtree, so on this input the first declined site would end the pass before it had normalized anything. Cases follow the existing -valid/-invalid convention, with the invalid half asserting byte-identical output: the `if`/`while`/`for` test positions, an arrow concise body, declarator, argument, operand and property positions, and `||`, which has no single-branch `if` form and is deliberately unhandled. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Schedules the atomic visitors to reverse javascript-obfuscator's Simplifying stage. Holds no rewrites of its own - what is obfuscator-specific is which visitors run, in what order, and that they run to a fixpoint. The fixpoint is required rather than tidy, because the rewrites unlock each other. The encoder nests its own outputs: a source `if` inside an `if` is emitted as `t && (x(), c ? a() : b());`, where the conditional sits in a sequence inside a `&&`. Nothing can reach it until the `&&` is reversed, which creates a statement; re-bracing then gives that statement a block; only then can the sequence split; only then is the conditional in statement position. The round count is the nesting depth, not a constant. Verified against the frozen corpus rather than by construction: applied to the existing obfuscator plugin's decoded output over the 2.19.0 column, it takes the normalization census to zero on all eight axes, and all 54 cells still reproduce their fixture's output exactly. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Shape-first: it matches the union of emitted shapes and resolves the entrypoint, reading the era off whichever matched rather than consulting one to decide what to match. Reports three outcomes rather than a boolean, because collapsing absence into failure is what makes a nested sample undiagnosable. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Detection resolved the entrypoint, but two of the handles it returned were the node a shape was *read* from rather than the node a remover has to act on, and both differences are removal-safety: - the plain-declaration holder came back as the VariableDeclaration. Removing it would take any sibling declarator with it, and `var a = 1, ARR = ['..']` is a legal spelling. It is now matched and returned as the declarator. - the rotator came back only as the statement it sits in. Adjacent-statement merging fuses the IIFE with the statement after it, so removing that statement takes the fused-in effect - a setInterval, on a sample enabling a timer - with it. The call is returned alongside, and it is also what now defines "inside the machinery", since a wrapper reference in a fused-in effect is a real use site rather than machinery. The undeclared-alias list is returned as well as noted, because a consumer has to gate on it rather than log it: an alias with no binding has call sites nothing can enumerate, so deleting the machinery after missing one breaks the program where declining would have left countable residue. Wrapper resolution now yields the wrapper node instead of a boolean. With several encodings configured there are several root wrappers, and which one a site reached decides which decode body evaluates it - resolution already has that answer. No change to what is matched: over the frozen corpus every cell reports the same status and the same signature as before, one per encoder version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Replaces every root-wrapper call with the string it returns and deletes the machinery that produced it. The strings are recovered by running the encoder's own code - the subsystem's source is captured, evaluated in an isolate built per decode, and each call site decoded by evaluating that call - rather than by reimplementing rotation, the index arithmetic and the none/base64/rc4 encodings. The reason is generality over three axes that are shape-only: the encodings, the eras (the holder, wrapper and rotator shapes move independently while their semantics never change, which is why extraction is era-dependent and decoding is era-invariant), and modified variants, which still run but no longer match a model keyed on the stock algorithm. One run decodes exactly one layer; there is no internal loop. The dangerous failure on a re-obfuscated sample is a pass mutating the layer beneath it, so the layer boundary is left observable, and layers need not come from the same encoder anyway. Four outcomes rather than two, because three of them are not failures: `absent` is what a terminating peel round reports, and `unowned` keeps a sample merely awaiting a later unit distinguishable from a matcher failure. Everything short of a complete decode leaves the tree untouched - a half-resolved string array manufactures two entities out of one for every matcher downstream. Two guards catch the failure a residue census cannot see, and both work with no expected output: a compare-loop rotator cannot terminate on an array it was not written for, so the eval timeout reads an incomplete extraction; and a decoded string in computed-member-key position must be a valid identifier, which is the general check since rotation is optional. Four gates run before anything is touched - an undeclared alias, a foreign scope wrapper, a rotator that does not rotate this holder, and a read of the array from outside the machinery. Verified against the frozen corpus rather than by construction. Over every encoder version the outcome partition is identical; the six-axis residue census reads zero on every cell reported decoded, against a non-zero control on the same inputs, while the cells reported unowned read exactly their control. The decoded output reproduces each fixture's own reported lines, excluding the anti-tamper cells that hang on reformatted output until a later unit strips them. Each refusal path was reached by a damaged input and landed on the guard it was written for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…derived cases The claims come from javascript-obfuscator's own StringArrayTransformer.spec.ts and StringArrayRotateFunctionTransformer.spec.ts rather than from cases invented here: a fixture pins a claim, and one written to match what the pass already does pins the pass to itself. Mining that list turned up coverage the frozen corpus cannot reach. `stringArrayIndexShift` and `stringArrayIndexesType` appear in exactly two option sets, and both are blind — the maximal profile's array is starved by its own `splitStringsChunkLength` so it has no live call site, and its raised-chunk sibling is function-form and therefore unowned. So the index shift and the numeric-string index spelling had never been decoded with a live use site. Three of the four outcomes are not failures and have no obvious golden, so `absent`, `unowned` and `unreadable` assert a status plus a tree that came through byte-identical, measured against a normalize-only run of the same bytes rather than against the raw input. Each of the five refusal cases also asserts the note, because all five report `unreadable` and a case can otherwise land on another guard and still look right. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…eclarator Babel's own removal hook deletes the parent VariableDeclaration when the removed declarator is the only one, so the declaration is either already gone or still holds at least one declarator - the empty-parent branch could never run. Left as a comment saying why, because the guard read as load-bearing and the next reader would either trust it or move it. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…pers A scope wrapper is a wrapper around the root wrapper, so it is one more piece of machinery to extract rather than a new kind of reversal: the detector now resolves them into handles instead of counting them as blockers, and they join the prelude, the call-site set and the removal. Membership is a fixpoint grown outward from the root wrappers, because the shape alone does not identify one - a plain forwarding function is a legal thing for a program to contain, and what makes it ours is that its chain ends at a root wrapper. Each is lifted under a synthetic name, since mangled output reuses short names across sibling scopes and they all land in one isolate scope. Numeric-string operands are folded where the operator coerces, which hexadecimal-numeric-string indexes need and which nothing before this reached: with the variable form the index stays positive and arrives as a bare string literal, so only a scope wrapper's negative offset produces the spelling. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…eam's cases Seven cases, each naming the describe() in the encoder's own StringArrayScopeCallsWrapperTransformer.spec.ts that asserts the shape. Four reach shapes the corpus cannot: mangled names, which is what the synthetic lifted names exist for; a three-level chain; a const program; and a prohibited scope. The mangled case independently reproduces the value upstream's own spec asserts. wrappers-function moves from unowned to decoded, and its reporter is corrected: it called a string, so both sides threw. That went unseen while the case was unowned, because the runtime oracle only runs for decoded - the builder refused the moment U3 decoded it, which is what the refuse-until-verified contract is for. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…ing for The gate has no input below javascript-obfuscator 3.2.0 because a 2.x use site is constant by construction, so from inside the decoder it reads as dead code. It is not: stringArrayCallsTransform rewrites a site's literal arguments into a control-flow storage member read, which is exactly what isEvaluableArg refuses. Saying so where someone would otherwise delete it. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Three single-rewrite visitors for the shapes an obfuscator produces when
it moves a static property name into a computed string so a string-
concealing pass can reach it: member un-computing, property-key
un-computing, and shorthand collapse.
None is specific to any obfuscator, so all three live in visitor/atomic/
and any plugin may compose them.
Key un-computing delegates its dangerous half to safeFunc's
uncomputeStringKey rather than reimplementing the exclusion list. Three
keys change meaning without brackets -- an object literal's __proto__, a
class's constructor, and a static prototype -- and separating
un-computing from de-literalizing is what confines the risk to one step,
since a key that is already non-computed can be de-literalized freely.
Shorthand collapse carries the mirror-image exclusion: in an object
literal { __proto__: x } sets the prototype while the shorthand form
defines an own property, so that one name is refused.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Schedules the reversal of javascript-obfuscator's Converting stage. Ten of that stage's eleven transformers rewrite a single node, and none of those reversals is encoder-specific, so this file holds only the ordering -- which of them run, in what order, and that the group runs to a fixpoint. Iteration is required rather than tidy: with splitStrings on, a property name is emitted as a chain rather than a string literal, so nothing can un-compute it until the chain has been folded. On one corpus cell the count of un-computable member reads rises from 33 to 365 once folding has run. Termination compares the tree between rounds instead of counting reported changes, because two of the six passes are shared visitors with no change signal and must not be edited to add one; counting only the rest would exit a round early whenever those two were the only ones to fire. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…encoder-derived cases Four goldens built from real 2.19.0 output for source shapes taken from upstream's own converting-transformer fixtures, plus three unit cases for hazards no encoder output can reach. The goldens come from a builder that refuses: it writes nothing unless the decoded output runs and reproduces the pre-obfuscation source's output, since exact string equality would otherwise make a wrong decode look authoritative. The class-member case covers ground the corpus cannot -- no corpus input declares a class -- and includes upstream's own 'constructor' spelling. The three hazard cases cover the keys that change meaning when brackets are added or removed, each paired with a safe sibling so the guard cannot pass vacuously. The pattern case pins a negative worth knowing: shorthand collapse can never fire on this encoder, because the expansion exists so renaming can separate key from value and renaming then does, at either setting of renameGlobals. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.