Introduction
JSFuck is a prominent JavaScript obfuscation tool that
converts JS code into a rather weird looking form with only six characters:
()+[]!
. For example, console.log(1)
is turned into this:
([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+(![]+[])[!+[]+!+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(+(+!+[]+[+!+[]]+(!![]+[])[!+[]+!+[]+!+[]]+[!+[]+!+[]]+[+[]])+[])[+!+[]]+(![]+[])[!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+(![]+[+[]]+([]+[])[([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+[]]+([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+(!![]+[])[+!+[]]])[!+[]+!+[]+[+[]]]+([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[+!+[]+[!+[]+!+[]+!+[]]]+[+!+[]]+([+[]]+![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[!+[]+!+[]+[+[]]]
Code is made to be practically unreadable, but still retains it’s original functionality. What’s the trick here? How can this possibly work?
Fortunately JSFuck is open source and we can take a look into the source code. We see that no AST level transformation are performed and that source code from the user is being treated as a string. One thing it does is changing substrings from the original source code into a weird form, such as:
false
becomes![]
true
becomes!![]
- Single
e
character becomes(true+"")[3]
- … and so on.
If we copy-paste these weird expressions into JavaScript REPL they do indeed get evaluated to their original form:
$ node
Welcome to Node.js v19.0.1.
Type ".help" for more information.
> ![]
false
> !![]
true
> (true+"")[3]
'e'
To understand what is going on here we must understand the concept of type
coercion. JavaScript is a weakly typed language, which means it provides
some leeway for developers using not exactly correct types. For example, ==
operator allows us to compare between numeric string and actual number:
> "1" == 1
true
If we wanted to be more strict with the comparison here, we could use ===
operator which also checks that both types are the same.
Type coercion is JavaScript language feature that entails applying type conversion implicity on as needed basis. For example, we may have a numeric string that we may want to apply some math on. JS runtime would perform the type conversion for us:
> "100" / 3.14
31.84713375796178
However we must not rely too much on this, as sometimes the results may not be what you would expect:
> "100" + 50
'10050'
Complete discussion of type coercion logic is outside the scope of this text, but some big picture rules are the following:
- Addition operation prefers converting stuff to strings, esp. if one of the sides is a string. As fallback, it tries to convert it to numbers.
- Subtraction operation tries to convert operands to numbers before subtracting them.
- There are truthy values that can be converted to
true
(this is most of all values) and some falsy values that can be converted tofalse
(0
,null
,undefined
,NaN
and empty string). This applies when doing binary logic, e.g. logical NOT operation (![]
is false because empty array[]
is truthy). - Unary operations (single
+
or-
) attempt conversion to numeric value.
For more on this, see the following pages on Mozilla developer portal:
So essentially what happens inside JSFuck is converting each character into these weird JS expressions that evaluate it back to the original value by abusing JavaScript type coercion functionality. But not all characters can be conjured from empty data by applying basic computational operations. Sometimes JSFuck has to play tricks on implementation details of JavaScript engine to obfuscate stuff (more on this later).
Enhanced constant folding
So we want to apply AST-level transformations on code obfuscated with JSFuck
to turn it back to it’s original form (or something close enough). We have
previously tried applying
constant folding
for deobfuscation, so perhaps it can be used here as well? After all, we have
a lot of BinaryExpression
and UnaryExpression
nodes in the AST.
From the previous article we got the following code:
export default function (babel) {
const { types: t } = babel;
return {
name: "ast-transform", // not required
visitor: {
BinaryExpression(path) {
let evaluated = path.evaluate();
if (!evaluated) return;
if (!evaluated.confident) return;
let value = evaluated.value;
let valueNode = t.valueToNode(value);
if (!t.isLiteral(valueNode)) return;
path.replaceWith(valueNode);
}
}
};
}
Let us try applying this on the obfuscated snippet in AST Explorer.
The code becomes:
([]["false"[+[]] + "false"[2] + "false"[+!+[]] + "true"[+[]]] + [])[3] + (!![] + []["false"[+[]] + "false"[2] + "false"[+!+[]] + "true"[+[]]])["10"] + ([][[]] + [])[+!+[]] + "false"[3] + (!![] + []["false"[+[]] + "false"[2] + "false"[+!+[]] + "true"[+[]]])["10"] + "false"[2] + "true"[3] + (+("11" + "true"[3] + [2] + [+[]]) + [])[+!+[]] + "false"[2] + (!![] + []["false"[+[]] + "false"[2] + "false"[+!+[]] + "true"[+[]]])["10"] + ("false0" + ""[([]["false"[+[]] + "false"[2] + "false"[+!+[]] + "true"[+[]]] + [])[3] + (!![] + []["false"[+[]] + "false"[2] + "false"[+!+[]] + "true"[+[]]])["10"] + ([][[]] + [])[+!+[]] + "false"[3] + "true"[+[]] + "true"[+!+[]] + ([][[]] + [])[+[]] + ([]["false"[+[]] + "false"[2] + "false"[+!+[]] + "true"[+[]]] + [])[3] + "true"[+[]] + (!![] + []["false"[+[]] + "false"[2] + "false"[+!+[]] + "true"[+[]]])["10"] + "true"[+!+[]]])["20"] + ([]["false"[+[]] + "false"[2] + "false"[+!+[]] + "true"[+[]]] + [])["13"] + [+!+[]] + ("0false" + []["false"[+[]] + "false"[2] + "false"[+!+[]] + "true"[+[]]])["20"];
Re-applying the transform again does not simplify it any further. Clearly the AST transform code we have from before is not sufficient to undo JSFuck obfuscation. Let us see how it can be improved:
- We only target
BinaryExpression
nodes, butUnaryExpression
nodes also need to be visited. The fix is simple: changeBinaryExpression(path)
to"BinaryExpression|UnaryExpression"(path)
. - JSFuck also abuses string indexing to pull out a character from larger string
(notice
"false"[2]
). Babel does not know how to simplify it without our help. We need to targetMemberExpression
nodes to recognise when this is done and replace the entireMemberExpression
with aStringLiteral
containing a resulting character.
More generally, we tried to make a leap and it didn’t work out. Let’s take another look at JSFuck source code and re-approach it in smaller steps.
Consider the following mapping from the top of jsfuck.js:
const SIMPLE = {
'false': '![]',
'true': '!![]',
'undefined': '[][[]]',
'NaN': '+[![]]',
'Infinity': '+(+!+[]+(!+[]+[])[!+[]+!+[]+!+[]]+[+!+[]]+[+[]]+[+[]]+[+[]])' // +"1e1000"
};
Let us try to deobfuscate the values in this objects back to the original form.
We fixed the node targeting, so ![]
is now converted back into false
and
!![]
is converted back into true
.
However [][[]]
is not simplified any further. That’s because this is neither
binary not unary expression. It is empty array being indexed by empty array.
We can confirm in the JS REPL that this does resolve into undefined
:
> [][[]]
undefined
Since at AST level this is MemberExpression
with empty ArrayExpression
s
at object
and property
fields we can target a MemberExpression
nodes
in our visitor, check both fields for empty arrays and replace it with a
node representing value undefined
.
We add the following code to our AST transform:
MemberExpression(path) {
let node = path.node;
if (t.isArrayExpression(node.object) &&
node.object.elements.length == 0 &&
t.isArrayExpression(node.property) &&
node.property.elements.length == 0) {
path.replaceWith(t.valueToNode(undefined));
}
}
Now we want +[![]]
to be converted to NaN
, but our current code only
converts it to +[false];
.
It might not be immediately obvious, but Babel does not generate NumericLiteral
node for NaN
value when running NodePath.evaluate()
/valueWithNode()
on code like this. Instead, it generates a BinaryExpression
representing
division of zero by zero (0 / 0
). Since we explicitly check if the value we
got is a literal it cannot proceed any further with this. But we also don’t
want our code to run into infinite loop or bottomless recursion by trying to
reduce an irreducible expression. So what we do is we replace the
BinaryExpression
with the result anyway, but if the result node is not
a NumericLiteral
we call NodePath.skip()
to cut off traversal on the new
node.
We modify constant folding code into the following:
"UnaryExpression|BinaryExpression"(path) {
let result = path.evaluate();
if (result.confident) {
let valueNode = t.valueToNode(result.value);
if (t.isLiteral(valueNode)) {
path.replaceWith(valueNode);
} else {
path.replaceWith(valueNode);
path.skip();
}
}
},
Now we got 0 / 0;
in the output, but we still want NaN
.
So we have to replace this binary expression with the appropriate node in the AST. We preempt the expression evaluation with the following code:
let node = path.node;
if (t.isNumericLiteral(node.left) && t.isNumericLiteral(node.right) &&
node.right.value === 0 && node.left.value === 0) {
path.replaceWith(t.identifier('NaN'));
return;
}
Notice the early return here - it’s needed so that code further in the transform
is not attempted, as we are overriding the Babel’s NodePath.evaluate()
stuff
and injecting our own answer. We don’t want it to revert it back to 0 / 0
for
us.
So we got NaN;
in the output now.
The following should be evaluated into Infinity
:
+(+!+[]+(!+[]+[])[!+[]+!+[]+!+[]]+[+!+[]]+[+[]]+[+[]]+[+[]])
Instead, the current transform turns it into:
+(1 + "true"[3] + [1] + [0] + [0] + [0]);
We see something we did not address yet - string indexing operation. We further
improve our MemberExpression
handling code to pull out the right character
for us and replace the MemberExpression
with StringLiteral
:
// https://stackoverflow.com/a/52986361/1044147
function isNumeric(n) {
return !isNaN(parseFloat(n)) && isFinite(n);
}
if (isNumeric(node.property.value)) {
node.property = t.valueToNode(Number(node.property.value));
}
if (t.isStringLiteral(node.object) &&
t.isNumericLiteral(node.property)) {
let character = node.object.value[node.property.value];
path.replaceWith(t.valueToNode(character));
}
Note that we also allow index to be a numeric string (I noticed this sometimes happens in the intermediate results when I was experimenting).
Since we have more complex JS expression to deobfuscate, we now apply the transform multiple times with the following results between steps:
+(1 + "true"[3] + [1] + [0] + [0] + [0]);
+(1 + "e" + [1] + [0] + [0] + [0]);
1 / 0;
The last expression cannot be simplified by the AST transform as it is, but
a fix is trivial with one more evaluation override that further improves what
we did in the NaN
case:
if (t.isNumericLiteral(node.left) && t.isNumericLiteral(node.right)) {
if (node.right.value === 0) {
if (node.left.value === 1) {
path.replaceWith(t.identifier('Infinity'));
return;
} else if (node.left.value === 0) {
path.replaceWith(t.identifier('NaN'));
return;
}
}
}
With this change, 1 / 0
is turned into Infinity
.
Now let us try some obfuscated values from MAPPING
object further down
below in jsfuck.js. (false+"")[1]
should turn into a
. Indeed our code
is able to deobfuscate it with "false"[1];
as intermediate value. Likewise,
(undefined+"")[2]
is turned into "d";
.
At this point our AST transform is as follows:
export default function (babel) {
const { types: t } = babel;
return {
name: "ast-transform", // not required
visitor: {
"UnaryExpression|BinaryExpression"(path) {
let node = path.node;
if (t.isNumericLiteral(node.left) && t.isNumericLiteral(node.right)) {
if (node.right.value === 0) {
if (node.left.value === 1) {
path.replaceWith(t.identifier('Infinity'));
return;
} else if (node.left.value === 0) {
path.replaceWith(t.identifier('NaN'));
return;
}
}
}
let result = path.evaluate();
if (result.confident) {
let valueNode = t.valueToNode(result.value);
if (t.isLiteral(valueNode)) {
path.replaceWith(valueNode);
} else {
path.replaceWith(valueNode);
path.skip();
}
}
},
MemberExpression(path) {
let node = path.node;
// https://stackoverflow.com/a/52986361/1044147
function isNumeric(n) {
return !isNaN(parseFloat(n)) && isFinite(n);
}
if (isNumeric(node.property.value)) {
node.property = t.valueToNode(Number(node.property.value));
}
if (t.isStringLiteral(node.object) &&
t.isNumericLiteral(node.property)) {
let character = node.object.value[node.property.value];
path.replaceWith(t.valueToNode(character));
}
if (t.isArrayExpression(node.object) &&
node.object.elements.length == 0 &&
t.isArrayExpression(node.property) &&
node.property.elements.length == 0) {
path.replaceWith(t.valueToNode(undefined));
}
}
}
};
}
This is able to undo a lot of stuff that JSFuck is doing and works on great
fraction of MAPPING
values.
… but this is not enough: further work
However, what about the (true+[]["flat"])[20]
that should evaluate to a
single {
character? This one is not simplified any futher by our transform.
Let’s unpack this expression in JS REPL:
> []["flat"]
[Function: flat]
> true + []["flat"]
'truefunction flat() { [native code] }'
> (true+[]["flat"])[20]
'{'
What we have here is that []["flat"]
gives us flat()
function as object,
that is stringified by adding true
to it and a character is extracted from
the resulting string. This kind of hack relies on JS runtime implementation
detail and our transform does not support this yet. It also does not support
the following:
'/': '(false+[0])["italics"]()[10]',
':': '(RegExp()+"")[3]',
';': '("")["fontcolor"](NaN+")[21]',
'<': '("")["italics"]()[0]',
'=': '("")["fontcolor"]()[11]',
'>': '("")["italics"]()[2]',
'?': '(RegExp()+"")[2]',
To fully deobfuscate code from JSFuck we have to further improve our AST transform to cover all the weird hacks JSFuck is doing with JS functions. That is the objective for the future work.
The plan is to have a trilogy of posts working towards complete reversal of JSFuck’ed code. Second part will deal with JS function hacks. The third part will be about tying up loose ends and developing a standalone deobfuscator with some basic CLI.