FlareOn-9 Writeups 07 anode

chall 07 banner

Introduction

Description:

You’ve made it so far! I can’t believe it! And so many people are ahead of you!

The challenge consist of a single Windows executable called anode.exe, acting as a crack-me.

This big boy have a size of 55.497 KB, which is insane. And the logo of the file is not reassuring: anode node

Time for some NodeJs compiled binary. I don’t have a lot of experience with such programs, but it sure was hell the last time I confront myself to such thing.

Extracting the JS code

The binary is that huge because it contains the whole NodeJS engine, and since we don’t want to reverse the engine, we can simply extract the plaintext JavaScript files.

This is a bit tedious to do by hand, but at least we are not missing anything. A total of 8 JavaScript files can be extracted, along with a html one. Most of it are internal files that we can ignore:

// This runs necessary preparations to prepare a complete Node.js context
// that depends on run time states.
// It is currently only intended for preparing contexts for embedders.

/* global markBootstrapComplete */
const {
  prepareMainThreadExecution
} = require('internal/bootstrap/pre_execution');

prepareMainThreadExecution();
markBootstrapComplete();
[...]

The last one, with is huge (10030 lines), is the actual code we want to look at. After some functions declarations that we can skip for now, we are able to locate the part of the code that reads the input password:

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));
  }

After that, the madness starts. An infinite loop is launched with what’s look like a big state machine that goes from almost 9999 lines of JS code:

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;
      case 311489:
        if (Math.random() < 0.5) {
          b[10] -= b[32] + b[1] + b[20] + b[30] + b[23] + b[9] + 115;
          b[10] &= 0xFF;
        } else {
          b[7] ^= (b[18] + b[14] + b[11] + b[25] + b[31] + b[21] + 19) & 0xFF;
        }
        state = 22167546;
        continue;
      case 755154:
        if (93909087n) {
          b[4] -= b[42] + b[6] + b[26] + b[39] + b[35] + b[16] + 80;
          b[4] &= 0xFF;
        } else {
          b[16] += b[36] + b[2] + b[29] + b[10] + b[12] + b[18] + 202;
          b[16] &= 0xFF;
        }
        state = 857247560;
        continue;
        [...]

Something strange is that each state behavior + each of the state transitions seems to be dictated by the result of a random number generation through Math.random(). Which doesn’t make sense since this is supposed to check the validity of the input and thus, must be determinist.

By taking a first look at it, it seems that each state is scrambling the array named b, which hold our input password. At the end of this state-machine, when the b array is fully scrambled, a comparison is made against another hardcoded value:

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.');
  }

A small patch in the NodeJS engine

The fact that the state-machine is dictated by a bunch of random numbers is very suspicious, but this weird statement can also lead us on the track of a modified NodeJS:

 // something strange is happening...
  if (1n) {
    console.log("uh-oh, math is too correct...");
    process.exit(0);
  }

Alright, let’s load this binary in IDA and look for any modifications in the NodeJS engine itself. The pdb file for NodeJS is available for download, which makes it less painful.

After diffing our crack-me with the node engine itself (using the BinDiff IDA plugin), and searching for anything related to randomness, we can find that the random number generator is indeed seeded with a hardcoded value in our program:

 void thiscall v8::base::RandomNumberGenerator::SetSeed(RandomNumberGenerator this,int64 param_1)
 {
                    / 0xd8dc60  11842  ?SetSeed@RandomNumberGenerator@base@v8@@QEAAX_J@Z */
    (__int64)this = param_1;
    (undefined8)(this + 8) = 0x60c43c4809ad2d74;
    (undefined8)(this + 0x10) = 0xce6a1a53db4c5403;
    return;
 }

This makes the state-machine not random anymore and perfectly deterministic.

Binary instrumentation

Alright, we figured a small part of it, but what if we need to make some test with the binary, and let it execute custom JS instructions ? For now, debugging is impossible since we have to deal with the NodeJS engine on top of the code.

Remember that the JS code is located in the compiled binary as plaintext ? Maybe we can write a little something to inject arbitrary JS code inside the binary and manipulate it to print our commands output for us…

After a few tests, we realize that we cannot add additional code in the binary, as it probably break something (like the size of the plaintext JS code): anode invalid

Instead, we’ll carve out the huge state-machine from the binary, and make sure not to change the size of the binary with our injected code. Our limit is 9999 lines of JS code, so I guess it should be more than enough :) anode cave

On the left, the original binary, and on the right, the carved out version we’ll use.

A few helper functions are used to make things cleaner:

def copy_file(dst):
    src = "template.exe"
    shutil.copyfile(src, dst)

def del_file(dst):
    os.remove(dst)

To insert the code we want to insert into the binary, the following function is used to start writing at the offset 0x35E39F8:

def insert_code(dst, code):
    offset = 0x35E39F8
    f = open(dst, "r+b")
    f.seek(offset)
    f.write(code)
    f.close()

And finally, to execute the binary from our python file and grab the input, the subprocess library is used:

def exec(dst):
    arg_input = b"AA\n"
    p = Popen(dst, stdin=PIPE, stdout=PIPE)
    p.communicate(input=arg_input)
    out, err = p.communicate()
    return out

def get_out(out):
    re = out.split(b"Try again.\n")[1].split(b"Try again.\n")[0]
    return re

We can test this right away with a dummy line of JS:

dst = "template_cpy.exe"

code = b'console.log("Hello from within NodeJS\\n")'
copy_file(dst)
insert_code(dst, code)
raw_re = exec(dst)
re = get_out(raw_re)
del_file(dst)
print(re)

And this is working fine, we now have a way to execute arbitrary JavaScript code in the context of this patched NodeJS engine: anode instrumentation test

Another helper function can help us understand the state-machine better.

We can spot that at certain places, the control-flow is based on some constants integers values:

    if (62542139n) {
        b[25] -= b[31] + b[29] + b[8] + b[36] + b[23] + b[40] + 216;
        b[25] &= 0xFF;
    } else {
        [...]
    if (69259497n) {
          b[28] ^= (b[10] + b[34] + b[31] + b[29] + b[17] + b[11] + Math.floor(Math.random() * 256)) & 0xFF;
    } else {
        [...]

As the NodeJs engine seems to also be patched in order to make some constant values evaluate to True and some other to False, the following helper function was added into our script:

def test_number_bool(dst, number):
    code = b'  if (XXX) {\n    console.log("True");}\n  else{\n    console.log("False");\n}'
    code = code.replace(b"XXX", number)
    insert_code(dst, code)

That way, we can evaluate a number to understand if it corresponds to a True or False statement:

copy_file(dst)
test_number_bool(dst, b'69259497n')
raw_re = exec(dst)
re = get_out(raw_re)
del_file(dst)
print(re)

In this particular case, this is an example of a constant number which evaluate to False thanks to the modified NodeJs engine: anode weird integer here

Leak of the fake PRNG values

Since our goal is to reverse this state-machine to revert the operations performed on the input password, and hopefully retrieve what is the value of the valid input, we have to start by eliminating all the dead code portions.

But with this giant code, no way to do that by hand.

Since most of the control-flow is based on the rigged NodeJS PRNG, we have to find a way to evaluate the conditions (in order to remove the branches that are not taken). An easy way would be to leak the N first values of the NodeJS PRNG. As a reminder, the seed of the random number generated is hardcoded, which means that it will always return the same sequence of number in the same order.

A function can be written to output the first 10000 generated numbers (which should be more than enough to start with).

With our previously created helper functions, things becomes easier:

def leak_rng(dst, num):
    leaked = []
    code = b' for (let i = 0; i < XX; i++) {\n    console.log(" - " + Math.random() + " - ");\n  } '
    code = code.replace(b'XX', str(num).encode())
    copy_file(dst)
    insert_code(dst, code)
    raw_re = exec(dst)
    re = get_out(raw_re)
    del_file(dst)
    for val in re.split(b'\n'):
        if val == b'':
            break
        a = val.split(b" - ")[1].split(b" - ")[0].decode()
        leaked.append(float(a))
    return leaked

Which allows us to dump them:

leaked = leak_rng(dst, 10000)
print("[+] Got %d leaked PRNG values" % len(leaked))

anode prng leak

Reconstruction of the control-flow

In order to programmatically deal with the conditions of the state-machine, we have to extract them. Once extracted, we’ll be able to use our PRNG leaked values to evaluate them and reconstruct the correct execution flow.

The following code is responsible for checking each state of the state-machine and to resolve the Math.random values against our leaked “random” values:


def get_state(state, leaked_rng, index):
    a = leaked_rng[index] * float((2**30))
    b = int(state) ^ int(a)
    index += 1
    return b, index

def grab_state(content, case_id):
    re = b""
    pattern = b"case %d:" % int(case_id)
    for x in range(0, len(content.split(b'\n'))):
        if pattern in content.split(b'\n')[x]:
            for y in range(0, 20):
                re += content.split(b'\n')[x + y]
                if b"continue" in content.split(b'\n')[x + y]:
                    break
                if b"break" in content.split(b'\n')[x + y]:
                    print("End of the state-machine")
                    return b""
                    break
            break
    return re

with open("C:\\Users\\homardboy\\Downloads\\07_anode\\extracted\\state_machine.txt", "rb") as f:
    content = f.read()

extracted = []
rng_idx = 0
state = 1337    

leaked = leak_rng(dst, 10000)
print("[+] Got %d leaked PRNG values" % len(leaked))

for x in range(0, 2000):
    state, rng_idx = get_state(state, leaked, rng_idx)
    print("\n[+] State: %s (rng:%d)" % (state, rng_idx))
    state_content = grab_state(content, state)
    if state_content == b"":
        print("ERROR: no state", state)
        break
    condition, true_val, false_val, n_state = process_out(rng_idx, state_content)

    n_state = n_state.split(b"state = ")[1].split(b";")[0]
    state = int(n_state)
    print("[DEBUG] condition", condition)
    
    if b"Math.random" in condition:
        if leaked[rng_idx] < 0.5:
            extracted.append(true_val)
            print(true_val)
            if b"Math.random" in true_val:
                rng_idx += 1
        else:
            extracted.append(false_val)
            print(false_val)
            if b"Math.random" in false_val:
                rng_idx += 1
        rng_idx += 1
    else:
        num = condition.split(b"if (")[1].split(b")")[0]
        integer_check = try_integer(dst, num)
        if b"False" in integer_check:
            extracted.append(false_val)
            print(false_val)
            if b"Math.random" in false_val:
                rng_idx += 1
        else:
            extracted.append(true_val)
            print(true_val)
            if b"Math.random" in true_val:
                rng_idx += 1

Which gives us a simplified version of each state: anode simplification phase 1

We can observe that we are still missing some Math.random() inside the computation of the b array.

This wrapper was created to evaluate the missing random values:

with open("C:\\Users\\homardboy\\Downloads\\07_anode\\instrumentation\\07_reconstruct_out.txt", "r") as f:
    content = f.read()

extracted = []
rng_idx = 0
dst = "template_cpy.exe"

leaked = leak_rng(dst, 10000)

a = content.split('\n')
re = ""

# Fix the missing random value in expressions
for x in range(0, len(a)):
    sub = ""
    if "End of the" in a[x]:
        break
    if "[+] State: " in a[x]:
        index = int(a[x].split("(rng:")[1].split(")")[0]) - 1
        for y in range(1, 5):
            if (x+y) > len(a):
                break
            if (a[x+y] == ''):
                break
            if "End of" in a[x+y]:
                break
            if "DEBUG" in a[x+y]:
                if "random" in a[x+y]:
                    index += 1
                else:
                    continue
            else:
                if "random" in a[x+y]:
                    rand_val = leaked[index]
                    index += 1
                    clean = a[x+y].replace('  ', '')[2:].replace(';', ';\n')[:-1]
                    clean = clean.replace("Math.random()", str(rand_val))
                else:
                    clean = a[x+y].replace('  ', '')[2:].replace(';', ';\n')[:-1]
                re += clean
    else:
        continue

# Save the result output
with open('inverted_correct_dmp.txt', 'r') as f:
    re = f.read()

The file 07_reconstruct_out.txt being the result from our previous step.

We now have the linear set of operations that are performed on the input password, without the dead code and without any Math.random() in our way: anode linear equations

Solving the constraints on the input

Alright, the hardest part is done.

As a reminder, the char array named b is the one that hold our input.

At the end of this set of operations, it is checked against this array:

  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];

To retrieve the correct password, we need to invert the operations.

This is only a matter of taking the equations in the reverse order (the last one first), replacing the values with the one from our target array. Then, we can evaluate the characters of the array, and continue to replace the values from bottom to start.

For instance, the last operation is:

b[39] += b[18] + b[16] + b[8] + b[19] + b[5] + b[23] + 36;
b[39] &= 0xFF;

We know that at the last step, b[18], b[16], b[8] and the other one are equal to their respective index in the target array.

For instance:

  • b[18] = 4
  • b[16] = 166
  • b[8] = 91

And we also know the final value of b[39].

To solve this, we have to programmatically go through the previous operations, and evaluate the values of b[39], b[16] in them, and repeating the operation until we have reached the first operation, which is dealing with our input.

The following script can do that for us:

with open('inverted_correct_dmp.txt', 'r') as f:
    re = f.read()


b = [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];

a = re.split('\n')
c = a

import math

# resolve the equations
for line in c:
    
    if line == '':
        continue
    
    b_index = line.split(" ")[0]
    array_index = int(b_index.split("[")[1].split("]")[0])

    if line == ("b[%d] &= 0xFF;" % array_index):
        continue

    operation = line.split("= ")[1].split(";")[0]
    if "Math.floor" in operation:
        operation = operation.replace("Math.floor", "math.floor")
        
    op_result = eval(operation)
    final_re = op_result

    op_assign = line.split(" ")[1].split(" ")[0].replace(" ", "")

    if op_assign == "+=":
        b[array_index] -= final_re
        b[array_index] &= 0xFF
    elif op_assign == "-=":
        b[array_index] += final_re
        b[array_index] &= 0xFF
    elif op_assign == "^=":
        b[array_index] ^= final_re
        
    else:
        print("unknown operation", op_assign)
        break
    
    print(operation)

flag = ""
print(b)
for f in b:
    flag += chr(f)
print("flag:", flag)

And after execution, we have successfully reversed the operations performed on the input, which gives us the correct password: anode flag

n0t_ju5t_A_j4vaSCriP7_ch4l1eng3@flare-on.com

A serious challenge that involved a lot of scripting (and not that much reverse engineering) but it was straight-forward and could have been way worse.

Nevertheless, a cool one that I enjoyed.

anode validation