Writeup: FlareOn 2022: 007 - anode

Task description

1. TLDR

TLDR graph

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:

nodejs

Narzędziem pe-bear zidentyfikowałem overlay zawierający kod napisany w języku javascript:

pebear-overlay

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:

  1. zidentyfikowałem poszczególne bloki if/else, które były wykonywane przez interpreter
  2. 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