Dylan's Blog

2022 Flare-On 9 Challenge 7: anode

Category: CTF

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

Tools Used

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.)

Program Info

Running the program prompts us to enter the flag.

Enter Flag

Entering anything closes the window, so let’s try running it from a shell.

Try Again

Entering garbage prints out “Try again.” and exits the program. Time to do a little more digging.

Extracting Source Code

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:

Nexe Description

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:

Truthyness

What’s more interesting, running the binary vs running the extracted source code provides different results.

Extracted vs Original Output

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.

case 185078700:
        break;

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.

Nexe Metadata

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.

Assemble Deliverable

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.

Validate Script Size

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):

  1. Use regex to find all calls to Math.random() in the equations and surround them with backticks. (`)
    • FIND: Math.floor(Math.random() * 256)
    • REPLACE: ${$&}
  2. 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.

Equations

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:

  1. Replace all += with some value that doesn’t appear in the script. Like AAAA
  2. Replace all -= with +=
  3. 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.

Reversed Equations

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.

Final Script

And also add the following code at the end to print out the final flag.

Final Script End

Running the script successfully gives us the flag for this challenge:

Flag

Flag

Flag: n0t_ju5t_A_j4vaSCriP7_ch4l1eng3@flare-on.com