Skip to main content

20251025120557 - BLG - Flare-on 12 Chall 8 - Deobfuscating Indirect Calls and Jumps with Binja

img

Description

Just like the post for Challenge 7, this entry documents the journey to de-obfuscate this challenge. Note that this isn’t a challenge walkthrough; it’s more about sharing the process of creating a de-obfuscator using Binary Ninja. There will be some interesting discoveries along the way, along with tweaks made to the script for various edge cases. Instead of just parsing and de-obfuscating by matching disassembly instructions, the focus was on making the most of the license for added features. Only the Binary Ninja Intermediate Language (BNIL) was used for writing the obfuscator. Of course, during the challenge, the script was not created as smoothly as what it is going to look like XD

First Look at the CFG

Taking a look at the CFG of main function in IDA, it was clear that the basic blocks were lined up side by side, but most weren’t connected. There were nether incoming nor outgoing edges for most of them. Interestingly, during dynamic analysis, the last instruction in these blocks led to a calculated jump target, which makes reverse engineering tricky. Firstly, there are functions that are present but not created and analyzed yet. Secondly, the parent and child(ren) basic blocks are not apparent

In this case, de-obfuscation means recovering the control flow graph by linking as many blocks as possible. With the goal of de-obfuscating using Binary Ninja for Flare-on this year, I switched over to Binary Ninja :D

img

Indirect Callsites and Window

In a basic block, there may be multiple or no call sites. While some of these call sites may be considered “junk”, they should not be removed. Although we could have perform some function matching and inlining, I did not do that during the challenge. Instead, I added the first few lines of disassembly as comments to quickly identify which functions I could skip.

The following image highlights an example of indirect calls, where the target call sites are calculated in a fairly uniform manner, though there may be some variations. Each chunk in the image is highlighted with an orange box, which I will refer to as the “window” of instructions for analysis during the de-obfuscation process.

img

Obfuscated Tail Jumps

At the end of a basic block, the jump target is determined before executing the next jump. Two scenarios can occur: unconditional jumps and conditional jumps.

In the first example, there is a basic block that has only one possible jump target calculation. This represents a straightforward unconditional jump.

img

In the second example, we encounter a conditional jump, which means there are two possible jump targets. This is made possible by the setcc instruction, allowing for two potential values. Similarly, I have highlighted the window which we should analyze later on. This window can be emulated since everything is pretty much self-contained. Additionally, during emulation, we should emulate this twice with zero flag (ZF) set as 0 and another as 1.

img

Resolving Indirect Call Sites

For this challenge, I look towards Low Level IL (LLIL) since the pattern is straightforward. The following script shows how we can quickly look for all the indirect call sites.

Here is what it look like with LLIL view.

img

For this challenge, we can obtain and parse the disassembly of each instruction or examine the BNIL. However, I focused on the BNIL because I want to experiment more with it.

Finding Indirect Call to RAX in BB

The following shows the BNIL Instruction Graph of a call rax:

img

First thing to do is to write a script to locate indirect call sites within current function. We can do so by iterating each LLIL instructions and check for LLIL_CALL operations. Furthermore, we need to make sure that this LLIL_CALL actually calls a register (LLIL_REG). For this challenge, I assumed that all calculated call address would be stored into register RAX.

from binaryninja import BinaryView, LowLevelILOperation
bv:BinaryView = bv
funcs = [current_function]
for f in funcs:
    llil_func = f.llil
    if not llil_func:
        continue
    for bb in llil_func:
        items = list(bb)
        for instr in items:
            # Check that it is a call
            if instr.operation == LowLevelILOperation.LLIL_CALL:
                # Get the destination of the call
                dest = instr.dest
                if dest.operation == LowLevelILOperation.LLIL_REG:
                    reg_name = dest.src
                    print(f"Function {f.name} calls from {reg_name} @ {instr.address:#x}")

Checking against the output, we can confirm that the script is working!

img

To resolve the call addresses, we need to be able to go back up at least 4 instructions (Look at window for 0x1400749d1) back. I have also made the assumption that indirect calls are made to RAX (in case I wanted to apply deobfuscation script globally which I did not in the end).

To account for this, we can collect the instructions and create a “window” or basically a 4 or actually 5 (to account for rare edge cases found later on) instructions up to the indirect call sites in a list.

I have transformed the script to implement the following:

  1. Collect all the LLIL instructions in a list
  2. For each LLIL, detect indirect call sites
  3. If indirect call sites detected, create the window of instructions to analyze
  4. After that, attempt to resolve target from window
from binaryninja import BinaryView, LowLevelILOperation
bv:BinaryView = bv
funcs = [current_function]
LOOKBACK_INSNS = 4 # excluding call
"""
Assume that it is always call rax that we are interested in
"""
def is_call_via_rax(il):
if il.operation not in (LowLevelILOperation.LLIL_CALL, LowLevelILOperation.LLIL_TAILCALL):
return False
d = il.dest
return (d is not None and
d.operation == LowLevelILOperation.LLIL_REG and
getattr(d.src, "name", "").lower() == "rax")
### Parsing the window of instructions
def resolve_target_from_window(bv, window, call_addr=None):
return (None, None)
for f in funcs:
    llil_func = f.llil
    if not llil_func:
        continue
    for bb in llil_func:
        items = list(bb) # Get list of BB used to get window
        for idx, il in enumerate(items):
            if not is_call_via_rax(il):
                continue
            start = max (0, idx - LOOKBACK_INSNS)
            window = items[start:idx+1]
            # seq_start is used to track where to start NOP'ing from later
            tgt, seq_start = resolve_target_from_window(bv, window, call_addr=il.address)

Lets us take a closer look at the indirect call site window so that we can handle the pattern matching within resolve_target_from_window. Working from indirect call upwards, we see that the target is calculated via an add instruction. In this case, it is added by register RDX. RDX’s imm64 value is obtained by mov instruction. This value is added to RAX which originally got its value by dereferencing a constant value (the address to another imm64).

1400749f1 mov rax, qword [rel data_1400be0e8] ;; rax load from constant pointer
1400749f8 mov rdx, 0xfb46bff55d1567a9 ;; constant
140074a02 add rax, rdx ;; lhs is always rax but lhs might be different
140074a05 call rax

Finding Addition of RAX with RHS Register

The first thing I want to do is to look for pattern similar to “rax = rax + rhs”. Referring to BNIL Instruction graph, we want to make sure that the IL operation is LLIL_SET_REG with destination’s name to be “rax”. We also want the src’s operation to be LLIL_ADD and it has to be between two LLIL_REGs. The left LLIL_REG should be “rax” as well.

img Once that line of LLIL is found within the window, we want to indicate that this instruction is found. We also want to remember the RHS register to check if this register is also the register where imm64 constant is moved to later on.

def add_rax_rhs_reg(il):
    """Return RHS register name for: rax = rax + <reg>, else None."""
    if il.operation != LowLevelILOperation.LLIL_SET_REG:
        return None
    if getattr(il.dest, "name", "").lower() != "rax":
        return None
    s = il.src
    if s.operation != LowLevelILOperation.LLIL_ADD:
        return None
    if s.left.operation != LowLevelILOperation.LLIL_REG:
        return None
    if getattr(s.left.src, "name", "").lower() != "rax":
        return None
    if s.right.operation != LowLevelILOperation.LLIL_REG:
        return None
    reg = getattr(s.right.src, "name", "").lower()
    print( f"Found rax = rax + {reg} at {hex(il.address)}" )
    return reg
def resolve_target_from_window(bv, window, call_addr=None):
    add_reg = None # track register used in the add instruction
    add_idx = None # track index of the add instruction
    # Starting from the bottom up (doesnt make a diff)
    for i in range(len(window)-1, -1, -1):
        # Search for this pattern and return the reg name of rhs
        # to be used to check if reg is where imm64 is moved to
        r = add_rax_rhs_reg(window[i])
        if r:
            add_reg = r
            add_idx = i
            break
    return (add_reg, add_idx)

Running the script with these would lead us to confirm the detection of the add instruction. We see that it correctly indicated the RHS register.

img

Once this IL instruction is successfully detected within the analysis window, we can move on to look for the other two patterns.

Finding Moving of imm64 Value to RHS Register

Let’s take a look at how the instruction graph look like for this LLIL instruction <mov <RHS register>, <constant>. This time, we want to match the IL for LLIL_SET_REG. The src of the instruction should specifically be LLIL_CONST. If this matches, then we need to extract the constant value. For this script, I have converted this to unsigned 64 bits value to make it easier to compare.

img During the target address calculation later, we have to remember to make this signed. The following is the script to look for this pattern within the window. In this script, I have also included the check to make sure the register that is obtaining the imm64 value is indeed the RHS register that was used to add RAX for the target call address calculation.

MASK64 = (1 << 64) - 1
def u64(x):
    return x & MASK64
def mov_reg_imm64(il, target_reg):
    """
    Match <reg> = <const>
    """
    if il.operation != LowLevelILOperation.LLIL_SET_REG:
        return None
    if getattr(il.dest, "name", "").lower() != target_reg:
        return None
    s = il.src
    if s.operation != LowLevelILOperation.LLIL_CONST:
        return None
    const_val = u64(s.constant)
    print( f"Found mov {target_reg}, {hex(const_val)} at {hex(il.address)}" )
    return const_val
def resolve_target_from_window(bv, window, call_addr=None):
    add_reg = None # track register used in the add instruction
    add_idx = None # track index of the add instruction
    # Starting from the bottom up (doesnt make a diff)
    for i in range(len(window)-1, -1, -1):
        # Search for this pattern and return the reg name of rhs
        # to be used to check if reg is where imm64 is moved to
        r = add_rax_rhs_reg(window[i])
        if r:
            add_reg = r
            add_idx = i
            break
    # Ignore this window and continue searching for other potential windows
    if add_reg is None:
        return (None, None)
    imm_val = None
    imm_idx = None
    # Starting from the bottom up (doesnt make a diff)
    for i in range(len(window)-1, -1, -1):
        #  <reg> = <const>  (check for immediate value moves)
        il = window[i] #  get the current instruction
        if imm_val is None:
            c = mov_reg_imm64(il, add_reg) # we want to move to the register (add_reg)
            if c is not None:
                imm_val = c
                imm_idx = i
    return (None, None)

Running script confirms that we now have unsigned 64 immediate values. Label 1 indicates the value that is set to RHS register RDX. Label 2 shows the value being subjected to 2’s complement to get the unsigned value which is then confirmed by Label 3.

img

Finding Loads to RAX from Constant Pointer

To finally get the other value for the calcuation, we will need to look for mov rax, qword [rel <constant Ptr>] We can begin crafting based on the instruction graph! We want the destination to be RAX (assumed). We want to track instruction with LLIL_SET_REG of value that is loaded (LLIL_LOAD) by a constant pointer (LLIL_CONST_PTR). If we find this pattern, extract the constant pointed to by the constant from LLIL_CONST_PTR

img

To we get the constant value from the constant pointer, we can read from the binary view with bv.read_pointer() API! The following shows the newly updated script to detect all three patterns.

def rax_load_from_constptr_q(il, bv: BinaryView):
    """
    Match: rax = [CONSTPTR].q  -> u64 value, else None.
    """
    if il.operation != LowLevelILOperation.LLIL_SET_REG:
        return None
    if getattr(il.dest, "name", "").lower() != "rax":
        return None
    s = il.src
    if s.operation != LowLevelILOperation.LLIL_LOAD:
        return None
    a = s.src
    if not a:
        return None
    if a.operation == LowLevelILOperation.LLIL_CONST_PTR:
        print( f"Found rax = [CONSTPTR].q at {hex(il.address)}" )
        data_pointer = bv.read_pointer(a.constant)
        print( f"  qword loaded = {hex(u64(data_pointer))}" )
        return u64(data_pointer)
    return None
def resolve_target_from_window(bv, window, call_addr=None):
    add_reg = None # track register used in the add instruction
    add_idx = None # track index of the add instruction
    # Starting from the bottom up (doesnt make a diff)
    for i in range(len(window)-1, -1, -1):
        # Search for this pattern and return the reg name of rhs
        # to be used to check if reg is where imm64 is moved to
        r = add_rax_rhs_reg(window[i])
        if r:
            add_reg = r
            add_idx = i
            break
    # Ignore this window and continue searching for other potential windows
    if add_reg is None:
        return (None, None)
    imm_val = None  # Tracking imm64 value set to add_reg
    imm_idx = None  # Tracking index of imm64 move instruction\
    rax_qword = None # Track the qword loaded into rax
    rax_idx = None  # Tracking index of rax load instruction
    # Starting from the bottom up (doesnt make a diff)
    for i in range(len(window)-1, -1, -1):
        #  <reg> = <const>  (check for immediate value moves)
        il = window[i] #  get the current instruction
        if imm_val is None:
            c = mov_reg_imm64(il, add_reg) # we want to move to the register (add_reg)
            if c is not None:
                imm_val = c
                imm_idx = i
        if rax_qword is None:
            v = rax_load_from_constptr_q(il,bv)
            if v is not None:
                rax_qword = v
                rax_idx = i
        # We can stop searching the window if we found the two patterns
        if imm_val is not None and rax_qword is not None:
            break
    # Make sure we can find all three instructions
    if imm_val is None or rax_qword is None or add_reg is None:
        return (None, None)
    return (None, None)

Running the updated, observe that we have successfully detected the pattern (1), extracted the constant pointer (2) as well as extracted the constant value (3).

img

Calculating Target Jump Address

Now that we are able to get all three patterns in the window, we can attempt to resolve the indirect call target. One thing to note is that when performing this target call site, we want to add the rax value with the signed imm64 value. With these, we can proceed to calculate the target value!

def s64(x):
    x &= MASK64
    return x if x < (1 << 63) else x - (1 << 64)
def resolve_target_from_window(bv, window, call_addr=None):
    add_reg = None # track register used in the add instruction
    add_idx = None # track index of the add instruction
    # Starting from the bottom up (doesnt make a diff)
    for i in range(len(window)-1, -1, -1):
        # Search for this pattern and return the reg name of rhs
        # to be used to check if reg is where imm64 is moved to
        r = add_rax_rhs_reg(window[i])
        if r:
            add_reg = r
            add_idx = i
            break
    # Ignore this window and continue searching for other potential windows
    if add_reg is None:
        return (None, None)
    imm_val = None  # Tracking imm64 value set to add_reg
    imm_idx = None  # Tracking index of imm64 move instruction\
    rax_qword = None # Track the qword loaded into rax
    rax_idx = None  # Tracking index of rax load instruction
    # Starting from the bottom up (doesnt make a diff)
    for i in range(len(window)-1, -1, -1):
        #  <reg> = <const>  (check for immediate value moves)
        il = window[i] #  get the current instruction
        if imm_val is None:
            c = mov_reg_imm64(il, add_reg) # we want to move to the register (add_reg)
            if c is not None:
                imm_val = c
                imm_idx = i
        if rax_qword is None:
            v = rax_load_from_constptr_q(il,bv)
            if v is not None:
                rax_qword = v
                rax_idx = i
        # We can stop searching the window if we found the two patterns
        if imm_val is not None and rax_qword is not None:
            break
    # Make sure we can find all three instructions
    if imm_val is None or rax_qword is None or add_reg is None:
        return (None, None)
    target = u64(rax_qword + s64(imm_val))
    print( f"Resolved target address: {hex(target)}" )
    seq_start_il = window[min(x for x in (imm_idx, rax_idx, add_idx) if x is not None)]
    seq_start_addr = seq_start_il.address if seq_start_il is not None else None
    print( f"Sequence start address: {hex(seq_start_addr) if seq_start_addr is not None else 'N/A'}" )
    return target, seq_start_addr

We now would be able to resolve all indirect call sites within the selected main function!

img

To make things easier to read, I have annotated the first few disassembly lines of every resolved function at the indirect call sites since there are many “junk-ish” functions that were resolved ( I did not inline them ) are noise, I can just skip those if I see similar patterns. Furthermore, many of those resolved function may not be analyzed yet. To get over that, we can check if we can access that function object. If not, we can create one at that target address with bv.create_user_function(target_addr). We can also define new FunctionSymbol as well. After that, we can preview some instructions and add those preview as comments. All of this would be handled in the next snippet:

def disasm_preview_inline(bv, addr: int, max_insns: int = 3, max_chars: int = 120, include_addr: bool = False):
    """Return 'mnemonic1 ; mnemonic2 ; mnemonic3' (optionally with addresses), truncated."""
    parts = []
    cur = addr
    for _ in range(max_insns):
        data = bv.read(cur, 16)
        if not data:
            break
        try:
            tokens, length = bv.arch.get_instruction_text(data, cur)
        except Exception:
            break
        if not length:
            break
        text = "".join(tok.text for tok in tokens)
        parts.append(f"{cur:#x}  {text}" if include_addr else text)
        cur += length
    line = " ; ".join(parts)
    if len(line) > max_chars:
        line = line[:max_chars - 1] + "…"
    return line
def annotate_indirect_callsite(bv, call_addr, target_addr):
    print("annotating indirect callsite...")
    """Annotate the callsite at call_addr with the resolved target_addr."""
    target_addr = u64(target_addr)
    print( f"Annotating callsite at {hex(call_addr)} with target {hex(target_addr)}" )
    try:
        if not bv.get_function_at(target_addr):
            bv.create_user_function(target_addr)
    except Exception as e:
        print(f"Error annotating callsite at {hex(call_addr)}: {e}")
    sym_name = f"Resolved_0x{target_addr:x}"
    ## Check for existing symbol in case there are duplicates
    try:
        if not any(s.name == sym_name for s in bv.get_symbols(target_addr)):
            bv.define_user_symbol(Symbol(SymbolType.FunctionSymbol, target_addr, sym_name))
    except Exception as e:
        print(f"Error defining symbol at {hex(target_addr)}: {e}")
    try:
        preview = disasm_preview_inline(bv, target_addr, max_insns=3, max_chars=120, include_addr=False)
        print( f"Preview of target {hex(target_addr)}: {preview}" )
        comment = f"---> 0x{target_addr:016x}"
        if preview:
            comment += f" | {preview}"
        bv.set_comment_at(call_addr, comment)
    except Exception:
        print(f"Error setting comment at {hex(call_addr)}")
        pass

Running with these updated script and calling annotate_indirect_callsite function should allow us to see function preview and annotated call site.

img

NOP and Patching Calls

To patch to make a call to those exact resolved functions, we can first NOP all the LLIL instruction within the window. For this script, I have calculated the call target address (rel32) offset and patched in the call <rel32> starting from the first address of that window. I have thought about inlining functions that are small enough, but I did not go ahead with that.

To calculate the rel32 offset, I took the address of the first LLIL instruction in the window, add 5 (size of this call rel32 instruction) and subtract this value from the target.

rel32 = target - (window[0].address + 5)

Beautifully, Binary Ninja has an API to create_nop which allows me to skip extra step to calculate the size of instruction of LLIL within the window and writing NOP to those locations. I just need to iterate through the window and call that API. The call rel32 instructions are then written to the start of the window via the bv.write() API. Really neat.

def patch_obfuscated_indirect_call(bv: BinaryView, window, call_addr, target:int):
# NOP all instruction in window before
for il in window:
if not bv.convert_to_nop(il.address):
print( f"[ERROR] - Failed to NOP instruction at {hex(il.address)}" )
for il in window:
# We want to patch in e8 <rel32> (size = 5)
rel32 = target - (window[0].address + 5)
if -0x80000000 <= rel32 <= 0x7FFFFFFF:
# Be careful not to write straight at the call address because we will overwrite three bytes into the next iosntruction
# In this case, I choose to write to the first IL in window
bv.write(window[0].address, b"\xE8" + int(rel32 ).to_bytes(4, "little", signed=True))
print( f"Patched direct call E8 {rel32 & 0xffffffff} at {hex(call_addr)}" )
return True
else:
print("tasukete kudasai!!!!!")
return False

The following shows the successful patching of resolved function! The function has name Resolved_0x* which is set by us which indicates that Binary Ninja did not previously detect and analyze this function and was created because of our call target resolution.

img

Resolving Tail Jumps

Previously, we wanted to resolve all the indirect calls. Now, we want to start linking up Basic Blocks (BB). There are two types of jumps that we need to deal with here: Conditional and Unconditional Jumps.

Dealing with Unconditional Jump

The following shows an example of unconditional jump. This means there will only be one possible value. While emulating quickly emulating with SeNinja, I have determined that the window should start from the mov of constant value from constant pointer that is similar to what we have seen when trying to resolve indirect call sites. The difference here is that I am not sure if we can assume that it would always be stored into RAX register. The end of the window would be the last instruction in the BB.

img

The following demonstrates that we can calculate the next jump target.

rax = 0xbfceabba05b72099
rcx = 0x403154473a52c437
rcx = bv.read_pointer(rax + rcx)
r8 = 0xf73d01c0b270c6c
rax = rcx
rax |= r8
r9 = rcx
r9 -= rax
rdx = 0x1ee7a038164e18d8
rdx = rdx + r9*2
rcx &= r8
rax -= rcx
rcx = rax
rcx |= rdx
rax &= rdx
rax += rcx
print("Jump Target : ", hex(rax & 0xffffffffffffffff))
"""
Jump Target : 0x140074b55
"""

With this simplicity (compared to the having many complex calls) in mind, I have chosen unicorn to emulate these instructions.

For this script, I did not make things recursive. This means that it will not automatically try to work on new BB that appears from creating the unconditional jump to calculated target. This way, I can choose which BB to work on by pressing on the run script hotkey.

Some things to note about scripting is that current_function and here are keywords that are exposed by Binary Ninja so that we can get the current function and the current address respectively based on where our cursor is selecting.

img

With that, we can get the BB which has instruction selected by our cursor:

## Get to the current Basic Block
bb = next((b for b in current_function.basic_blocks if b.start <= here <= b.end), None)
if bb is not None:
    print(f"Current Basic Block from {hex(bb.start)} to {hex(bb.end)}")
"""
Current Basic Block from 0x140074860 to 0x140074b55
"""

Checking Last Instruction

Here, we can also assume that all tail jumps in this obfuscation are all “jmp rax”s. So we can just look at the last instruction of that basic block. This is a lot easier to search because we are checking with just the last instruction of selected basic block and we assume that the instruction would always be JMP RAX which means we can just do simple token comparisons. While working on this, it is a lot of auto-completion and searching for relevant attributes while writing the script in visual studio code. Eventually, this is what the script looks like:

found_tail_jump = False
bb = next((b for b in current_function.basic_blocks if b.start <= here <= b.end), None)
if bb is not None:
    print(f"Current Basic Block from {hex(bb.start)} to {hex(bb.end)}")
    # get the last instruction in this basic block
    instruction_list = list(bb)
    last_instr = instruction_list[-1]
    instr = last_instr[0]
    # We want to match['jmp', '     ', 'rax']
    first_token = instr[0].text.lower()
    last_token = instr[2].text.lower()
    if first_token == 'jmp' and last_token == 'rax':
        print("This is an indirect jump via rax")
        found_tail_jump = True
if not found_tail_jump:
    print("No indirect tail jump via rax found in the current basic block.")

When dealing with emulation, I also want to collect all instructions in a list to create a window for processing, mainly to look for the first instruction to start emulation from. After observing different tail unconditional jump, I realised that all we need to look out for is for instruction similar to mov rax, qword [rel <constant pointer>] starting from the last instruction upwards. This will be different from conditional jump as we will see later on.

Initial Window Creation

To create the window for tail jump calculation, I have assumed that the calculation is done after the all the indirect calls. Therefore, we can recollect the instructions from the same basic block. This time, starting from the last instruction, we find our way to the first NOP if any.

tailcall_window = []
offset_from_start_of_bb = 0
instruction_list = []
bb = next((b for b in current_function.basic_blocks if b.start <= here <= b.end), None)
for instr in bb:
    instruction_list.append({ "address" : bb.start + offset_from_start_of_bb, "instruction" : instr })
    offset_from_start_of_bb += instr[1]
tailcall_start_addr = 0
tailcall_end_addr   = 0
# Find the earliest instruction of tail call window (first NOP from the back)
for idx in range(len(instruction_list)-1, -1, -1):
    if instruction_list[idx]["instruction"][0][0].text.lower() == 'nop':
        tailcall_start_addr = instruction_list[idx+1]["address"]
        break
tailcall_end_addr = instruction_list[-1]["address"]
print(f"Tail call window starts at {hex(tailcall_start_addr)}")
print(f"Tail call window ends at {hex(tailcall_end_addr)}")

The window here does not start at that mov rax, qword [rel <constant pointer>] pattern at yet but rather at the start of the first NOP. If you are wondering “What if there are no indirect calls but still a jmp rax?”, you are right to think about that. This is dealt with later when I realised that this happened during static analysis when I create function that belongs to another not-analyzed-yet function. Nevertheless, the following image shows the start of the window (not the true start) located right after the last NOP of the BB.

img

Finding True Start of Window

To find the true start of the window, first obtain list of LLIL instructions to further look for the start of the emulation.

## within this window, we want to get the LLIL instructions
for i in llil_func.instructions:
    if i.address >= tailcall_window_start_addr and i.address <= tailcall_window_end_addr:
        tailcall_llil_window.append(i)
## Now we start hunting for loading of values from constant pointer
for il in tailcall_llil_window:
    print(f"LLIL Instruction  {il}")

The output for the LLIL Instructions can be confirmed by the output!

LLIL Instruction rcx = [rbp + 0x198 {var_3b0}].q
LLIL Instruction rax = [rax].q
LLIL Instruction [rax + 0x28].q = rcx
LLIL Instruction rax = [0x1400c2fd0].q <------ THIS IS THE TRUE START OF THE WINDOW
LLIL Instruction rcx = 0x403154473a52c437
LLIL Instruction rcx = [rax + rcx].q
LLIL Instruction r8 = 0xf73d01c0b270c6c
LLIL Instruction rax = rcx
LLIL Instruction rax = rax | r8
LLIL Instruction r9 = rcx
LLIL Instruction r9 = r9 - rax
LLIL Instruction rdx = 0x1ee7a038164e18d8
LLIL Instruction rdx = rdx + (r9 << 1)
LLIL Instruction rcx = rcx & r8
LLIL Instruction rax = rax - rcx
LLIL Instruction rcx = rax
LLIL Instruction rcx = rcx | rdx
LLIL Instruction rax = rax & rdx
LLIL Instruction rax = rax + rcx
LLIL Instruction jump(rax)

To determine the true start of the window, write the function to look for the true start within the current “window” that we have. Similar to rax_load_from_constptr_q, we have the following structure in BNIL Instruction Graph.

img

Unlike rax_load_from_constptr_q, I did not restrict the destination register to be “rax”.

def load_from_const_pointer(bv, il):
    data = None
    if il.operation != LowLevelILOperation.LLIL_SET_REG:
        return None
    if not il.src: return None
    if il.src.operation != LowLevelILOperation.LLIL_LOAD:
        return None
    src = il.src.src
    if not src:
        return None
    if src.operation != LowLevelILOperation.LLIL_CONST_PTR:
        return None
    ptr_addr = src.constant
    data = bv.read_pointer(ptr_addr)
    print(f"Pointer Value: {ptr_addr} -> {hex(u64(data))}")
    return data
...
...
## Now we start hunting for loading of value
#   from constant pointer which is the start of the emulation
found_conditional_jump_emu_start = False
for il in tailcall_llil_window:
    print(f"LLIL Instruction  {il}")
    pointer_value = load_from_const_pointer(bv, il)
    print(f"Pointer Value: {pointer_value}")
    if pointer_value is not None:
        found_conditional_jump_emu_start = True
        print(f"Found load from constant pointer at {hex(il.address)} with value {hex(pointer_value)}")
        break
if found_conditional_jump_emu_start == False:
    print("No load from constant pointer found in tail call window.")
    raise SystemExit()

Begin Emulation, NOP and Patching

To patch, we can follow very similarly on how to patch a jmp <rel32> instruction. The following shows how we can use setup_uc and emulate_to_tail_jump functions , write comments, NOP those instructions starting from the true start of the window and

def write_rel32_jump(bv, at, target):
    rel = (target - (at + 5)) & 0xFFFFFFFF
    signed = rel if rel < (1<<31) else rel - (1<<32)
    if -0x80000000 <= signed <= 0x7FFFFFFF:
        bv.write(at, b"\xE9" + int(signed).to_bytes(4, "little", signed=True))
        return 5
    else:
        print(f"Rel32 jump from {at:#x} to {target:#x} out of range")
        raise SystemExit()
...
...
if found_conditional_jump_emu_start == False:
    print("No load from constant pointer found in tail call window.")
    raise SystemExit()
else:
    # Found the location for start of emulation
    emu_start_addr = tailcall_window_start_addr
    emu_stop_addr = tailcall_window_end_addr
    print(f"Emulation will start at {hex(emu_start_addr)}")
    # Setup Unicorn Emulator
    u = setup_uc()
    # Takes in zf to deal with setcc later on
    t = emulate_to_tail_jump(u, emu_start_addr, emu_stop_addr, zf=None)
    print(f"Emulation result: {hex(t) if t is not None else 'N/A'}")
    bv.set_comment_at(emu_stop_addr, f"Emulated tail jump target: {hex(t) if t is not None else 'N/A'}")
    if bv.get_function_at(t) is None and t is not None:
        bv.create_user_function(t)
    ## TODO: Add extra check in case it starts to point somewhere outside of 0x14xxxxxxxx
    ## We can NOP all the instruction from the tail call llil window
for il in tailcall_llil_window:
if il.address < tailcall_window_start_addr:
continue
if not bv.convert_to_nop(il.address):
print( f"[ERROR] - Failed to NOP instruction at {hex(il.address)}" )
    # Finally, we patch in the direct jump
    if write_rel32_jump(bv, tailcall_window_start_addr, t):
        print( f"Successfully patched direct jump at {hex(tailcall_window_start_addr)} to {hex(t)}" )
    else:
        print( f"[ERROR] - Failed to patch direct jump at {hex(tailcall_window_start_addr)}")

Here, we successfully connected two basic blocks with an unconditional jump!

img

Dealing with Conditional Jump

The following shows an example that entails a conditional jump. Depending on the value of RAX and RCX, the zero flag (zf) may or may not be set. Thinking ahead during static analysis, we have prepared the zf flag as parameter for emulate_to_tail_jump function which made life easier. To get the two possible values, we just need to emulate twice withzf = 0 and zf = 1. But before that, we will need to get the true start of the window which is not the same as the unconditional jump. []

img

Finding True Start of Window

In LLIL, we want to match up starting from the comparison

img

Looking at the BNIL Instruction Graph, we can easily find this within the window. Note that when piecing things up, the “initial” window searching algorithm is the same for both conditional and unconditional jumps. However, since they both also have data being loaded from constant pointer, we should prioritize look for the conditional jumps first and fallback to conditional jump if the load does not exist in the basic block.

The following image shows the the BNIL instruction graph of the instruction that we want to match:

img

def set_condition_cmpne_setcc(bv:BinaryView, il):
if il.operation != LowLevelILOperation.LLIL_SET_REG:
return False
if hasattr(il, "src") and il.src is not None:
s = il.src
if s.operation == LowLevelILOperation.LLIL_CMP_NE:
print(f"Found CMP_NE at {hex(il.address)}")
bv.set_comment_at(il.address, "CMP_NE detected here")
return True
return False
...
...
##### main function
found_conditional_jump_emu_start = False
for il in tailcall_llil_window:
    if set_condition_cmpne_setcc(bv, il):
        found_conditional_jump_emu_start = True
        tailcall_window_start_addr = il.address
        print(f"Found conditional jump emulation start at {hex(il.address)}")

We see that we can successfully look for the true start of the window which we can start emulating from.

img

Begin Emulation

Now, we can go ahead and emulate when zf is 0 and 1. After that, set a comment for easier reading.

for il in tailcall_llil_window:
if set_condition_cmpne_setcc(bv, il):
found_conditional_jump_emu_start = True
tailcall_window_start_addr = il.address
print(f"Found conditional jump emulation start at {hex(il.address)}")
## Emulate twice for ZF = 0 and ZF = 1\
u0 = setup_uc()
t0 = emulate_to_tail_jump(u0, il.address, tailcall_window_end_addr, zf=0)
u1 = setup_uc()
t1 = emulate_to_tail_jump(u1, il.address, tailcall_window_end_addr, zf=1)
print(f"Emulation result ZF=0: {hex(t0) if t0 is not None else 'N/A'}")
print(f"Emulation result ZF=1: {hex(t1) if t1 is not None else 'N/A'}")
bv.set_comment_at(tailcall_window_end_addr, f"Emulated tail jump targets: ZF=0->{hex(t0) if t0 is not None else 'N/A'}, ZF=1->{hex(t1) if t1 is not None else 'N/A'}")
if bv.get_function_at(t0) is None and t0 is not None:
bv.create_user_function(t0)
if bv.get_function_at(t1) is None and t1 is not None:
bv.create_user_function(t1)
break

Great, we get two resolved addresses this time.

img

Patching the Conditional Jumps

We first NOP all the instruction in the window that is involved with emulation. Instead of patching jmp statement, we do jz or je which starts with 0f 84 <rel32> whose rel32 belong to result when zf = 0.

def write_rel32_jz(bv, at, target):
rel = (target - (at + 6)) & 0xFFFFFFFF
signed = rel if rel < (1<<31) else rel - (1<<32)
if -0x80000000 <= signed <= 0x7FFFFFFF:
bv.write(at, b"\x0F\x84" + int(signed).to_bytes(4, "little", signed=True))
return 6
return 0
...
...
####### main function
## We want to NOP the instructions from true start of the window
for il in tailcall_llil_window:
if il.address < tailcall_window_start_addr:
continue
if not bv.convert_to_nop(il.address):
print( f"[ERROR] - Failed to NOP instruction at {hex(il.address)}" )
raise SystemExit()
# Patch JZ at the start of emulation window
if t0 is not None and write_rel32_jz(bv, tailcall_window_start_addr, t0):
print( f"Successfully patched JZ at {hex(tailcall_window_start_addr)} to {hex(t0)}" )
else:
print( f"[ERROR] - Failed to patch JZ at {hex(tailcall_window_start_addr)} (t0={t0})" )

We see that the conditional jump is now in place

img

Script -> Test -> Debug -> Repeat

While testing this script out, we will face a few edge cases. In this section, we will see some of the things discovered and how the script is adjusted to fix these to create a good enough deobfuscator.

Edge Case #1 - True Start of Window Match Variants

When looking for the true start of the window for conditional jump, it is not just limited to LLIL_CMP_NE but also others.

There is another case where it is LLIL_CMP_E

img

Yet another case which is LLIL_CMP_SGE

img

The Fix

I have then renamed from set_condition_cmpne_setcc to set_condition_cmp_setcc and included more of other LowLevelILOperation comparisons:

def set_condition_cmp_setcc(bv:BinaryView, il):
if il.operation != LowLevelILOperation.LLIL_SET_REG:
return False
if hasattr(il, "src") and il.src is not None:
s = il.src
if s.operation == LowLevelILOperation.LLIL_CMP_NE or s.operation == LowLevelILOperation.LLIL_CMP_E or s.operation == LowLevelILOperation.LLIL_CMP_SLT or s.operation == LowLevelILOperation.LLIL_CMP_SLE or s.operation == LowLevelILOperation.LLIL_CMP_SGT or s.operation == LowLevelILOperation.LLIL_CMP_SGE:
print(f"Found CMP_NE at {hex(il.address)}")
bv.set_comment_at(il.address, "CMP detected here")
return True
return False

Edge Case #2 - BB may not have Indirect Calls

During testing, deobfuscation failed and the following debug messages shows that while emulation begins at 0x14000d55a, somehow it was tryhing to reach 0x1400d1ef0 which is way too far.

LLIL Instruction rax = [0x1400ba460].q
Pointer Value: 5369472096 -> 0x64512dcef4b67fd2
Pointer Value: 7228609243798470610
Found load from constant pointer at 0x14000d55a with value 0x64512dcef4b67fd2
Emulation will start at 0x14000d55a
Error occurred during emulation: Invalid memory fetch (UC_ERR_FETCH_UNMAPPED)
Did not reach jmp rax (RIP=0x1400d1ef0)
Emulation result: N/A
The Fix

It turns out that that basic block did not have any indirect call and therefore, there were no NOP. This therefore broke when we are calculating the start of the initial window. This means we need to limit the number of instructions to lookback or it might end up looking into the wrong basic block giving wrong values.

The following changes to limit up to 25 instructions in the initial window creation fixes that issue:

# Find the earliest instruction of tail call window (first NOP from the back)
for idx in range(len(instruction_list)-1, -1, -1):
    if instruction_list[idx]["instruction"][0][0].text.lower() == 'nop' :
        tailcall_window_start_addr = instruction_list[idx+1]["address"]
        break
    ## Some BB does not have NOPS, we limit the number of instructions in case of false positives
    if len(instruction_list) - idx > 25: ## Hardcoded to 225 because that was the highest we need after iterative testing
        tailcall_window_start_addr = instruction_list[0]["address"]
        break

Edge Case #3 - Indirect Calls Calculation but not Addition but Subtracting

After some testing, some indirect calls were not resolved. It turns out that there are cases where instead of adding, we subtract instead.

img

The Fix

The following script shows the changes that were made:

  1. Collect the two different patterns
  2. Collect the correct RHS register
  3. Calculate the target jump target depending on which pattern matched
def resolve_target_from_window(bv, window, call_addr=None):
add_reg = None # track register used in the add instruction
add_idx = None # track index of the add instruction
sub_reg = None # track register used in the sub instruction
sub_idx = None # track index of the sub instruction
# Starting from the bottom up (doesnt make a diff)
for i in range(len(window)-1, -1, -1):
# Search for this pattern and return the reg name of rhs
# to be used to check if reg is where imm64 is moved to
r = add_rax_rhs_reg(window[i])
if r:
add_reg = r
add_idx = i
break
r = sub_rax_rhs_reg(window[i]) ## Added this to account for the sub
if r:
sub_reg = r
sub_idx = i
break
...
...
    # Starting from the bottom up (doesnt make a diff)
    for i in range(len(window)-1, -1, -1):
        #  <reg> = <const>  (check for immediate value moves)
        il = window[i] #  get the current instruction
        if imm_val is None:
            target_reg = add_reg if add_reg is not None else sub_reg ## Depending on whether it is an add or sub, we want to check that imm64 is moved to that register
            c = mov_reg_imm64(il, target_reg) # we want to move to the register
            ...
            ...
    """ Time to calculate the target address depending on add or sub """
    if sub_reg:
        target = u64(rax_qword - s64(imm_val))
        print( f"Resolved target address: {hex(target)}" )
        seq_start_il = window[min(x for x in (imm_idx, rax_idx, sub_idx) if x is not None)]
        seq_start_addr = seq_start_il.address if seq_start_il is not None else None
        print( f"Sequence start address: {hex(seq_start_addr) if seq_start_addr is not None else 'N/A'}" )
        return target, seq_start_addr
    elif add_reg:
        target = u64(rax_qword + s64(imm_val))
        print( f"Resolved target address: {hex(target)}" )
        seq_start_il = window[min(x for x in (imm_idx, rax_idx, add_idx) if x is not None)]
        seq_start_addr = seq_start_il.address if seq_start_il is not None else None
        print( f"Sequence start address: {hex(seq_start_addr) if seq_start_addr is not None else 'N/A'}" )
        return target, seq_start_addr

Now, we can see the resolution:

img

Edge Case #4 - Lookback of 4 isn’t enough

During testing, I realised that some calls are not resolved. I do not have a screenshot for that now but it seems we need the lookback of at least 5. With that, there were no more problems for that edge case.

The Fix

LOOKBACK_INSNS = 5 # Change from 4 to 5

Full Deobfuscation Script

from binaryninja import BinaryView, LowLevelILOperation
from binaryninja import Symbol, SymbolType
import unicorn as uc
from unicorn import Uc, UC_ARCH_X86, UC_MODE_64, UC_PROT_READ, UC_PROT_WRITE, UC_PROT_EXEC
from unicorn.x86_const import *
PAGE = 0x1000
MASK64 = (1 << 64) - 1
STACK_TOP, STACK_SIZE = 0x00007FFF00000000, 0x01000000
MAX_WINDOW_SIZE = 25
def is_call_via_rax(il):
if il.operation not in (LowLevelILOperation.LLIL_CALL, LowLevelILOperation.LLIL_TAILCALL):
return False
d = il.dest
return (d is not None and
d.operation == LowLevelILOperation.LLIL_REG and
getattr(d.src, "name", "").lower() == "rax")
def add_rax_rhs_reg(il):
"""Return RHS register name for: rax = rax + <reg>, else None."""
if il.operation != LowLevelILOperation.LLIL_SET_REG:
return None
if getattr(il.dest, "name", "").lower() != "rax":
return None
s = il.src
if s.operation != LowLevelILOperation.LLIL_ADD:
return None
if s.left.operation != LowLevelILOperation.LLIL_REG:
return None
if getattr(s.left.src, "name", "").lower() != "rax":
return None
if s.right.operation != LowLevelILOperation.LLIL_REG:
return None
reg = getattr(s.right.src, "name", "").lower()
print( f"Found rax = rax + {reg} at {hex(il.address)}" )
return reg
def sub_rax_rhs_reg(il):
"""Return RHS register name for: rax = rax - <reg>, else None."""
if il.operation != LowLevelILOperation.LLIL_SET_REG:
return None
if getattr(il.dest, "name", "").lower() != "rax":
return None
s = il.src
if s.operation != LowLevelILOperation.LLIL_SUB:
return None
if s.left.operation != LowLevelILOperation.LLIL_REG:
return None
if getattr(s.left.src, "name", "").lower() != "rax":
return None
if s.right.operation != LowLevelILOperation.LLIL_REG:
return None
reg = getattr(s.right.src, "name", "").lower()
print( f"Found rax = rax - {reg} at {hex(il.address)}" )
return reg
def u64(x):
return x & MASK64
def s64(x):
x &= MASK64
return x if x < (1 << 63) else x - (1 << 64)
def mov_reg_imm64(il, target_reg):
"""
Match <reg> = <const>
"""
if il.operation != LowLevelILOperation.LLIL_SET_REG:
return None
if getattr(il.dest, "name", "").lower() != target_reg:
return None
s = il.src
if s.operation != LowLevelILOperation.LLIL_CONST:
return None
const_val = u64(s.constant)
print( f"Found mov {target_reg}, {hex(const_val)} at {hex(il.address)}" )
return const_val
def rax_load_from_constptr_q(il, bv: BinaryView):
"""
Match: rax = [CONSTPTR].q -> u64 value, else None.
"""
if il.operation != LowLevelILOperation.LLIL_SET_REG:
return None
if getattr(il.dest, "name", "").lower() != "rax":
return None
s = il.src
if s.operation != LowLevelILOperation.LLIL_LOAD:
return None
a = s.src
if not a:
return None
if a.operation == LowLevelILOperation.LLIL_CONST_PTR:
print( f"Found rax = [CONSTPTR].q at {hex(il.address)}" )
data_pointer = bv.read_pointer(a.constant)
print( f" qword loaded = {hex(u64(data_pointer))}" )
return u64(data_pointer)
return None
def resolve_target_from_window(bv, window, call_addr=None):
add_reg = None # track register used in the add instruction
add_idx = None # track index of the add instruction
sub_reg = None # track register used in the sub instruction
sub_idx = None # track index of the sub instruction
# Starting from the bottom up (doesnt make a diff)
for i in range(len(window)-1, -1, -1):
# Search for this pattern and return the reg name of rhs
# to be used to check if reg is where imm64 is moved to
r = add_rax_rhs_reg(window[i])
if r:
add_reg = r
add_idx = i
break
r = sub_rax_rhs_reg(window[i])
if r:
sub_reg = r
sub_idx = i
break
# Ignore this window and continue searching for other potential windows
if add_reg is None and sub_reg is None:
return (None, None)
imm_val = None # Tracking imm64 value set to add_reg
imm_idx = None # Tracking index of imm64 move instruction
rax_qword = None # Track the qword loaded into rax
rax_idx = None # Tracking index of rax load instruction
# Starting from the bottom up (doesnt make a diff)
for i in range(len(window)-1, -1, -1):
# <reg> = <const> (check for immediate value moves)
il = window[i] # get the current instruction
if imm_val is None:
## Depending on whether it is an add or sub, we want to check
## that imm64 is moved to that register
target_reg = add_reg if add_reg is not None else sub_reg
c = mov_reg_imm64(il, target_reg) # we want to move to the register
if c is not None:
imm_val = c
imm_idx = i
if rax_qword is None:
v = rax_load_from_constptr_q(il,bv)
if v is not None:
rax_qword = v
rax_idx = i
# We can stop searching the window if we found the two patterns
if imm_val is not None and rax_qword is not None:
break
# Make sure we can find all three instructions
if imm_val is None or rax_qword is None:
return (None, None)
""" Time to calculate the target address depending on add or sub """
if sub_reg:
target = u64(rax_qword - s64(imm_val))
print( f"Resolved target address: {hex(target)}" )
seq_start_il = window[min(x for x in (imm_idx, rax_idx, sub_idx) if x is not None)]
seq_start_addr = seq_start_il.address if seq_start_il is not None else None
print( f"Sequence start address: {hex(seq_start_addr) if seq_start_addr is not None else 'N/A'}" )
return target, seq_start_addr
elif add_reg:
target = u64(rax_qword + s64(imm_val))
print( f"Resolved target address: {hex(target)}" )
seq_start_il = window[min(x for x in (imm_idx, rax_idx, add_idx) if x is not None)]
seq_start_addr = seq_start_il.address if seq_start_il is not None else None
print( f"Sequence start address: {hex(seq_start_addr) if seq_start_addr is not None else 'N/A'}" )
return target, seq_start_addr
def disasm_preview_inline(bv, addr: int, max_insns: int = 3, max_chars: int = 120, include_addr: bool = False):
"""Return 'mnemonic1 ; mnemonic2 ; mnemonic3' (optionally with addresses), truncated."""
parts = []
cur = addr
for _ in range(max_insns):
data = bv.read(cur, 16)
if not data:
break
try:
tokens, length = bv.arch.get_instruction_text(data, cur)
except Exception:
break
if not length:
break
text = "".join(tok.text for tok in tokens)
parts.append(f"{cur:#x} {text}" if include_addr else text)
cur += length
line = " ; ".join(parts)
if len(line) > max_chars:
line = line[:max_chars - 1] + "…"
return line
def annotate_indirect_callsite(bv, call_addr, target_addr):
print("annotating indirect callsite...")
"""Annotate the callsite at call_addr with the resolved target_addr."""
target_addr = u64(target_addr)
print( f"Annotating callsite at {hex(call_addr)} with target {hex(target_addr)}" )
try:
if not bv.get_function_at(target_addr):
bv.create_user_function(target_addr)
except Exception as e:
print(f"Error annotating callsite at {hex(call_addr)}: {e}")
sym_name = f"Resolved_0x{target_addr:x}"
## Check for existing symbol in case there are duplicates
try:
if not any(s.name == sym_name for s in bv.get_symbols(target_addr)):
bv.define_user_symbol(Symbol(SymbolType.FunctionSymbol, target_addr, sym_name))
except Exception as e:
print(f"Error defining symbol at {hex(target_addr)}: {e}")
try:
preview = disasm_preview_inline(bv, target_addr, max_insns=3, max_chars=120, include_addr=False)
print( f"Preview of target {hex(target_addr)}: {preview}" )
comment = f"---> 0x{target_addr:016x}"
if preview:
comment += f" | {preview}"
bv.set_comment_at(call_addr, comment)
except Exception:
print(f"Error setting comment at {hex(call_addr)}")
pass
def patch_obfuscated_indirect_call(bv: BinaryView, window, call_addr, target:int):
# NOP all instruction in window before
for il in window:
if not bv.convert_to_nop(il.address):
print( f"[ERROR] - Failed to NOP instruction at {hex(il.address)}" )
for il in window:
# We want to patch in e8 <rel32> (size = 5)
rel32 = target - (window[0].address + 5)
if -0x80000000 <= rel32 <= 0x7FFFFFFF:
# Be careful not to write straight at the call address because we will overwrite three bytes into the next iosntruction
# In this case, I choose to write to the first IL in window
bv.write(window[0].address, b"\xE8" + int(rel32 ).to_bytes(4, "little", signed=True))
print( f"Patched direct call E8 {rel32 & 0xffffffff} at {hex(call_addr)}" )
return True
else:
print("tasukete kudasai!!!!!")
return False
def build_llil_index(fn):
idx = {}
ll = fn.llil
if not ll: return idx
for bb in ll:
for ins in bb:
a = getattr(ins, "address", None)
if a is None: continue
idx.setdefault(a, []).append(ins)
return idx
def load_from_const_pointer(bv, il):
data = None
if il.operation != LowLevelILOperation.LLIL_SET_REG:
return None
if not il.src: return None
if il.src.operation != LowLevelILOperation.LLIL_LOAD:
return None
src = il.src.src
if not src:
return None
if src.operation != LowLevelILOperation.LLIL_CONST_PTR:
return None
ptr_addr = src.constant
data = bv.read_pointer(ptr_addr)
print(f"Pointer Value: {ptr_addr} -> {hex(u64(data))}")
return data
def setup_uc():
u = Uc(UC_ARCH_X86, UC_MODE_64)
## Mapping all segments from BinaryView into Unicorn
for seg in bv.segments:
base = seg.start & ~(PAGE - 1)
size = ((seg.end - base + PAGE - 1)//PAGE)*PAGE
if size <= 0:
continue
try:
# Map memory segment
u.mem_map(base, size, UC_PROT_READ | UC_PROT_WRITE| UC_PROT_EXEC)
# Get the data from the segment
blob = bv.read(base, size) or b""
if blob:
u.mem_write(base, blob)
except:
pass
# Map a stack
try:
u.mem_map(STACK_TOP - STACK_SIZE, STACK_SIZE, UC_PROT_READ | UC_PROT_WRITE)
except:
pass
## Set rsp and rbp
u.reg_write(UC_X86_REG_RSP, STACK_TOP - 0x4000)
u.reg_write(UC_X86_REG_RBP, STACK_TOP - 0x8000)
## We want to define map window in case there are invalid memory accesses
MAP_CHUNK = 0x10000
def map_window(emu, addr):
if addr >= 0x0000800000000000: return False
base = (addr & ~(PAGE-1)) & ~(MAP_CHUNK-1)
try:
emu.mem_map(base, MAP_CHUNK, UC_PROT_READ|UC_PROT_WRITE|UC_PROT_EXEC); return True
except:
try:
emu.mem_protect(base, MAP_CHUNK, UC_PROT_READ|UC_PROT_WRITE|UC_PROT_EXEC); return True
except: return False
def on_invalid(emu, access, addr, size, value, user): return map_window(emu, addr)
try:
if getattr(uc, "UC_HOOK_MEM_READ_UNMAPPED", None) is not None: u.hook_add(uc.UC_HOOK_MEM_READ_UNMAPPED, on_invalid)
if getattr(uc, "UC_HOOK_MEM_WRITE_UNMAPPED", None) is not None: u.hook_add(uc.UC_HOOK_MEM_WRITE_UNMAPPED, on_invalid)
if getattr(uc, "UC_HOOK_MEM_FETCH_UNMAPPED", None) is not None: u.hook_add(uc.UC_HOOK_MEM_FETCH_UNMAPPED, on_invalid)
if getattr(uc, "UC_HOOK_MEM_INVALID", None) is not None: u.hook_add(uc.UC_HOOK_MEM_INVALID, on_invalid)
except:
pass
return u
GPRS = {"rax":UC_X86_REG_RAX,"rbx":UC_X86_REG_RBX,"rcx":UC_X86_REG_RCX,"rdx":UC_X86_REG_RDX,
"rsi":UC_X86_REG_RSI,"rdi":UC_X86_REG_RDI,"r8":UC_X86_REG_R8,"r9":UC_X86_REG_R9,
"r10":UC_X86_REG_R10,"r11":UC_X86_REG_R11,"r12":UC_X86_REG_R12,"r13":UC_X86_REG_R13,
"r14":UC_X86_REG_R14,"r15":UC_X86_REG_R15}
def emulate_to_tail_jump(u: Uc, start_addr: int, stop_addr: int, zf=None):
# init all registers to zero
for reg in GPRS.values():
u.reg_write(reg, 0)
# Set the ZF flag if provided
if zf is not None:
try: u.reg_write(UC_X86_REG_EFLAGS, (1<<6) if zf else 0)
except:
try: u.reg_write(UC_X86_REG_RFLAGS, (1<<6) if zf else 0)
except: pass
# Set the instruction pointer
u.reg_write(UC_X86_REG_RIP, start_addr)
out = {"rax": None}
# Set up hooks for code execution
def on_code(emu, address, size, user):
# print(f"Emulating instruction at {address:#x}, size={size}")
if address == stop_addr:
out["rax"] = emu.reg_read(UC_X86_REG_RAX)
emu.emu_stop()
h = u.hook_add(uc.UC_HOOK_CODE, on_code)
try:
u.emu_start(start_addr, 0)
except Exception as e:
print(f"Error occurred during emulation: {e}")
finally:
u.hook_del(h)
if out["rax"] is None:
rip = u.reg_read(UC_X86_REG_RIP)
print(f"Did not reach jmp rax (RIP={rip:#x})")
return out["rax"]
def write_rel32_jump(bv, at, target):
rel = (target - (at + 5)) & 0xFFFFFFFF
signed = rel if rel < (1<<31) else rel - (1<<32)
if -0x80000000 <= signed <= 0x7FFFFFFF:
bv.write(at, b"\xE9" + int(signed).to_bytes(4, "little", signed=True))
return 5
else:
print(f"Rel32 jump from {at:#x} to {target:#x} out of range")
raise SystemExit()
def set_condition_cmp_setcc(bv:BinaryView, il):
if il.operation != LowLevelILOperation.LLIL_SET_REG:
return False
if hasattr(il, "src") and il.src is not None:
s = il.src
if s.operation == LowLevelILOperation.LLIL_CMP_NE or s.operation == LowLevelILOperation.LLIL_CMP_E or s.operation == LowLevelILOperation.LLIL_CMP_SLT or s.operation == LowLevelILOperation.LLIL_CMP_SLE or s.operation == LowLevelILOperation.LLIL_CMP_SGT or s.operation == LowLevelILOperation.LLIL_CMP_SGE:
print(f"Found CMP_NE at {hex(il.address)}")
bv.set_comment_at(il.address, "CMP detected here")
return True
return False
def is_setcc_instruction(bv: BinaryView, addr: int):
"""Check if the instruction at addr is a setcc instruction (sete, setge, setl, etc.)"""
data = bv.read(addr, 3)
if len(data) >= 3:
# setcc instructions: 0x0F 9x xx where 9x varies by condition
if data[0] == 0x0F and (data[1] & 0xF0) == 0x90:
# Get the instruction text to identify which setcc it is
try:
tokens, length = bv.arch.get_instruction_text(data, addr)
text = "".join(tok.text for tok in tokens)
if any(setcc in text.lower() for setcc in ['sete', 'setge', 'setl', 'setne', 'setg', 'setle']):
return True
except:
pass
return False
def find_setcc_in_block(bv: BinaryView, bb_start: int, bb_end: int):
"""Find the first setcc instruction in the basic block"""
for addr in range(bb_start, bb_end):
if is_setcc_instruction(bv, addr):
# Get the instruction text
try:
data = bv.read(addr, 3)
tokens, length = bv.arch.get_instruction_text(data, addr)
text = "".join(tok.text for tok in tokens)
print(f"Found setcc instruction '{text}' at {hex(addr)}")
return addr
except:
pass
return None
def write_rel32_jz(bv, at, target):
rel = (target - (at + 6)) & 0xFFFFFFFF
signed = rel if rel < (1<<31) else rel - (1<<32)
if -0x80000000 <= signed <= 0x7FFFFFFF:
bv.write(at, b"\x0F\x84" + int(signed).to_bytes(4, "little", signed=True))
return 6
return 0
bv:BinaryView = bv
funcs = [current_function]
LOOKBACK_INSNS = 5 # excluding call
llil_func = None
for f in funcs:
llil_func = f.llil
if not llil_func:
continue
for bb in llil_func:
items = list(bb) # Get list of BB used to get window
for idx, il in enumerate(items):
if not is_call_via_rax(il):
continue
start = max (0, idx - LOOKBACK_INSNS)
window = items[start:idx+1]
# seq_start is used to track where to start NOP'ing from later
tgt, seq_start = resolve_target_from_window(bv, window, call_addr=il.address)
# IGNORE if there is no match
if tgt is None:
continue
annotate_indirect_callsite(bv, il.address, tgt)
# Begin Patching
if patch_obfuscated_indirect_call(bv, window, il.address, tgt) == True:
print( f"Successfully patched indirect call at {hex(il.address)}" )
else:
print( f"[ERROR] - Failed to patch indirect call at {hex(il.address)}" )
## Get to the current Basic Block
found_tail_jump = False
tailcall_llil_window = []
bb = next((b for b in current_function.basic_blocks if b.start <= here <= b.end), None)
if bb is not None:
print(f"Current Basic Block from {hex(bb.start)} to {hex(bb.end)}")
# get the last instruction in this basic block
instruction_list = list(bb)
last_instr = instruction_list[-1]
instr = last_instr[0]
# We want to match['jmp', ' ', 'rax']
first_token = instr[0].text.lower()
last_token = instr[2].text.lower()
if first_token == 'jmp' and last_token == 'rax':
print("This is an indirect jump via rax")
found_tail_jump = True
if not found_tail_jump:
print("No indirect tail jump via rax found in the current basic block.")
#raise SystemExit()
else:
offset_from_start_of_bb = 0
# Get current basic block
instruction_list = []
bb = next((b for b in current_function.basic_blocks if b.start <= here <= b.end), None)
#bb = bv.get_basic_blocks_at(here)[0]
# Collect instructions into instruction list storing address and instruction (containing instruction and length)
for instr in bb:
instruction_list.append({ "address" : bb.start + offset_from_start_of_bb, "instruction" : instr })
offset_from_start_of_bb += instr[1] # Adding length of current instruction
tailcall_window_start_addr = 0
tailcall_window_end_addr = 0
temp = None
# Find the earliest instruction of tail call window (first NOP from the back)
for idx in range(len(instruction_list)-1, -1, -1):
if instruction_list[idx]["instruction"][0][0].text.lower() == 'nop' :
tailcall_window_start_addr = instruction_list[idx+1]["address"]
break
## Some BB does not have NOPS, we limit the number of instructions in case of false positives
if len(instruction_list) - idx > MAX_WINDOW_SIZE: ## Hardcoded to 225 because that was the highest we need after iterative testing
tailcall_window_start_addr = instruction_list[0]["address"]
break
tailcall_window_end_addr = instruction_list[-1]["address"]
print(f"Tail call window starts at {hex(tailcall_window_start_addr)}")
print(f"Tail call window ends at {hex(tailcall_window_end_addr)}")
## within this window, we want to get the LLIL instructions
for i in llil_func.instructions:
if i.address >= tailcall_window_start_addr and i.address <= tailcall_window_end_addr:
tailcall_llil_window.append(i)
## Now we need to find the actual tail call sequence starting with setcc instruction
found_conditional_jump_emu_start = False
found_unconditional_jump_emu_start = False
# Look for the setcc instruction (sete, setge, etc.) - this should be the real start
setcc_addr = find_setcc_in_block(bv, bb.start, bb.end)
if setcc_addr:
found_conditional_jump_emu_start = True
tailcall_window_start_addr = setcc_addr
print(f"Starting emulation from setcc instruction at {hex(setcc_addr)}")
## Emulate twice for ZF = 0 and ZF = 1
print(f"Pre-mapping regions from {hex(setcc_addr)} to {hex(tailcall_window_end_addr)}")
u0 = setup_uc()
# Ensure the execution region is mapped
try:
base_addr = (setcc_addr & ~0xFFF) & ~0xFFFF # Align to 64KB boundary
size = 0x20000 # Map 128KB to be safe
data = bv.read(base_addr, size)
if data:
try:
u0.mem_map(base_addr, size, UC_PROT_READ | UC_PROT_WRITE | UC_PROT_EXEC)
u0.mem_write(base_addr, data)
print(f"Pre-mapped execution region: {hex(base_addr)} - {hex(base_addr + size)}")
except:
pass
except Exception as e:
print(f"Failed to pre-map execution region: {e}")
t0 = emulate_to_tail_jump(u0, setcc_addr, tailcall_window_end_addr, zf=0)
u1 = setup_uc()
# Ensure the execution region is mapped for second emulator too
try:
base_addr = (setcc_addr & ~0xFFF) & ~0xFFFF
size = 0x20000
data = bv.read(base_addr, size)
if data:
try:
u1.mem_map(base_addr, size, UC_PROT_READ | UC_PROT_WRITE | UC_PROT_EXEC)
u1.mem_write(base_addr, data)
except:
pass
except:
pass
t1 = emulate_to_tail_jump(u1, setcc_addr, tailcall_window_end_addr, zf=1)
print(f"Emulation result ZF=0: {hex(t0) if t0 is not None else 'N/A'}")
print(f"Emulation result ZF=1: {hex(t1) if t1 is not None else 'N/A'}")
bv.set_comment_at(tailcall_window_end_addr, f"Emulated tail jump targets: ZF=0->{hex(t0) if t0 is not None else 'N/A'}, ZF=1->{hex(t1) if t1 is not None else 'N/A'}")
if t0 is not None and bv.get_function_at(t0) is None:
bv.create_user_function(t0)
if t1 is not None and bv.get_function_at(t1) is None:
bv.create_user_function(t1)
else:
# Fallback: use the old detection method
print("No setcc instruction found, using fallback detection")
for il in tailcall_llil_window:
if set_condition_cmp_setcc(bv, il):
found_conditional_jump_emu_start = True
tailcall_window_start_addr = il.address
print(f"Found conditional jump emulation start at {hex(il.address)} (FALLBACK)")
u0 = setup_uc()
t0 = emulate_to_tail_jump(u0, il.address, tailcall_window_end_addr, zf=0)
u1 = setup_uc()
t1 = emulate_to_tail_jump(u1, il.address, tailcall_window_end_addr, zf=1)
print(f"Emulation result ZF=0: {hex(t0) if t0 is not None else 'N/A'}")
print(f"Emulation result ZF=1: {hex(t1) if t1 is not None else 'N/A'}")
bv.set_comment_at(tailcall_window_end_addr, f"Emulated tail jump targets: ZF=0->{hex(t0) if t0 is not None else 'N/A'}, ZF=1->{hex(t1) if t1 is not None else 'N/A'}")
if t0 is not None and bv.get_function_at(t0) is None:
bv.create_user_function(t0)
if t1 is not None and bv.get_function_at(t1) is None:
bv.create_user_function(t1)
break
## Start patching at the start (tailcall_window_start_addr)
if found_conditional_jump_emu_start:
## We want to NOP the instructions from start of the window
for il in tailcall_llil_window:
if il.address < tailcall_window_start_addr:
continue
if not bv.convert_to_nop(il.address):
print( f"[ERROR] - Failed to NOP instruction at {hex(il.address)}" )
raise SystemExit()
# Patch JZ at the start of emulation window
if t0 is not None and write_rel32_jz(bv, tailcall_window_start_addr, t0):
print( f"Successfully patched JZ at {hex(tailcall_window_start_addr)} to {hex(t0)}" )
else:
print( f"[ERROR] - Failed to patch JZ at {hex(tailcall_window_start_addr)} (t0={t0})" )
else:
print("No load from constant pointer found in tail call window.")
for il in tailcall_llil_window:
## This pattern search (Load_from_const_pointer) exists in both conditional and unconditional.
## so do this 0only if we fail to look for conditional (setcc) pattern
print(f"LLIL Instruction {il}")
pointer_value = load_from_const_pointer(bv, il)
print(f"[DEBUG] -----> Pointer Value: {pointer_value}")
if pointer_value is not None:
found_unconditional_jump_emu_start = True
tailcall_window_start_addr = il.address
print(f"Found load from constant pointer at {hex(il.address)} with value {hex(pointer_value)}")
break
if found_conditional_jump_emu_start == False:
# only proceed if we did not find conditional jump emulation start
# Found the location for start of emulation
emu_start_addr = tailcall_window_start_addr
emu_stop_addr = tailcall_window_end_addr
print(f"Emulation will start at {hex(emu_start_addr)}")
# Setup Unicorn Emulator
u = setup_uc()
# Takes in zf to deal with setcc later on
t = emulate_to_tail_jump(u, emu_start_addr, emu_stop_addr, zf=None)
print(f"Emulation result: {hex(t) if t is not None else 'N/A'}")
bv.set_comment_at(emu_stop_addr, f"Emulated tail jump target: {hex(t) if t is not None else 'N/A'}")
if bv.get_function_at(t) is None and t is not None:
bv.create_user_function(t)
## TODO: Add extra check in case it starts to point somewhere outside of 0x14xxxxxxxx
## We can NOP all the instruction from the tail call llil window
for il in tailcall_llil_window:
if il.address < tailcall_window_start_addr:
continue
if not bv.convert_to_nop(il.address):
print( f"[ERROR] - Failed to NOP instruction at {hex(il.address)}" )
# Finally, we patch in the direct jump
if write_rel32_jump(bv, tailcall_window_start_addr, t):
print( f"Successfully patched direct jump at {hex(tailcall_window_start_addr)} to {hex(t)}" )
else:
print( f"[ERROR] - Failed to patch direct jump at {hex(tailcall_window_start_addr)}" )

Deobfuscation Showcase

Exhibit #1 - Main Function in HLIL

img

Exhibit #2 - sub_14004d1c3 (Random selection) in HLIL

img

Exhibit #3 - ????

img

Live Demonstration

Brief Summary on the Solve

Since the aim for this post is about learning to deobfuscate with Binaryh Ninja, it will only inlcude the brief summary of the solve with the deobfuscator. From writeups that were done, it is obvious that some speedrunners did not need to deobfuscate that much. Also, there are many solid writeups in the wild with this being my favorite. Therefore, the following sections to come may not make too much sense for those that did not attempt.

For this challenge, I have made use of TTD which has helped to save a lot of time. I combined it with a little of live debugging because I need to make sure that I did have a button press which is not very apparent when dealing with TTD.

QMessageBox as Start Point

The most obvious UI component we see when inputting wrong values are the MessageBox. QT has a component for that which we can set a breakpoint on. The following presents the callstack on that break.

...
...
Qt6Widgets!QMessageBox::QMessageBox+0x7b
08 00000000`00147fe0 00007ff9`bdc650b1 Qt6Widgets!QMessageBox::showEvent+0x36c
09 00000000`00148080 00000001`4002a502 Qt6Widgets!QMessageBox::warning+0x21
0a 00000000`001480c0 00000001`4008855d chall8+0x2a502
0b 00000000`0014ab30 00000001`40088253 chall8+0x8855d
0c 00000000`0014ab80 00000001`4008989b chall8+0x88253
0d 00000000`0014abe0 00007ff9`bcd74023 chall8+0x8989b
0e 00000000`0014ac50 00007ff9`bcd766e4 Qt6Core!QObject::qt_static_metacall+0x17f3
0f 00000000`0014ad80 00007ff9`bdb29337 Qt6Core!QMetaObject::activate+0x84
10 00000000`0014adb0 00007ff9`bdb28e72 Qt6Widgets!QAbstractButton::clicked+0x497

The most relevant lines would be those that are called from chall8 (renamed to make it easier to set breakpoints).

0a 00000000`001480c0 00000001`4008855d chall8+0x2a502
0b 00000000`0014ab30 00000001`40088253 chall8+0x8855d
0c 00000000`0014ab80 00000001`4008989b chall8+0x88253
0d 00000000`0014abe0 00007ff9`bcd74023 chall8+0x8989b

Looking at 0x8855d and parents, it looks like some sort of dispatcher, choosing which challenge application callback to run.

To figure which the start of the function that makes the decision of right or wrong password, we can use TTD to g backwards till its caller with g-u. From TTD, it is sub_1400202b0 that does some check. If we are wrong, then it would call 0x14008e030

0:000> g-u
Time Travel Position: 1B22E:AC8
Qt6Widgets!QMessageBox::showEvent+0x367:
00007ffe`6a634037 e8c4b9ffff call Qt6Widgets!QMessageBox::QMessageBox (00007ffe`6a62fa00)
0:000> g-u
Time Travel Position: 1B22E:AB9
Qt6Widgets!QMessageBox::warning+0x1c:
00007ffe`6a6350ac e80fefffff call Qt6Widgets!QMessageBox::showEvent+0x2f0 (00007ffe`6a633fc0)
0:000>
0:000> g-u
Time Travel Position: 1B22E:AB0
chall8+0x2a500:
00000001`4002a500 ffd0 call rax {chall8+0x8e030 (00000001`4008e030)}
chall8+0x2a5fc:
00000001`4002a5fc 49b93c8c4cea458009f5 mov r9,0F5098045EA4C8C3Ch
0:000> g-u
Time Travel Position: 1B22C:40E
chall8+0x8855b:
00000001`4008855b ffd0 call rax {chall8+0x202b0 (00000001`400202b0)}

Looking for Number Button Pressed

Also, since I have never reversed any QT Application before, I resort to looking into the source code (at least close enough) to understand how buttons press and release or mouse press or release are handled. I was able to follow a little on what happen when when the button is pressed from https://github.com/qt/qtbase/blob/dev/src/widgets/widgets/qabstractbutton.cpp.

It does QAbstractButton::mousePressEvent and QAbstractButton::mouseReleaseEvent which I confirmed in the debugger. Reading in documentation, it shows that such interaction is done via ""Signal-slot” invocation.

img

img

Testing Button Pressing

For sanity, I set a breakpoint to print out the text on the button:

bu Qt6Widgets!QAbstractButton::mouseReleaseEvent "r @$t0=@rcx; .printf \"\n[grabbed button] this=%p\n\", @rcx; gc"
(Actual example)
[grabbed button] this=000002501d48c890 1
[grabbed button] this=000002501d48d690 2
[grabbed button] this=000002501d48e370 3
[grabbed button] this=000002501d48f0d0 4
[grabbed button] this=000002501d48fec0 5
[grabbed button] this=000002501d490b50 6
[grabbed button] this=000002501d4917e0 7
[grabbed button] this=000002501d48fe10 8
[grabbed button] this=000002501d4931a0 9
[grabbed button] this=000002501d493e30 del
[grabbed button] this=000002501d494b60 0
[grabbed button] this=000002501d4957f0 ok
[grabbed button] this=000002501ed428b0 msg box ok

Global Addition

When debugging, I found the input being updated in a memory location while stepping through the instructions till FlareAuthenticator+0x8cf74

img

I then made a conditional breakpoint to break upon access when I pressed on a button. The callstack indicates a mouse click event with the activate function via a mouse release. The memcpy there surely is interesting.

0:000> ba r1 00000000`0014fe08+7
0:000> g
Breakpoint 1 hit
VCRUNTIME140!memcpy+0x119:
00007ffd`4bfe21e9 c3 ret
0:000> k
# Child-SP RetAddr Call Site
00 00000000`00149988 00000001`4008939f VCRUNTIME140!memcpy+0x119 [D:\a\_work\1\s\src\vctools\crt\vcruntime\src\string\amd64\memcpy.asm @ 217]
01 00000000`00149990 00000001`4008b4f3 FlareAuthenticator+0x8939f
02 00000000`001499e0 00000001`4008a7cc FlareAuthenticator+0x8b4f3
03 00000000`00149a60 00000001`4008c83d FlareAuthenticator+0x8a7cc
04 00000000`00149aa0 00000001`40015a49 FlareAuthenticator+0x8c83d
05 00000000`00149ae0 00000001`4008855d FlareAuthenticator+0x15a49
06 00000000`0014ab30 00000001`40088253 FlareAuthenticator+0x885f5d
07 00000000`0014ab80 00000001`4008989b FlareAuthenticator+0x88253
08 00000000`0014abe0 00007ffc`b7694023 FlareAuthenticator+0x8989b
09 00000000`0014ac50 00007ffc`b76966e4 Qt6Core!QObject::qt_static_metacall+0x17f3
0a 00000000`0014ad80 00007ffc`c3259337 Qt6Core!QMetaObject::activate+0x84
0b 00000000`0014adb0 00007ffc`c3258e72 Qt6Widgets!QAbstractButton::clicked+0x497
0c 00000000`0014adf0 00007ffc`c325a2ca Qt6Widgets!QAbstractButton::click+0x1b2
0d 00000000`0014ae20 00007ffc`c319dce4 Qt6Widgets!QAbstractButton::mouseReleaseEvent+0xda
0e 00000000`0014ae60 00007ffc`c3160ade Qt6Widgets!QWidget::event+0x164
0f 00000000`0014af40 00007ffc`c315ea50 Qt6Widgets!QApplicationPrivate::notify_helper+0x10e
10 00000000`0014af70 00007ffc`b765a0f5 Qt6Widgets!QApplication::notify+0x750
11 00000000`0014b420 00007ffc`c31645ff Qt6Core!QCoreApplication::notifyInternal2+0xc5
12 00000000`0014b490 00007ffc`c31bcfe9 Qt6Widgets!QApplicationPrivate::sendMouseEvent+0x3ef
13 00000000`0014b5b0 00007ffc`c31ba833 Qt6Widgets!QWidgetRepaintManager::updateStaticContentsSize+0x3409
14 00000000`0014bab0 00007ffc`c3160ade Qt6Widgets!QWidgetRepaintManager::updateStaticContentsSize+0xc53
15 00000000`0014bbc0 00007ffc`c315fbb1 Qt6Widgets!QApplicationPrivate::notify_helper+0x10e
16 00000000`0014bbf0 00007ffc`b765a0f5 Qt6Widgets!QApplication::notify+0x18b1
17 00000000`0014c0a0 00007ffc`b9668dc3 Qt6Core!QCoreApplication::notifyInternal2+0xc5
18 00000000`0014c110 00007ffc`b96b77ba Qt6Gui!QGuiApplicationPrivate::processMouseEvent+0x703
19 00000000`0014c630 00007ffc`b77ba360 Qt6Gui!QWindowSystemInterface::sendWindowSystemEvents+0xea
1a 00000000`0014c660 00007ffc`b9905f39 Qt6Core!QEventDispatcherWin32::processEvents+0x90
1b 00000000`0014f7f0 00007ffc`b765fab4 Qt6Gui!QWindowsGuiEventDispatcher::processEvents+0x19
1c 00000000`0014f820 00007ffc`b7657e1d Qt6Core!QEventLoop::exec+0x194
1d 00000000`0014f8c0 00000001`40075444 Qt6Core!QCoreApplication::exec+0x15d
1e 00000000`0014f920 00000001`4008d21c FlareAuthenticator+0x75444
1f 00000000`0014fef0 00007ffd`94e4259d FlareAuthenticator+0x8d21c
20 00000000`0014ff30 00007ffd`9570af78 KERNEL32!BaseThreadInitThunk+0x1d
21 00000000`0014ff60 00000000`00000000 ntdll!RtlUserThreadStart+0x28

With TTD, I checked out the start of the function.

Time Travel Position: C464:22D1
chall8+0x15a49:
00000001`40015a49 eb00 jmp chall8+0x15a4b (00000001`40015a4b)
0:000> g-u
Time Travel Position: C38B:412
chall8+0x8855b:
00000001`4008855b ffd0 call rax {chall8+0x12e50 (00000001`40012e50)}
0:000> r rax
rax=0000000140012e50

Deobfuscating sub_140012e50, we can now observe the size of this function on higher level. Also, to find functions of interests, I look for previously indirect call functions that are huge.

img

While looking for those functions, we have a relatively huge function which I called NumberGeneration_Resolved_0x140081760 This function is called in two places within sub_140012e50.

img

These two functions generates two different numbers. sub_140012e50 multiplies these two values up together in 0x140016772. This multiplication happens each time we press a number which confirms this to be part of the button callback and that all number button shares the same callback.

img

On each iteration, it seems that numbers at specific position yields the same multiplier and muliplicant. The values are then stored into a global variable.

img

Tracing Mulitiplied Result

Furthermore, there is are a few referenced global variables that a simple addition. It is along the line of

global_store += muliplied value

img

Final Validation in OK Button

When tracing the OK button, we could see the conditional statement that either give the warning (wrong password) or the flag (right password). The following shows the decision on whether the answer is right. This means that all the addition of multiplied number (25 of them) should sum up to 0xbc42d5779fec401

img

What are the Multiplier and Multiplicant?

When looking at these values, I set a breakpoint at chall8+0x16772 and dump out the register for rax and rcx. It then gives an interesting pattern. It seems that the same number at the same position ALWAYS gives a fixed value independent on the value of the other position. This means that we can dump out all values at all positions. img

Solver Script

After realizing the challenge and the multiplier and multiplicant, treating the number generator as blackbox, I used x64dbg to dump out all multiplied values before requesting for Sir ChatGPT to writeup a z3 solver for 25 digit pin which adds up to 0xbc42d5779fec401.

from z3 import *
MASK64 = (1<<64) - 1
N_STEPS, N_DIG = 25, 10
TARGET = 0xbc42d5779fec401
# ---- paste your 250 ints here (row-major, 10 per row) ----
s = [0xF2EB6684284AC, 0xC38D14DF6D665, 0x1D3A557CD3A980, 0x26D8E6DC23319D, 0x1C544F24D1B429, 0x17F846E0621293, 0x1B6E4030F71F3D, 0x80F42A7139E80, 0x1FCAC56F82739C, 0x19B3240445AA06, 0x3ED168087F3548, 0x6391D7049DDE8, 0x8114813C91A610, 0xA8FA7B20F1E228, 0x786666BE83B978, 0x6AE188A0BC46F0, 0x6FB9A153C78328, 0x1B18D762CB44C8, 0x921C4279B1A9A8, 0x6F63394844DF78, 0x49DE34FFC63EC0, 0x39FEE368E99380, 0x7A11DE14C79E80, 0xD8DC90E996E40, 0x7146BFC66E2740, 0x680673DB489E00, 0x6E31309B7B8940, 0x316C3AFCB92AC0, 0x82DEEFE21A73C0, 0x6DF6A4586E71C0, 0x1CA2F34F18CD40, 0xA614B2DC1C6980, 0x60D84A48824900, 0x9280B83FADD240, 0x60801A9A7374C0, 0x49FE057966CC00, 0x579189ABC15440, 0xB2F907A133B2C0, 0x612F00F6EA6080, 0x4EA15FC542C9C0, 0x6229B366FE169, 0xACB3FF351198AB, 0x4C6C9CDBCE1FE5, 0x7C9128173EA742, 0x47ED36357FF53C, 0x321B2C8DECC760, 0x436E1CAD683194, 0x97DE278C5E1226, 0x489730C9AA7E6A, 0x3AC57453ACE252, 0x483F192B06217F, 0x1E5CB35F54FA69, 0x6E1E405C548974, 0x137E71B08CA2AA, 0x6928A14A143271, 0x616EB297EDDB28, 0x6431716B60DF88, 0x2A4A823D6D822D, 0x6E4F38A8434132, 0x6402164C9FDB19, 0x2BE31F155AB714, 0x21AE09901BF552, 0xB221597F1D3D8, 0x1556DDAA385C92, 0x7D821185844EC, 0x462D157005089, 0x6B104399CEDBC, 0x1E79B0A36CDAFF, 0xA266DD02D7453, 0x69B5253875B96, 0x6FD7CB84FD4AD7, 0x6255B824338303, 0xAC26E207A0A6C5, 0x23700DD18B0E3C, 0xABD8D1A448644C, 0x97F36052CAA668, 0xA3F30CB6971D36, 0x4F56BF7AFE673B, 0x708E71FCEE988, 0x9C0D47EAC35D2D, 0x16F0557C6F1B97, 0x105A5256455ED6, 0x575F94A1E493B, 0xC0C1389DB1C28, 0x4D8773736490A, 0x1DC3A46D3EB22, 0x43AFA29A5046E, 0x12101A50F4E1FB, 0x737572378FC07, 0x30B9DA3C1BFE7, 0x255E081F63BA07, 0x1B8260C83DD73C, 0x4188E0049C9EBA, 0x527EEC073C111B, 0x3DD8DF72E747E1, 0x38195731A5D0AE, 0x3A28381B02C813, 0x162F7A6E9D784F, 0x48C67301DAFECB, 0x3A03C1D1D02F29, 0xBC35FAA41240, 0x4D5BA7B7C6DB28, 0x26C6DD80773E62, 0x3C52C884071124, 0x1FD5838C6C7F50, 0x188898B9E96D66, 0x1D66839EF1A1C0, 0x58A12D4900A2FA, 0x2DB81C4AE88D20, 0x1D392355DF459C, 0x5EB45F3E513AC, 0x46247570894A4, 0x924CE94DF0690, 0x1D5530EA52210, 0x920A24B9448D0, 0x81027F2A79420, 0x8B4887801A9FC, 0x35E3DA634FB94, 0x928D4D1040ED8, 0x8484A22A795E4, 0x5E391DD240E89D, 0x39874C7FFB294C, 0x15E2AB5E23C478, 0x312496AE1C4433, 0x1356D94E3C824F, 0x6FB9313A4228E, 0x10CAFC6DD92AE3, 0x409BC32BBA4E37, 0x13B66FDE55B77C, 0xBE331DD3107AD, 0x7FA2B9A827FAE, 0x42193CC5270058, 0x2043847B9EEDD8, 0x2EE265CBAEA1AE, 0x1D1555557808F2, 0x1820C6CA831F20, 0x19E699125F8740, 0x3D81E379F6B82A, 0x2062F49DA45AB4, 0x19C7C11DA4E4A2, 0x5DC0C3E3261CDF, 0x3A76362613DD95, 0x201BB9EA8ED81C, 0x33509B969E3741, 0x19EB1C9C94E6A8, 0x1368E07F2E794F, 0x17BFD098C23630, 0x448303CFD4DD31, 0x1E41E65B7AFC35, 0x1796E76685E997, 0x75583891352145, 0x4F18252739A5AC, 0xC857C28BE5198, 0x32C55970EA1358, 0xC421F0A303C70, 0x984973478DC5AC, 0x56017C15FBDD1, 0x5906717CF6C571, 0x1A062A307BCABD, 0x9BDC1F78073127, 0x926EC5880F9992, 0x81FA523E38B793, 0x481AF6CCB653B, 0x39FC6F33CB770B, 0xDB8605E2F4AA0E, 0xC34699DB257145, 0xD6872C5CC11542, 0x6AD52B4C617CF3, 0x12C1CA7EAE79A6, 0xCCE53B2DF56140, 0xED19AD19480BE, 0x3A82193F6EF346, 0x23394341031AC0, 0x2F828795A6E6BE, 0x208D625A5FD472, 0x1C635AA6DF8398, 0x1DE0F95CF827AE, 0x3D2149D449636, 0x28782F9453EFDE, 0x1DC6931C286DB2, 0xB0E0C55A4D6238, 0x97D9725BE8876E, 0x26B598F9022C30, 0x51C2FA15841D7E, 0x18D5FBE4CDA4C8, 0xA3F26DF601552, 0x13F8DFF2FE8408, 0x8A5498F0183BB0, 0x3496095EF45282, 0x139D946E9D6D82, 0x46A5661935D9CA, 0xBD4C34161B102, 0x82A99A5DE4C84F, 0xAE5A988393338F, 0x825E0AE4D3D5F5, 0x6E8EA108204593, 0x7A7FE273C35A4C, 0x172C91B106B2ED, 0x82F69B85B1A2A1, 0x72A31CFDE71EF6, 0x22F572E6839826, 0x1133B875BED53A, 0x4A9B4A38CF1460, 0x65C33E4F5A2FC0, 0x481234127842A2, 0x3BC3C87F8C0454, 0x4588CE32DCF25A, 0x573D341F00D72, 0x48724D79B38078, 0x40A5DB3578D586, 0x88B763CB6FB9F0, 0x2F08D6E954E096, 0xD9CCF65D9CD7A4, 0x17C3E165050BF0, 0xCF2B002B1AD140, 0xBEA37CEF15E1FA, 0xC48CEE7BAE850C, 0x489420271C9606, 0xDA31DC2C7FDBCE, 0xC427156A9E2860, 0x1DA1D095B7C0B4, 0xBF7B1F10223116, 0x6586F47FC6CF52, 0x8E40676E4927E0, 0x58692F2E100A24, 0x4A9CA491835636, 0x53CFDDBDD2F61C, 0xB2B5098A832538, 0x619C35508AEABA, 0x537869C92A42D0, 0x62360169143A78, 0x55333012D9DD23, 0x9C4964E3F15005, 0x18A07DDC589B2C, 0x9BFE0FEDBF05DC, 0x88D54339CF6CF1, 0x9463624AACA2C6, 0x42E7AF41E9BD7C, 0xAB36B7C0777858, 0x8CC856E432BC50, 0x3684BCD9A0F789, 0x2525F943737B70, 0x86BB1A4A4AA7D, 0x19CA34ADEE1B3A, 0x6CC51DF5A1F44, 0x4661D5224E5470, 0x52CED44ED58CC, 0x29A7CADA9C4CB1, 0xD0CB5244CA7FD, 0x20CCD008AD41A]
assert len(s) == N_STEPS * N_DIG
# Build rows of 64-bit constants
rows = [[s[i*N_DIG + d] & MASK64 for d in range(N_DIG)] for i in range(N_STEPS)]
for row in rows:
for ii, r in enumerate(row):
print((ii+1)%10, " : " , hex(r))
print("\n")
# Z3 vars: running 64-bit state H[0..N]
H = [BitVec(f"H{i}", 64) for i in range(N_STEPS+1)]
z = Solver()
# Initial and final constraints
z.add(H[0] == BitVecVal(0, 64))
z.add(H[N_STEPS] == BitVecVal(TARGET, 64))
# Step constraints: H[i+1] = H[i] + one-of(row i)
for i in range(N_STEPS):
opts = [ H[i+1] == H[i] + BitVecVal(v, 64) for v in rows[i] ]
z.add(Or(*opts))
# Solve
if z.check() == sat:
m = z.model()
# Recover chosen values from differences
chosen_vals = []
chosen_digits = []
for i in range(N_STEPS):
hi = m[H[i]].as_long() & MASK64
hip = m[H[i+1]].as_long() & MASK64
diff = (hip - hi) & MASK64
chosen_vals.append(diff)
# Optional: map to a digit index (first match if duplicates)
try:
d = rows[i].index(diff)
except ValueError:
d = -1 # shouldn't happen
chosen_digits.append(d)
# Print whichever you prefer:
print("Chosen values per step (hex):")
print([hex(v) for v in chosen_vals])
# If you *also* want a PIN string:
if all(d >= 0 for d in chosen_digits):
print('{"Pin":"' + ''.join(str((d+1)%10) for d in chosen_digits) + '"}')
else:
print("UNSAT")

Output

Chosen values per step (hex):
['0x26d8e6dc23319d', '0xa8fa7b20f1e228', '0x82deefe21a73c0', '0xb2f907a133b2c0', '0xacb3ff351198ab', '0x6e4f38a8434132', '0x2be31f155ab714', '0xac26e207a0a6c5', '0x16f0557c6f1b97', '0x527eec073c111b', '0x58a12d4900a2fa', '0x928d4d1040ed8', '0x5e391dd240e89d', '0x42193cc5270058', '0x5dc0c3e3261cdf', '0x9bdc1f78073127', '0xdb8605e2f4aa0e', '0x3a82193f6ef346', '0xb0e0c55a4d6238', '0xae5a988393338f', '0x65c33e4f5a2fc0', '0xda31dc2c7fdbce', '0xbf7b1f10223116', '0xab36b7c0777858', '0x4661d5224e5470']
{"Pin":"4498291314891210521449296"}

img

Conclusion

It was definitely a fun process and for those that actually followed the post, I hope that you have enjoyed following the whole process. During the challenge though, I was in a huge dilemma, weighing the pros and cons of writing a deobfuscator VS solving things dynamically. It was tough because it felt like there were many edge cases making for a very complicated script. This caused me to switch between scripting and dynamically analyzing the challenge many times. If there was something I learnt at hindsight from this, it would be that sometimes writing a partial deobfuscator is better than writing a full perfect one. Yes, this deobfuscator is NOT perfect but it worked in maybe 99.999% of the blocks I guess.

ChatGPT is so strong :O