Challenge 7 - anode
Description
You’ve made it so far! I can’t believe it! And so many people are ahead of you!
Download (password: flare
) - 07_anode.7z
Contents
- Text Editor
- Node.js
- Python
Solution
Intro
For this challenge, we’re again given a single executable, anode.exe
Again, let’s start off by running the program.
Initial Analysis
Going to run the program, I noticed that we’re possibly dealing with Node.js. (Also hinted in the name of the challenge.)
Running the program prompts us to enter the flag.
Entering anything closes the window, so let’s try running it from a shell.
Entering garbage prints out “Try again.” and exits the program. Time to do a little more digging.
Having worked on a few Node.js reversing challenges in the past, I found that the Javascript source code is usually stored in plaintext in the binary. There are a handful ways of extracting the source code, this time, I just used strings
and copy-pasted to a file.
I won’t paste the entire source code here as it’s over 10,000 lines of Javascript, instead, I’ll just paste snippets of interesting parts.
Nexe, what’s that?
At the end of the code, there’s the following line <nexe~~sentinel>
as well as some target
array that is checked against some array b
, we’ll come back to this later.
<...snip...>
var target = [106, 196, 106, 178, 174, 102, 31, 91, 66, 255, 86, 196, 74, 139, 219, 166, 106, 4, 211, 68, 227, 72, 156, 38, 239, 153, 223, 225, 73, 171, 51, 4, 234, 50, 207, 82, 18, 111, 180, 212, 81, 189, 73, 76];
if (b.every((x,i) => x === target[i])) {
console.log('Congrats!');
} else {
console.log('Try again.');
<nexe~~sentinel>
Googling for the nexe sentinel
brings us to the following Github Repo: Nexe with the following in the README.
Nexe is a command-line utility that compiles your Node.js application into a single executable file.
Perfect, so now we have an idea of how this program was created. From what I understand, Nexe compiles your Javascript, a Node.js runtime, and required libraries into an executable that you can run.
But here’s a much better explanation of how it works by the author himself:
Static Analysis
const readline = require('readline').createInterface({
input: process.stdin,
output: process.stdout,
readline.question(`Enter flag: `, flag => {
readline.close();
if (flag.length !== 44) {
console.log("Try again.");
process.exit(0);
}
var b = [];
for (var i = 0; i < flag.length; i++) {
b.push(flag.charCodeAt(i));
}
// something strange is happening...
if (1n) {
console.log("uh-oh, math is too correct...");
process.exit(0);
var state = 1337;
while (true) {
state ^= Math.floor(Math.random() * (2**30));
switch (state) {
case 306211:
if (Math.random() < 0.5) {
b[30] -= b[34] + b[23] + b[5] + b[37] + b[33] + b[12] + Math.floor(Math.random() * 256);
b[30] &= 0xFF;
} else {
b[26] -= b[24] + b[41] + b[13] + b[43] + b[6] + b[30] + 225;
b[26] &= 0xFF;
}
state = 868071080;
continue;
<...snip...>
The script starts off by reading in user input into the variable flag
and checking to see if it’s exactly 44 characters long, if not it exits the program.
Next, it initializes an array b
with the Unicode value of each character in flag
.
As mentioned in the comment/clue, there’s some strange behavior going on in the next line. if (1n)
should always evaluate to true as BigInt values are always “truthy”, except for 0n, which is “falsy”. As seen in the following screenshot:
What’s more interesting, running the binary vs running the extracted source code provides different results.
Running the extracted source code with the Node.js runtime installed in my VM prints the “uh-oh, math is too correct…” message. While the exe just prints “Try again.”. I make a note of this and continue on.
Next, the program initializes the variable state
to 1337 and enters a huge state machine with 1000+ cases in the switch statement. Each case does some simple math (addition, subtraction, XOR) on a single byte of b
based on some conditional tested against Math.random()
, a BigInt value, or a regular integer.
The default
case, prints the message, “uh-oh, math.random() is too random…”, this seems to be another clue left by the FLARE team.
default:
console.log("uh-oh, math.random() is too random...");
process.exit(0);
The following case
breaks us out of the state machine.
What’s interesting to me, is at the beginning of the while loop, the state
variable is XOR’d with a value dervied from Math.random()
. If Math.random()
is truly random, it should be impossible to match any of the cases.
while (true) {
state ^= Math.floor(Math.random() * (2**30));
switch (state) {
case 306211:
if (Math.random() < 0.5) {
b[30] -= b[34] + b[23] + b[5] + b[37] + b[33] + b[12] + Math.floor(Math.random() * 256);
b[30] &= 0xFF;
} else {
b[26] -= b[24] + b[41] + b[13] + b[43] + b[6] + b[30] + 225;
b[26] &= 0xFF;
}
state = 868071080;
continue;
Finally, as we saw earlier, we can see the target
array being checked against our newly calculated b
array. And either prints out a “Congrats!” or “Try again.”
var target = [106, 196, 106, 178, 174, 102, 31, 91, 66, 255, 86, 196, 74, 139, 219, 166, 106, 4, 211, 68, 227, 72, 156, 38, 239, 153, 223, 225, 73, 171, 51, 4, 234, 50, 207, 82, 18, 111, 180, 212, 81, 189, 73, 76];
if (b.every((x,i) => x === target[i])) {
console.log('Congrats!');
} else {
console.log('Try again.');
With the strangeness of the Math.random()
calls and the truthyness of BigInts, I suspect that the Node runtime that was packaged with the executable was patched. At the very least with a hardcoded seed or hardcoded value for Math.random()
and some patch to the truthyness evaluation of BigInts.
To summarize, we’re given our target
AKA modified final flag. We need to reverse the arithmetic operations performed on it to get the original flag while keeping in mind the patched node.js runtime.
Gameplan
Figuring out a way to approach this challenge took me a while, I first thought about diffing the patched node.js with a “clean” version but decided that was too much work.
My next thought was to modify the code that’s embedded in the binary, if I can get the binary to run my own code, I don’t need to worry about the patched node.js runtime.
After some trial and error, digging through Nexe source code, messing with the binary in a hex editor, and creating my own binary with Nexe. I was able to supply my own script that the challenge binary executes. However, in order for the script to be parsed correctly, there were 2 values I had to modify in the binary. (Long story short, it’s just the length of script.)
The the first value that needs to be updated with the new script size is 321847
.
!(function () {process.__nexe = {"resources":{"./anode.js":[0,321847]}};})();
The second value is at the end of the file after the nexe sentinel
. The bytes we’ll need to update are: DC A4 13 41
. I figured out that these were the values that I needed to update after creating my own binary with Nexe and noticed these values changing when I supplied scripts of varying lengths.
If we examine line 290 in the source code for compiler.ts
in the Nexe Github Repo, we can see how the final deliverable gets assembled and how the metadata is calculated.
The variable we’ll focus on is lengths
. We see it is initialized as a 16-byte buffer (line 299) and makes use of the writeDoubleLE
method to write this.bundle.size
(our script length) at an offset of 8 bytes.
I launch a node REPL and test to make sure the value 321847
does calculate to DC A4 13 41
.
Now that I can supply any script I want to the challenge binary to execute, I can finally start solving the challenge.
The Final Mile
My plan is to print out each equation that is executed; however, we can’t just print out equation as is, since some of the equations have a call to Math.random()
. To get around that, we can use template strings for string interpolation to substitute the Math.random()
expression with it’s value.
Here’s a rough outline of the steps performed to print out the equations (using Sublime Text):
- Use regex to find all calls to
Math.random()
in the equations and surround them with backticks. (`)
- FIND: Math.floor(Math.random() * 256)
- REPLACE: ${$&}
- Regex to find all equations and surround them with backticks (`) and a
console.log()
- FIND: b[\d+].*
- REPLACE: console.log(`$&`);
Note: $&
is a reference to the matched substring.
Now we can copy the modified script and paste it over the original script in the binary. We’ll also update the 2 values in the binary to ensure the new script gets parsed correctly.
We run the modified binary and save the output to a file.
Now that we have all the equations saved to a file, all that’s left is to reverse the operations and print out the flag.
Finish Line: Reversing the operations
We can use a find and replace to swap the +=
and -=
like so:
- Replace all
+=
with some value that doesn’t appear in the script. Like AAAA
- Replace all
-=
with +=
- Replace all
AAAA
with -=
We don’t have to worry about replacing the ^=
as XOR is already reversible.
Next, I use tac
to reverse the equations file and save it to a new file called final_equations.js
.
While the equations are reversed, we also have to ensure that the bitmask, &= 0xFF
comes after each equation to ensure the values are a single byte. I wrote a python script to move the bitmask after each equation.
inp = open('final_equations.js', 'r')
out = open('swapped.js', 'w')
for line in inp:
if line.endswith("&= 0xFF;",0,-1):
out.write(inp.readline())
out.write(line)
else:
out.write(line)
out.close()
inp.close()
Finally, I copy the target
value from the original script and assign it to variable b at the beginning of my swapped.js
file.
And also add the following code at the end to print out the final flag.
Running the script successfully gives us the flag for this challenge:
Flag
Flag: n0t_ju5t_A_j4vaSCriP7_ch4l1eng3@flare-on.com