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

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

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.

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.

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.

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.

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:

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 = bvfuncs = [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!

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:
- Collect all the LLIL instructions in a list
- For each LLIL, detect indirect call sites
- If indirect call sites detected, create the window of instructions to analyze
- After that, attempt to resolve target from window
from binaryninja import BinaryView, LowLevelILOperation
bv:BinaryView = bvfuncs = [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 instructionsdef 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 pointer1400749f8 mov rdx, 0xfb46bff55d1567a9 ;; constant140074a02 add rax, rdx ;; lhs is always rax but lhs might be different140074a05 call raxFinding 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.
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.

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.
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) - 1def 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.

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

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).

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_addrWe now would be able to resolve all indirect call sites within the selected main function!

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)}") passRunning with these updated script and calling annotate_indirect_callsite function should allow us to see function preview and annotated call site.

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 FalseThe 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.

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.

The following demonstrates that we can calculate the next jump target.
rax = 0xbfceabba05b72099rcx = 0x403154473a52c437rcx = bv.read_pointer(rax + rcx)r8 = 0xf73d01c0b270c6crax = rcxrax |= r8r9 = rcxr9 -= raxrdx = 0x1ee7a038164e18d8rdx = rdx + r9*2rcx &= r8rax -= rcxrcx = raxrcx |= rdxrax &= rdxrax += rcxprint("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.

With that, we can get the BB which has instruction selected by our cursor:
## Get to the current Basic Blockbb = 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 = 0instruction_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 = 0tailcall_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.

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 instructionsfor 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 pointerfor 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}].qLLIL Instruction rax = [rax].qLLIL Instruction [rax + 0x28].q = rcxLLIL Instruction rax = [0x1400c2fd0].q <------ THIS IS THE TRUE START OF THE WINDOWLLIL Instruction rcx = 0x403154473a52c437LLIL Instruction rcx = [rax + rcx].qLLIL Instruction r8 = 0xf73d01c0b270c6cLLIL Instruction rax = rcxLLIL Instruction rax = rax | r8LLIL Instruction r9 = rcxLLIL Instruction r9 = r9 - raxLLIL Instruction rdx = 0x1ee7a038164e18d8LLIL Instruction rdx = rdx + (r9 << 1)LLIL Instruction rcx = rcx & r8LLIL Instruction rax = rax - rcxLLIL Instruction rcx = raxLLIL Instruction rcx = rcx | rdxLLIL Instruction rax = rax & rdxLLIL Instruction rax = rax + rcxLLIL 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.

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 emulationfound_conditional_jump_emu_start = Falsefor 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!

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. []

Finding True Start of Window
In LLIL, we want to match up starting from the comparison

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:

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.

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) breakGreat, we get two resolved addresses this time.

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

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

Yet another case which is LLIL_CMP_SGE

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 FalseEdge 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].qPointer Value: 5369472096 -> 0x64512dcef4b67fd2Pointer Value: 7228609243798470610Found load from constant pointer at 0x14000d55a with value 0x64512dcef4b67fd2Emulation will start at 0x14000d55aError occurred during emulation: Invalid memory fetch (UC_ERR_FETCH_UNMAPPED)Did not reach jmp rax (RIP=0x1400d1ef0)Emulation result: N/AThe 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"] breakEdge 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.

The Fix
The following script shows the changes that were made:
- Collect the two different patterns
- Collect the correct RHS register
- 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_addrNow, we can see the resolution:

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 5Full Deobfuscation Script
from binaryninja import BinaryView, LowLevelILOperationfrom binaryninja import Symbol, SymbolType
import unicorn as ucfrom unicorn import Uc, UC_ARCH_X86, UC_MODE_64, UC_PROT_READ, UC_PROT_WRITE, UC_PROT_EXECfrom unicorn.x86_const import *
PAGE = 0x1000MASK64 = (1 << 64) - 1STACK_TOP, STACK_SIZE = 0x00007FFF00000000, 0x01000000MAX_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 = bvfuncs = [current_function]LOOKBACK_INSNS = 5 # excluding callllil_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 = Falsetailcall_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

Exhibit #2 - sub_14004d1c3 (Random selection) in HLIL

Exhibit #3 - ????

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+0x7b08 00000000`00147fe0 00007ff9`bdc650b1 Qt6Widgets!QMessageBox::showEvent+0x36c09 00000000`00148080 00000001`4002a502 Qt6Widgets!QMessageBox::warning+0x210a 00000000`001480c0 00000001`4008855d chall8+0x2a5020b 00000000`0014ab30 00000001`40088253 chall8+0x8855d0c 00000000`0014ab80 00000001`4008989b chall8+0x882530d 00000000`0014abe0 00007ff9`bcd74023 chall8+0x8989b0e 00000000`0014ac50 00007ff9`bcd766e4 Qt6Core!QObject::qt_static_metacall+0x17f30f 00000000`0014ad80 00007ff9`bdb29337 Qt6Core!QMetaObject::activate+0x8410 00000000`0014adb0 00007ff9`bdb28e72 Qt6Widgets!QAbstractButton::clicked+0x497The 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+0x2a5020b 00000000`0014ab30 00000001`40088253 chall8+0x8855d0c 00000000`0014ab80 00000001`4008989b chall8+0x882530d 00000000`0014abe0 00007ff9`bcd74023 chall8+0x8989bLooking 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-uTime Travel Position: 1B22E:AC8Qt6Widgets!QMessageBox::showEvent+0x367:00007ffe`6a634037 e8c4b9ffff call Qt6Widgets!QMessageBox::QMessageBox (00007ffe`6a62fa00)0:000> g-uTime Travel Position: 1B22E:AB9Qt6Widgets!QMessageBox::warning+0x1c:00007ffe`6a6350ac e80fefffff call Qt6Widgets!QMessageBox::showEvent+0x2f0 (00007ffe`6a633fc0)0:000>0:000> g-uTime Travel Position: 1B22E:AB0chall8+0x2a500:00000001`4002a500 ffd0 call rax {chall8+0x8e030 (00000001`4008e030)}chall8+0x2a5fc:00000001`4002a5fc 49b93c8c4cea458009f5 mov r9,0F5098045EA4C8C3Ch0:000> g-uTime Travel Position: 1B22C:40Echall8+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.


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 okGlobal Addition
When debugging, I found the input being updated in a memory location while stepping through the instructions till FlareAuthenticator+0x8cf74

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+70:000> g
Breakpoint 1 hitVCRUNTIME140!memcpy+0x119:00007ffd`4bfe21e9 c3 ret0:000> k # Child-SP RetAddr Call Site00 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+0x8939f02 00000000`001499e0 00000001`4008a7cc FlareAuthenticator+0x8b4f303 00000000`00149a60 00000001`4008c83d FlareAuthenticator+0x8a7cc04 00000000`00149aa0 00000001`40015a49 FlareAuthenticator+0x8c83d05 00000000`00149ae0 00000001`4008855d FlareAuthenticator+0x15a4906 00000000`0014ab30 00000001`40088253 FlareAuthenticator+0x885f5d07 00000000`0014ab80 00000001`4008989b FlareAuthenticator+0x8825308 00000000`0014abe0 00007ffc`b7694023 FlareAuthenticator+0x8989b09 00000000`0014ac50 00007ffc`b76966e4 Qt6Core!QObject::qt_static_metacall+0x17f30a 00000000`0014ad80 00007ffc`c3259337 Qt6Core!QMetaObject::activate+0x840b 00000000`0014adb0 00007ffc`c3258e72 Qt6Widgets!QAbstractButton::clicked+0x4970c 00000000`0014adf0 00007ffc`c325a2ca Qt6Widgets!QAbstractButton::click+0x1b20d 00000000`0014ae20 00007ffc`c319dce4 Qt6Widgets!QAbstractButton::mouseReleaseEvent+0xda0e 00000000`0014ae60 00007ffc`c3160ade Qt6Widgets!QWidget::event+0x1640f 00000000`0014af40 00007ffc`c315ea50 Qt6Widgets!QApplicationPrivate::notify_helper+0x10e10 00000000`0014af70 00007ffc`b765a0f5 Qt6Widgets!QApplication::notify+0x75011 00000000`0014b420 00007ffc`c31645ff Qt6Core!QCoreApplication::notifyInternal2+0xc512 00000000`0014b490 00007ffc`c31bcfe9 Qt6Widgets!QApplicationPrivate::sendMouseEvent+0x3ef13 00000000`0014b5b0 00007ffc`c31ba833 Qt6Widgets!QWidgetRepaintManager::updateStaticContentsSize+0x340914 00000000`0014bab0 00007ffc`c3160ade Qt6Widgets!QWidgetRepaintManager::updateStaticContentsSize+0xc5315 00000000`0014bbc0 00007ffc`c315fbb1 Qt6Widgets!QApplicationPrivate::notify_helper+0x10e16 00000000`0014bbf0 00007ffc`b765a0f5 Qt6Widgets!QApplication::notify+0x18b117 00000000`0014c0a0 00007ffc`b9668dc3 Qt6Core!QCoreApplication::notifyInternal2+0xc518 00000000`0014c110 00007ffc`b96b77ba Qt6Gui!QGuiApplicationPrivate::processMouseEvent+0x70319 00000000`0014c630 00007ffc`b77ba360 Qt6Gui!QWindowSystemInterface::sendWindowSystemEvents+0xea1a 00000000`0014c660 00007ffc`b9905f39 Qt6Core!QEventDispatcherWin32::processEvents+0x901b 00000000`0014f7f0 00007ffc`b765fab4 Qt6Gui!QWindowsGuiEventDispatcher::processEvents+0x191c 00000000`0014f820 00007ffc`b7657e1d Qt6Core!QEventLoop::exec+0x1941d 00000000`0014f8c0 00000001`40075444 Qt6Core!QCoreApplication::exec+0x15d1e 00000000`0014f920 00000001`4008d21c FlareAuthenticator+0x754441f 00000000`0014fef0 00007ffd`94e4259d FlareAuthenticator+0x8d21c20 00000000`0014ff30 00007ffd`9570af78 KERNEL32!BaseThreadInitThunk+0x1d21 00000000`0014ff60 00000000`00000000 ntdll!RtlUserThreadStart+0x28With TTD, I checked out the start of the function.
Time Travel Position: C464:22D1chall8+0x15a49:00000001`40015a49 eb00 jmp chall8+0x15a4b (00000001`40015a4b)0:000> g-uTime Travel Position: C38B:412chall8+0x8855b:00000001`4008855b ffd0 call rax {chall8+0x12e50 (00000001`40012e50)}0:000> r raxrax=0000000140012e50Deobfuscating 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.

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.

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.

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.

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
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

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. 
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) - 1N_STEPS, N_DIG = 25, 10TARGET = 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 constantsrows = [[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 constraintsz.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))
# Solveif 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"}
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