Writeup: FlareOn 2022: 007 - anode
1. TLDR
2. Dane wejściowe
Plik z zadaniem znajduje się tutaj. Hasło: flare.
Przedmiotem zadania był plik PE:
anode.exe
3. Analiza pliku jako pliku PE
Zweryfikowałem typ pliku:
$ file anode.exe
anode.exe: PE32+ executable (console) x86-64, for MS Windows
Program po uruchomieniu wyświetlał komunikat:
Enter flag:
Po wpisaniu ciągu znaków, program kończył działanie.
Załadowałem program do IDA. Zauważyłem wiele ciągów znaków wskazujących na to, że jest to program wykorzystujący node.js:
Narzędziem pe-bear zidentyfikowałem overlay zawierający kod napisany w języku javascript:
Odczytując plik anode.exe
od bajtu 35dfa00
, zapisałem javascript do osobnego pliku i rozpocząłem jego analizę:
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. Analiza kodu javascript
Skrypt przyjmował na wejściu flagę, a następnie sprawdzał, czy jest ona poprawna poprzez obliczenie “sumy kontrolnej”. Próba uruchomienia lekko poprawionego kodu w Chrome poskutkowała wypisaniem:
uh-oh, math.random() is too random...y
Moją uwagę zwrócił komentarz // something strange is happening...
i instrukcja if obok niego, która powinna zawsze kończyć program. Zatem albo należało przeanalizować sam skrypt (omijając “złośliwe” fragmenty kodu) lub interpreter javascript działał inaczej niż można się było tego spodziewać. W celu potwierdzenie tej hipotezy próbowałem przeanalizować fragment kodu interpretera po podaniu tekstu na standardowe wejście. Po kilku minutach stwierdziłem, że dokładna analiza i ujawnienie “oszustwa” interpretera będzie zbyt karkołomne. Postanowiłem więc skopiować plik anode.exe
i nadpisać kawałek kodu javascript tak, aby rozróżnić poszczególne fragmenty kodu.
W tym celu nadpisałem pierwszy z ciągów znaków "Try again"
na Wrong len
.
Przeprowadziłem następnie 2 eksperymenty:
Wprowadziłem na wejście ciąg znaków o niepoprawnej długości:
>myanode.exe
Enter flag: aa
Wrong len.
Następnie wprowadziłem na wejście ciąg znaków o poprawnej długości (44 znaki):
>myanode.exe
Enter flag: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Try again.
Wyglądało na to, że instrukcje if działały na odwrót. W celu potwierdzenia wykonałem jeszcze jedną zmianę:
// something strange is happening...
if (true){
console.log("uh-oh, math is too correct...")
Na standardowym wyjściu pojawiło się:
>python -c "print('a'*44)" | .\myanode.exe
Enter flag: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
uh-oh, math is too correct...
W odwrotnym przypadku:
// something strange is happening...
if (false){
console.log("uh-oh, math is too correct...")
… można było zaobserwować komunikat świadczący o dalszym wykonaniu:
>python -c "print('a'*44)" | .\myanode.exe
Enter flag: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Try again.
Dysponując zdobytą wiedzą, postanowiłem przetestować funkcje Math.floor() oraz Math.random(). W wyniku eksperymentu ustaliłem, że każdorazowo, pierwsze wykonanie Math.random() daje ten sam wynik.
5. Deobfuskacja
Wiedząc, że ziarno generatora jest stałe, przystąpiłem do deobfuskacji.
Postanowiłem wygenerować stany, przez które przechodzi program.
W tym celu nadpisałem ponownie komentarz // something strange is happening...
, towarzyszącą instrukcję if
kodem oraz początek pętli while w taki sposób, aby prześledzić wykonanie przez poszczególne bloki case:
var state = 1337;
while (true) {
state ^= Math.floor(Math.random() * (2**30));
console.log(state);
switch (state) {
Następnie uruchomiłem kod i wygenerowałem listę stanów:
>python -c "print('a'*31+'@flare-on.com')" | .\myanode.exe > states.txt
W kolejnym kroku opracowałem narzędzie, którym:
- zidentyfikowałem poszczególne bloki if/else, które były wykonywane przez interpreter
- obliczyłem kolejne elementy ciągu generowanego przez 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()
W wyniku działania skryptu pozyskałem plik output.js
zawierający 1024 przypisania. Po delikatnej edycji, polegającej na usunięciu z kolejnych linii “szumu” generowanego przez działania modulo 255, otrzymałem zestaw kolejnych przypisań:
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. Odczytanie flagi
Pozostało zatem odwrócić kolejność wykonywanych przypisań i odczytać flagę. W tym celu napisałem skrypt:
#!/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()
Po uruchomieniu na standardowym wyjściu pojawiła się flaga:
n0t_ju5t_A_j4vaSCriP7_ch4l1eng3@flare-on.com