FlareOn-9 Writeups 07 anode
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:
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):
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 :)
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:
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:
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))
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:
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:
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:
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.