[Write-up] Jet Logger - Midnight CTF 2023

Apr 16, 2023

Jet Logger was a linux reverse engineering challenge from MidNightCTF 2023.

We are given an archive with the following content :

jet_logger
encrypted_frames.txt.enc

Given the files, we can guess that we will be needing to find a way to decrypt the encrypted frames.

jet_logger is a classic x64 ELF binary we can open in IDA.

Main function is rather simple:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  puts(
    " ------------- JetLogger v1.19.3 -------------\n"
    "|                                             |\n"
    "| The solution to keep your ADS-B frames safe |\n"
    "|                 by HellCorp                 |\n"
    "|                                             |\n"
    " ---------------------------------------------\n");
  __printf_chk(1LL, "Please input your password : ");
  if ( !fgets(s, 17, _bss_start) )
  {
    puts("An error occured when reading input...");
    result = 1;
    goto LABEL_7;
  }
  if ( !(unsigned __int8)check_password((FILE *)s) )
  {
    puts("Wrong password !");
    goto LABEL_7;
  }

  puts("Success ! Decrypting your frames...");
  v3 = fopen("./frames.txt.enc", "r");
    while(1){
    LOBYTE(v17) = fgetc(v3);
        [some computation in a loop, using our input "s"]
        algo_encrypt(&v17, (__m128i *)&v15[(16 * v12) & 0xFF0]);
        [some more computation in a loop]

    }
LABEL_17:
  __printf_chk(1LL, "Here they are : \n%s\n", v15);
}

Parts of the decompilation has been redacted for brevity.

Two things to note :

  • algo_encrypt is probably symmetrical, otherwise it could not be used to decrypt frames.txt.enc
  • we have to input a password that will probably be used as a key to decrypt frames

check_password function seems rather simple too:

  v3 = __readfsqword(0x28u);
  result = 0;
  if ( trigger_data == 34740228 )
  {
    puts("Password verification...");
    trigger_data = 0x25120496uLL / (unsigned __int8)valid_ciphertext;
    algo_encrypt(a1, &v2);
    result = valid_ciphertext == v2;
  }
  if ( v3 != __readfsqword(0x28u) )
    return on_breakpoint();
  return result;

We have to find an input that result in valid_ciphertext’s value once encrypted.

Let’s take a lok at algo_encrypt :

.text:0000000000001984                 mov     edx, [rdi+8]
.text:0000000000001987                 mov     r8, rsi
.text:000000000000198A                 xor     edx, cs:dword_4078
.text:0000000000001990                 mov     esi, [rdi]
.text:0000000000001992                 xor     esi, cs:key
.text:0000000000001998                 ror     edx, 0Dh
.text:000000000000199B                 rol     esi, 7
.text:000000000000199E                 mov     ecx, [rdi+4]
.text:00000000000019A1                 mov     eax, [rdi+0Ch]
.text:00000000000019A4                 xor     ecx, cs:dword_4074
.text:00000000000019AA                 xor     eax, cs:dword_407C
.text:00000000000019B0                 xor     edx, esi
.text:00000000000019B2                 mov     r9d, esi
.text:00000000000019B5                 xor     eax, esi
.text:00000000000019B7                 mov     esi, ecx
.text:00000000000019B9                 not     r9d
.text:00000000000019BC                 ror     ecx, 9
.text:00000000000019BF                 ror     esi, 2
.text:00000000000019C2                 xor     ecx, r9d
.text:00000000000019C5                 movd    xmm1, r9d
.text:00000000000019CA                 xor     eax, esi
.text:00000000000019CC                 movd    xmm2, ecx
.text:00000000000019D0                 xor     eax, edx
.text:00000000000019D2                 not     edx
.text:00000000000019D4                 punpckldq xmm1, xmm2
.text:00000000000019D8                 movd    xmm0, eax
.text:00000000000019DC                 movd    xmm3, edx
.text:00000000000019E0                 punpckldq xmm0, xmm3
.text:00000000000019E4                 punpcklqdq xmm0, xmm1
.text:00000000000019E8                 movups  xmmword ptr [r8], xmm0
.text:00000000000019EC                 retn

The algorithm is simple enough. A constraint solver tool such as z3 should be able to provide a solution easily. Problem is, we cannot simply emulate this function to get our constraints, since it requieres knowing values of variables key, qword_4074, qword_4078, qword_407C, which are in .bss section :

.bss:0000000000004070 key             dd ?                    ; DATA XREF: algo_encrypt+12↑r
.bss:0000000000004070                                         ; on_breakpoint:loc_1C09↑w ...
.bss:0000000000004074 dword_4074      dd ?                    ; DATA XREF: algo_encrypt+24↑r
.bss:0000000000004074                                         ; on_breakpoint:loc_1B09↑w ...
.bss:0000000000004078 dword_4078      dd ?                    ; DATA XREF: algo_encrypt+A↑r
.bss:0000000000004078                                         ; on_breakpoint:loc_1C79↑w ...
.bss:000000000000407C dword_407C      dd ?                    ; DATA XREF: algo_encrypt+2A↑r

I assume that those values will be filled at runtime. We can check where by looking at Xrefs. It seems to be used in a on_breakpoint function, which is quite large, and I do not want to reverse engineer this function. A better approach would be to recover this value dynamically.

Back on check_password, checking valid_ciphertext’s reveals something fishy :

  v3 = __readfsqword(0x28u);
  result = 0;
  if ( trigger_data == 34740228 )
  {
    puts("Password verification...");
    trigger_data = 0x25120496uLL / (unsigned __int8)valid_ciphertext;
    algo_encrypt(a1, &v2);
    result = valid_ciphertext == v2;
  }
  if ( v3 != __readfsqword(0x28u) )
    return on_breakpoint();
  return result;
.data:0000000000004040 valid_ciphertext xmmword 3C2E4CAC1BC19A84684145B626570D00h

This value ends with a 0, which should trigger a zero divide exception.

Something must be editing this value. Taking a look at Xrefs, we can discover premain function:

  v0 = sys_clone(0x1200011uLL, 0LL, 0LL, (void *)(__readfsqword(0x10u) + 720));
  child_pid = v0;
  if ( (_DWORD)v0 )
  {
    waitpid(v0, 0LL, v1);
    v2 = (unsigned int)child_pid;
    v3 = v29;
    for ( i = 54LL; i; --i )
    {
      *(_DWORD *)v3 = 0;
      v3 += 4;
    }
    ptrace(PTRACE_GETREGS, v2, 0LL, v29);
    child_load_addr = (__int64 (__fastcall *)(int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, int, __int64))(v31 - (_QWORD)child_load_addr);
    install_hardware_breakpoint(12, v2, v5, v6, v7, v8, v21 & 0xFC, (__int64)main, v22 & 0xF0);
    install_hardware_breakpoint(12, v2, v9, v10, v11, v12, v23 & 0xFC | 1, (__int64)check_password, v24 & 0xF0);
    install_hardware_breakpoint(12, v2, v13, v14, v15, v16, v25 & 0xFC | 2, (__int64)&trigger_data, v26 & 0xF0 | 9);
    install_hardware_breakpoint(12, v2, v17, v18, v19, v20, v27 | 3, (__int64)&trigger_data, v28 & 0xF0 | 0xB);
    while ( 1 )
    {
      ptrace(PTRACE_CONT, (unsigned int)child_pid, 0LL, 0LL);
      if ( (unsigned __int8)bp_index > 3u )
        break;
      waitpid(child_pid, 0LL, 0);
      on_breakpoint();
    }
    bp_index = 0;
    waitpid(child_pid, 0LL, 0);
    ptrace(PTRACE_GETREGS, (unsigned int)child_pid, 0LL, v29);
    v30 = 1LL;
    LOBYTE(valid_ciphertext) = valid_ciphertext_start;
    ptrace(PTRACE_POKEDATA, (unsigned int)child_pid, &valid_ciphertext, (_QWORD)valid_ciphertext);
    ptrace(PTRACE_SETREGS, (unsigned int)child_pid, 0LL, v29);
    ptrace(PTRACE_CONT, (unsigned int)child_pid, 0LL, 0LL);
    exit(0);
  }
  ptrace(PTRACE_TRACEME, 0LL, 0LL, 0LL);

A lot of mess here, this looks like a classic nanomite or father/child ptrace stuff I do not want to reverse engineer myself. Sadly, PTRACE will probably prevent us from debugging this binary and recovering the values we need to recover to find a valid input (key, qword_4074, qword_4078 and qword_407C).

Luckily, I created a tool at Flare-On CTF 2020 to help me reverse engineer ptraced binaries. It’s called tick, and all it does is preloading 99% of libc functions to log them to the terminal.

Running this tool outputs a rather long trace :

[ 5486] [waitpid] 
[ 5486] [ptrace] ptrace(PTRACE_GETREGS, 5487)
		rax : 0
		rcx : 7FADF516D19E
		rdx : 0
		rsi : 0
		rdi : 0
		rbx : 0
		r8  : FFFFFFFF
		r9  : F
		r10 : 0
		r11 : 286
		r12 : 7FFF2904A5D8
		r14 : 55AB8F3611C0
		r13 : 55AB8F363D68
		r15 : 7FADF5334040
		rbp : 1
		rsp : 7FFF2904A3F0
		orax : FFFFFFFFFFFFFFFF (origin RAX)
		rip : 55AB8F36163F

[ 5486] [ptrace] ptrace | 3 is not implemented by the tool.
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x350) | data : 55AB8F3611C0
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x388) | data : 401
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x380) | data : 0
[ 5486] [ptrace] ptrace | 3 is not implemented by the tool.
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x358) | data : 55AB8F3619F0
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x388) | data : 405
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x380) | data : 0
[ 5486] [ptrace] ptrace | 3 is not implemented by the tool.
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x360) | data : 55AB8F364028
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x388) | data : 9000415
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x380) | data : 0
[ 5486] [ptrace] ptrace | 3 is not implemented by the tool.
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x368) | data : 55AB8F364028
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x388) | data : B9000455
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x380) | data : 0
[ 5486] [ptrace] ptrace(PTRACE_CONT, 5487) | [Delivered signal]: [Unknown signal 0]
[ 5486] [waitpid] 
[long list of PTRACE PEEKTEXT here]
[ 5486] [ptrace] ptrace(PTRACE_POKEDATA, pid 5487, @ 0x55ab8f364070) | data : C4A34DA6
[ 5486] [ptrace] ptrace | 3 is not implemented by the tool.
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x350) | data : 0
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x388) | data : B9000454
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x380) | data : 0
[ 5486] [ptrace] ptrace(PTRACE_CONT, 5487) | [Delivered signal]: [Unknown signal 0]
[ 5486] [waitpid] 
[long list of PTRACE PEEKTEXT here]
[ 5486] [ptrace] ptrace(PTRACE_POKEDATA, pid 5487, @ 0x55ab8f364074) | data : B7D80F2B
[ 5486] [ptrace] ptrace | 3 is not implemented by the tool.
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x358) | data : 0
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x388) | data : B9000450
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x380) | data : 0
[ 5486] [ptrace] ptrace(PTRACE_CONT, 5487) | [Delivered signal]: [Unknown signal 0]
[ 5486] [waitpid] 
[long list of PTRACE PEEKTEXT here]
[ 5486] [ptrace] ptrace(PTRACE_POKEDATA, pid 5487, @ 0x55ab8f364078) | data : 77871143
[ 5486] [ptrace] ptrace | 3 is not implemented by the tool.
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x360) | data : 0
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x388) | data : B9000440
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x380) | data : 0
[ 5486] [ptrace] ptrace(PTRACE_CONT, 5487) | [Delivered signal]: [Unknown signal 0]
[ 5486] [waitpid] 
[long list of PTRACE PEEKTEXT here]
[ 5486] [ptrace] ptrace(PTRACE_POKEDATA, pid 5487, @ 0x55ab8f36407c) | data : 9D63F669
[ 5486] [ptrace] ptrace | 3 is not implemented by the tool.
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x368) | data : 0
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x388) | data : B8000400
[ 5486] [ptrace] ptrace(PTRACE_POKEUSER, pid 5487, @ 0x380) | data : 0
[ 5486] [ptrace] ptrace(PTRACE_CONT, 5487) | [Delivered signal]: [Unknown signal 0]
[ 5486] [waitpid] 
[ 5486] [ptrace] ptrace(PTRACE_GETREGS, 5487)
		rax : 25120496
		rcx : 0
		rdx : 0
		rsi : 7FFF2904A420
		rdi : 7FFF2904A470
		rbx : 0
		r8  : 7FADF526CA70
		r9  : 55AB8FF658A0
		r10 : 7FADF526B2E0
		r11 : 246
		r12 : 7FFF2904A470
		r14 : 55AB8F3611C0
		r13 : 55AB8F363D68
		r15 : 7FADF5334040
		rbp : 7FFF2904A470
		rsp : 7FFF2904A420
		orax : FFFFFFFFFFFFFFFF (origin RAX)
		rip : 55AB8F361A53

[ 5486] [ptrace] ptrace(PTRACE_POKEDATA, pid 5487, @ 0x55ab8f364040) | data : 684145B626570D2F
[ 5486] [ptrace] ptrace(PTRACE_SETREGS, 5487)
		rax : 25120496
		rcx : 1
		rdx : 0
		rsi : 7FFF2904A420
		rdi : 7FFF2904A470
		rbx : 0
		r8  : 7FADF526CA70
		r9  : 55AB8FF658A0
		r10 : 7FADF526B2E0
		r11 : 246
		r12 : 7FFF2904A470
		r14 : 55AB8F3611C0
		r13 : 55AB8F363D68
		r15 : 7FADF5334040
		rbp : 7FFF2904A470
		rsp : 7FFF2904A420
		orax : FFFFFFFFFFFFFFFF (origin RAX)
		rip : 55AB8F361A53

[ 5486] [ptrace] ptrace(PTRACE_CONT, 5487) | [Delivered signal]: [Unknown signal 0]
[ 5486] [exit] 
[ 5487] [ptrace] ptrace(PTRACE_TRACEME, 0)
 ------------- JetLogger v1.19.3 -------------
|                                             |
| The solution to keep your ADS-B frames safe |
|                 by HellCorp                 |
|                                             |
 ---------------------------------------------

Please input your password : [ 5487] [fgets] 
Password verification...
Wrong password !

Interresting values came out of PTRACE_POKEDATA:

[ 5567] [ptrace] ptrace(PTRACE_POKEDATA, pid 5568, @ 0x55ccbecf4070 (base+0x4070)) | data : C4A34DA6
[ 5567] [ptrace] ptrace(PTRACE_POKEDATA, pid 5568, @ 0x55ccbecf4074 (base+0x4074)) | data : B7D80F2B
[ 5567] [ptrace] ptrace(PTRACE_POKEDATA, pid 5568, @ 0x55ccbecf4078 (base+0x4078)) | data : 77871143
[ 5567] [ptrace] ptrace(PTRACE_POKEDATA, pid 5568, @ 0x55ccbecf407c (base+0x407c)) | data : 9D63F669
[ 5567] [ptrace] ptrace(PTRACE_POKEDATA, pid 5568, @ 0x55ccbecf4040 (base+0x4040)) | data : 684145B626570D2F

Those are the values of key (qword_4070), qword_4074, qword_4078 and qword_407C. It also modifies valid_ciphertext’s value, replacing the 0 with 0x2F.

All we have to do now is use them in a symbolic execution to gather our constraints and solve them using z3 :


from miasm.analysis.machine import Machine
from miasm.core.locationdb import LocationDB
from miasm.analysis.binary import Container
from miasm.jitter.csts import *
from miasm.expression.expression import *
from miasm.ir.symbexec import SymbolicExecutionEngine
from miasm.ir.translators.z3_ir import TranslatorZ3
from miasm.arch.x86.lifter_model_call import LifterModelCall_x86_64
import z3
import sys

if len(sys.argv) != 2:
    print(f'Usage : {sys.argv[0]} <filename>')
    exit(1)

filename = sys.argv[1] #'jet_logger.bak'
loc_db = LocationDB()
data = open(filename, 'rb').read()
container = Container.from_stream(open(filename, 'rb'), loc_db)
machine = Machine(container.arch)
ira = machine.lifter(loc_db)
dis_engine = machine.dis_engine(container.bin_stream, loc_db=loc_db)


# we want to start emulation right inside encrypt func, here:
# .text:0000000000001984                 mov     edx, [rdi+8]
# .text:0000000000001987                 mov     r8, rsi
# .text:000000000000198A                 xor     edx, cs:dword_4078
# .text:0000000000001990                 mov     esi, [rdi]
# .text:0000000000001992                 xor     esi, cs:key
# .text:0000000000001998                 ror     edx, 0Dh
start_address = 0x1984
# Disassemble fn
asm_cfg = dis_engine.dis_multiblock(start_address)
ira_cfg = ira.new_ircfg_from_asmcfg(asm_cfg)

# Now we need to prepare the state for symexec
# This function takes two parameters :
# - a pointer to a 16 bytes argument in RDI, 
# - a pointer to a 16 bytes result in RSI

init_state = {}
# RDI will point to our arguments @0xDEAD0000, RSI to the result @0xC0FEC0FE
init_state[ExprId('RDI', 64)] = ExprInt(0xDEAD0000, 64)
init_state[ExprId('RSI', 64)] = ExprInt(0xC0FEC0FE, 64)

# Symbolize each byte of input
for i in range(16):
    init_state[ExprMem(ExprInt(0xDEAD0000+i, 64), 8)] = ExprId(f'ARG{i}', 8)   

# We add relevant information produced by ptrace child/father mechanism
init_state[ExprMem(ExprInt(0x4070, 64), 32)] = ExprInt(0xC4A34DA6, 32)
init_state[ExprMem(ExprInt(0x4074, 64), 32)] = ExprInt(0xB7D80F2B, 32)
init_state[ExprMem(ExprInt(0x4078, 64), 32)] = ExprInt(0x77871143, 32)
init_state[ExprMem(ExprInt(0x407C, 64), 32)] = ExprInt(0x9D63F669, 32)

# Execution of the encrypt function
sb = SymbolicExecutionEngine(LifterModelCall_x86_64(loc_db) , state=init_state)
sb.run_block_at(ira_cfg, addr=start_address, step=False)

# At this point symex is over, we translate result to z3 expression
trans = TranslatorZ3(loc_db=loc_db)

# We want to find an input that result in this key we retrieved from ptrace child/father mechanism
values = [0x2F, 0x0D, 0x57, 0x26, 0xB6, 0x45, 0x41, 0x68,
0x84, 0x9A, 0xC1, 0x1B, 0xAC, 0x4C, 0x2E, 0x3C]


s = z3.Solver()

for i in range(16):
    s.add(trans.from_expr(ExprId(f'ARG{i}', 8)) >= trans.from_expr(ExprInt(0x20, 8) ))
    s.add(trans.from_expr(ExprId(f'ARG{i}', 8)) <= trans.from_expr(ExprInt(0x7F, 8) ))
    expr = sb.eval_expr(ExprMem(ExprInt(0xC0FEC0FE+i, 64), 8))
    cmp = ExprInt(values[i], 8)
    cmp = trans.from_expr(cmp)
    expr = trans.from_expr(expr)
    s.add(expr == cmp)

assert (s.check() == z3.sat)

## Extract values from model
model = s.model()
r = {}
for model_index, _ in enumerate(model):
    index = int(_.name().split("ARG")[1])
    r[index] = _

password = ''.join([chr(int(str(model[r[i]]))) for i in range(16)])
print(f'Password is : {password}')
$ python3 ./src/solve.py ../../jet_logger
Password is : l1k3d_th3_alg0_?