Writeup: FlareOn 2022: 007 - anode
1. TLDR
2. Input data
The challenge file is here. Password: flare.
The subject of the task was the PE file:
anode.exe
3. Analysis of the file as a PE file
I verified the file type:
$ file anode.exe
anode.exe: PE32+ executable (console) x86-64, for MS Windows
The program displayed a message after starting:
Enter flag:
After typing a string, the program terminated.
I loaded the program into IDA. I noticed many strings indicating that it was a program using node.js:
Using the pe-bear tool, I identified an overlay containing code written in javascript:
Reading the anode.exe
file from byte 35dfa00
, I saved the javascript to a separate file and started analyzing it:
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;
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 185078700:
break;
...
case 1071767211:
if (Math.random() < 0.5) {
b[30] ^= (b[42] + b[9] + b[2] + b[36] + b[12] + b[16] + 241) & 0xFF;
} else {
b[20] ^= (b[41] + b[2] + b[40] + b[21] + b[36] + b[17] + 37) & 0xFF;
}
state = 109621765;
continue;
default:
console.log("uh-oh, math.random() is too random...");
process.exit(0);
}
break;
}
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.');
}
});
4. Analysis of javascript code
The script took a flag as input and then checked if it was correct by calculating a “checksum.” Attempting to run the slightly revised code in Chrome resulted in an output:
uh-oh, math.random() is too random...y
My attention was drawn to the comment // something strange is happening...
and the if statement next to it, which should always terminate the program. So, either the script itself should have been analyzed (bypassing the “malicious” code fragments) or the javascript interpreter was acting differently than expected. In order to confirm this hypothesis, I tried to analyze the interpreter’s code snippet after giving text to the standard input. After a few minutes, I concluded that a thorough analysis and revealing the interpreter’s “cheating” would be too breakneck. So I decided to copy the anode.exe
file and overwrite the javascript code snippet so as to distinguish between the different code fragments.
To do this, I overwrote the first of the strings Try again
to Wrong len
.
I then conducted 2 experiments:
I entered a string of incorrect length into the input:
>myanode.exe
Enter flag: aa
Wrong len.
I then input a string of characters of the correct length (44 characters):
>myanode.exe
Enter flag: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Try again.
It seemed that the if statements were working in reverse. To confirm, I made one more change:
// something strange is happening...
if (true){
console.log("uh-oh, math is too correct...")
The standard output showed:
>python -c "print('a'*44)" | .\myanode.exe
Enter flag: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
uh-oh, math is too correct...
In the reverse case:
// something strange is happening...
if (false){
console.log("uh-oh, math is too correct...")
… a message indicating further execution could be observed:
>python -c "print('a'*44)" | .\myanode.exe
Enter flag: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Try again.
With the knowledge I gained, I decided to test the Math.floor() and Math.random() functions. As a result of the experiment, I determined that each time, the first execution of Math.random() gives the same result.
5. Deobfuscation
Knowing that the generator seed is fixed, I proceeded to deobfuscate.
I decided to generate the states that the program goes through.
To do this, I re-wrote the // something strange is happening...
comment, the accompanying if
statement with code and the beginning of the while loop in such a way as to trace the execution through the various case blocks:
var state = 1337;
while (true) {
state ^= Math.floor(Math.random() * (2**30));
console.log(state);
switch (state) {
I then ran the code and generated a list of states:
>python -c "print('a'*31+'@flare-on.com')" | .\myanode.exe > states.txt
In the next step, I developed a tool with which:
- I identified the individual if/else blocks that were executed by the interpreter
- I calculated the subsequent elements of the string generated by the PRNG
#!/usr/bin/python3
import re
import time
from subprocess import Popen, PIPE,STDOUT
from tqdm import tqdm
def get_content(filename):
content = None
with open(filename, 'rb') as f:
content = f.read()
return content
def get_lines(filename):
return get_content(filename).decode().split('\n')
def get_start_code(script):
start_code = ''
start_line = 0
for i in range(len(script)):
if "var state" in script[i]:
start_code += script[i]
if "state ^= Math" in script[i]:
start_code += script[i]
start_line = i+1
start_code += r'''console.log(state);'''
return start_code, start_line
def save_code(code, filename):
with open(filename, 'wb') as script:
script.write(code.encode())
def patch_file(filename, offset, code):
with open(filename, 'rb+') as exe:
exe.seek(offset, 0)
exe.write(code.encode())
def copy_file(src,dst):
content = None
with open(src, 'rb') as s:
content = s.read()
with open(dst, 'wb') as d:
d.write(content)
def execute_in(executable, patch_offset, patch_max_length, code, line=-1):
if len(code)>patch_max_length:
chars_to_remove = len(code) - patch_max_length
lines = code.split('\n')
nchars_to_remove = (0 if (len(lines[0])>chars_to_remove) else chars_to_remove-len(lines[0]))
lines[0]=' '*(len(lines[0])-chars_to_remove)
chars_to_remove = nchars_to_remove
if chars_to_remove>0:
for i in reversed(range(len(lines))):
if re.match(r'//', lines[i]):
if len(lines[i])<=chars_to_remove:
chars_to_remove-=len(lines[i])
lines[i] = ''
else:
lines[i]=' '*(len(lines[i])-chars_to_remove)
chars_to_remove=0
if chars_to_remove==0:
break
code='\n'.join(lines)
if len(code)>patch_max_length:
print(f"!!!ERROR!!! New code length is not equal to original code! Expected: {patch_max_length}. Given: {len(code)}")
exit(1)
code += (' '*(patch_max_length - len(code)))
tmp_exe = executable.replace('.exe', f'{line}.exe')
copy_file(executable, tmp_exe)
patch_file(tmp_exe, patch_offset, code)
p = Popen([tmp_exe], stdout=PIPE, stdin=PIPE, stderr=STDOUT)
default_input = b'b'*44+b'\n'
output = p.communicate(input=default_input)[0]
p.kill()
result = output.decode().split('\n')[0].replace("Enter flag: ","").strip()
time.sleep(0.05)
return result
def look_for(script, start_line, text):
result = None
for i in range(start_line, len(script)):
if text in script[i]:
result = i
break
return result
def get_script_lines(filename, offset, length):
content=None
with open(filename,'rb') as f:
f.seek(offset, 0)
content = f.read(length)
content = content.decode().split('\n')
return content
def evaluate(code_lines, line_number, executable,script_offset,script_length, math_floor_expression):
expression = code_lines[line_number].replace(math_floor_expression, "kv")
code_lines[line_number] = f"var kv={math_floor_expression};{expression}console.log(kv);"
known_value = execute_in(executable,script_offset,script_length, '\n'.join(code_lines))
code_lines[line_number] = expression.replace("kv", known_value)
return code_lines[line_number]
def main():
### settings ###
script_offset = 0x35E3964
script_end = 0x363213C
script_length = script_end-script_offset
math_floor_expression = "Math.floor(Math.random() * 256)"
executable = '.\\anode_interpreter.exe'
################
script = get_script_lines(executable, script_offset, script_length)
states = get_lines(".\states.txt")
start_code, start_line = get_start_code(script)
output=''
for state_counter in tqdm(range(len(states))):
state = states[state_counter]
case_start = look_for(script,start_line,state)
if not case_start:
print(f"Case {state} not found!")
print("Output:")
print(output)
exit(1)
continue_start = "break" not in script[case_start+1]
if continue_start:
if_start = case_start+1
if_statement = script[if_start]
else_start = look_for(script, if_start, "} else {")
state_start = look_for(script, case_start, "state = ")
if_script_block = '\n'.join(script[if_start+1:else_start])
else_script_block = '\n'.join(script[else_start+1:state_start-1])
state_log_script = script.copy()
state_log_script[else_start-1] += r'''console.log('True');'''
state_log_script[state_start-2] += r'''console.log('False');'''
test_result = ("True" == execute_in(executable,script_offset,script_length, '\x0A'.join(state_log_script)))
state_log_script = script.copy()
if test_result:
if math_floor_expression in if_script_block:
for i in range(if_start+1, else_start):
if math_floor_expression in state_log_script[i]:
state_log_script[i] = evaluate(state_log_script,i, executable,script_offset,script_length, math_floor_expression)
break
output+='\n'.join(state_log_script[if_start+1:else_start])+'\n'
else:
if math_floor_expression in else_script_block:
for i in range(else_start+1,state_start-1):
if math_floor_expression in state_log_script[i]:
state_log_script[i] = evaluate(state_log_script,i, executable,script_offset,script_length, math_floor_expression)
break
output+='\n'.join(state_log_script[else_start+1:state_start-1])+'\n'
else:
look_for(script, case_start, "break;")
break
save_code(output, 'output.js')
if __name__=="__main__":
main()
As a result of the script, I obtained a output.js
file containing 1024 assignments. After delicate editing, involving the removal of “noise” generated by actions modulo 255 from the following lines, I obtained a set of consecutive assignments:
b[29] -= b[37] + b[23] + b[22] + b[24] + b[26] + b[10] + 7;
b[39] += b[34] + b[2] + b[1] + b[43] + b[20] + b[9] + 79;
b[19] ^= b[26] + b[0] + b[40] + b[37] + b[23] + b[32] + 255;
b[28] ^= b[1] + b[23] + b[37] + b[31] + b[43] + b[42] + 245;
b[39] += b[42] + b[10] + b[3] + b[41] + b[14] + b[26] + 177;
b[9] -= b[20] + b[19] + b[22] + b[5] + b[32] + b[35] + 151;
...
b[40] += b[13] + b[3] + b[43] + b[31] + b[22] + b[25] + 49;
b[19] ^= b[0] + b[35] + b[14] + b[30] + b[21] + b[33] + 213;
b[11] -= b[32] + b[8] + b[9] + b[34] + b[39] + b[19] + 185;
b[21] += b[39] + b[6] + b[0] + b[33] + b[8] + b[40] + 179;
b[34] += b[35] + b[40] + b[13] + b[41] + b[23] + b[25] + 14;
b[22] += b[16] + b[18] + b[7] + b[23] + b[1] + b[27] + 50;
b[39] += b[18] + b[16] + b[8] + b[19] + b[5] + b[23] + 36;
6. Reading the flag
Therefore, it remained to reverse the order of the assignments performed and read the flag. To do this, I wrote a script:
#!/usr/bin/python3
def get_content(filename):
content = None
with open(filename, 'rb') as f:
content = f.read()
return content
def get_lines(filename):
return get_content(filename).decode().split('\n')
def parse_index_from(variable):
try:
index = int(variable.strip()[2:][:-1])
except ValueError:
index = None
return index
def find_operator(line, operations):
result = None
for operator in operations.keys():
if f"{operator}" in line:
result = operator
break
return result
def process(line, b, operations):
operator = find_operator(line, operations)
equation = line.replace(";","").split(operator)
variable = equation[0].strip()
variable_index=parse_index_from(variable)
operands = equation[1].split("+")
total = 0
for operand in operands:
stripped_operand = operand.strip()
operand_index = parse_index_from(stripped_operand)
total += (b[operand_index] if operand_index !=None else int(stripped_operand))
b[variable_index] = (operations[operator](b[variable_index], total) & 0xFF)
return b
def main():
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]
lines = get_lines(".\equations.txt")
operations = {"+=": (lambda a,t : a-t),\
"-=": (lambda a,t : a+t),\
"^=": (lambda a,t : a^t)}
for i in reversed(range(len(lines))):
b = process(lines[i], b, operations)
flag = ''.join([chr(c) for c in b])
print(flag)
if __name__ == "__main__":
main()
After running, a flag appeared on the standard output:
n0t_ju5t_A_j4vaSCriP7_ch4l1eng3@flare-on.com