with one click
deobf-indirect
// Deobfuscate indirect branches (CSEL + BR pattern) using symbolic execution and BFS traversal to recover control flow
// Deobfuscate indirect branches (CSEL + BR pattern) using symbolic execution and BFS traversal to recover control flow
| name | deobf-indirect |
| description | Deobfuscate indirect branches (CSEL + BR pattern) using symbolic execution and BFS traversal to recover control flow |
The obfuscator converts conditional branches into indirect jumps, where CSEL and BR instructions appear in pairs. CSEL selects one of two target addresses based on a condition, followed by some junk instructions, then BR jumps to the selected address.
Typical instruction sequence:
CMP W10, W11
CSEL W10, W13, W12, LS ; select W13 or W12 based on LS condition
...... ; junk code in between (address calculations, etc.)
ADD X8, X8, X14
BR X8 ; indirect jump to the computed target
Key characteristics:
CSEL selects one of two register values, representing two branch targetsCSEL and BR is address calculation or junk codeBR performs the final indirect jumpSymbolic execution: when encountering a CSEL instruction, force different branch selections to obtain two different BR target addresses.
Steps:
CSEL to select the first register (condition-true branch)CSEL to select the second register (condition-false branch)BR and yields a different target address (A and B)BFS traversal starting from the function entry block:
CSEL selections until BRRET as return blocks — do not continue from themCode between CSEL and BR is junk — patch a conditional branch directly at the CSEL location.
For example, CSEL W11, W8, W9, CC: W8 is the target for the CC-true branch (A), W9 is the target for the CC-false branch (B).
Patch as:
BCC A ; if condition met, jump to A
B B ; otherwise jump to B
Two instructions = 8 bytes, overwriting the CSEL (4 bytes) and the next junk instruction (4 bytes).
Unlike the CSEL variant in Part 1, the CSET variant uses a 0/1 index to look up a jump table for computing the target address. The code between CSET and BR is NOT all junk — it contains useful instructions that subsequent basic blocks depend on.
Typical instruction sequence:
CMP X27, X8
CSET W8, EQ ; W8 = 0 or 1 (index)
STR W8, [SP, #offset] ; store index (junk)
LDR X9, [SP, #tbl_off] ; load jump table pointer (junk)
LDR X8, [X9, W8, UXTW#3] ; table[index] (junk)
ADRP X9, #page ; load encrypted constant (junk)
LDR W9, [X9, #off] ; (junk)
MOV W10, #imm ; XOR key (junk)
MOVK W10, #imm, LSL#16 ; (junk)
EOR W9, W9, W10 ; decrypt offset (junk)
NEG W9, W9 ; negate (junk)
ADD X8, X8, W9, SXTW ; final target address (junk, boundary)
; --- useful code below ---
ADRP X25, #0x100004000 ; register setup for successor blocks
ADD X25, X25, #0x250
MOV W28, #0xF065 ; constant init
MOVK W28, #0x611A, LSL#16
LDR X23, [SP, #0x50] ; load state for successor
BR X8 ; indirect jump (junk)
ADD Xn, Xn, Wm, SXTW is the boundary between junk and useful code. Everything from CSET to ADD (inclusive) is junk:
| Instruction | Purpose | Classification |
|---|---|---|
CSET Wd, cond | Set 0/1 index | junk (replaced by patch) |
STR Wd, [SP, #off] | Store index value | junk (only used by jump table) |
LDR Xn, [base, #off] | Load jump table pointer | junk |
LDR Xm, [Xn, Wd, UXTW#3] | Table lookup table[index] | junk |
ADRP + LDR Wn | Load encrypted constant | junk |
MOV + MOVK Wm | XOR decryption key | junk |
EOR Wn, Wn, Wm | Decrypt offset | junk |
NEG Wn, Wn | Negate | junk |
ADD Xm, Xm, Wn, SXTW | Compute final address | junk (boundary) |
| Subsequent MOV/LDR/STR/ADRP+ADD | Register and stack state init | useful |
BR Xm | Indirect jump | junk (replaced by patch) |
Same as the CSEL variant: symbolic execution forces CSET to take 1 and 0 respectively, runs until BR to obtain two target addresses.
One difference: a single basic block may contain multiple CSEL/CSET instructions (e.g., a data-selection CSEL followed by a branch-controlling CSET). The script forces selection on every CSEL/CSET encountered; the recorded csel_addr is the last one (the one that controls the BR target).
Cannot patch directly at the CSET location (would skip useful code). Correct approach:
ADD Xn, Xn, Wm, SXTW (the boundary)Bcond A; B B immediately afterBefore: [CSET][junk...][ADD][useful code][BR]
After: [useful code][Bcond A][B B][... dead code ...]
Prerequisite: useful code must be position-independent (SP-relative addressing, immediate assignments, or same-page ADRP). Moving ADRP within the same 4KB page requires no immediate adjustment.
Two script variants for different obfuscation sub-patterns:
script/deinbr-v3-csel.py — CSEL variant (ELF)For binaries where CSEL directly selects between two target addresses and the code between CSEL and BR is pure address calculation junk. Patches 8 bytes at the CSEL location (Bcond + B), overwriting CSEL and the next junk instruction.
script/deinbr-v3-cset.py — CSET variant (Mach-O / ELF)For binaries where CSET sets a 0/1 index and the code between CSET and BR contains useful side-effect instructions (register setup, stack stores for subsequent blocks) interleaved with address calculation junk.
Patching strategy: find ADD Xn, Xn, Wm, SXTW (last address calculation step), move the useful code (between ADD and BR) up to the CSET location, then append Bcond + B. This preserves register/memory state that successor blocks depend on. The moved instructions must be position-independent (SP-relative, immediates, or same-page ADRP).
Both scripts share the same core workflow:
analyze_br(proj, func_start) — BFS traversal, collects all patch pointsrun_until_br(proj, state, csel_selector) — execute from a given state until BR, forcing CSEL/CSET selectiondo_patch(binary_path, save_path, proj, patch_list) — assemble patches with keystone and write to binaryKey angr options:
CALLLESS — ignore function calls to prevent analysis divergenceLAZY_SOLVES — defer constraint solving for performanceZERO_FILL_UNCONSTRAINED_MEMORY — fill unconstrained memory with zerosZERO_FILL_UNCONSTRAINED_REGISTERS — fill unconstrained registers with zerosAll non-trivial Python code (both analysis scripts and final patching scripts) MUST be written to a file and executed via python script.py. Never use python -c "..." inline in bash. This avoids shell quoting/escaping issues and makes scripts easier to debug and reuse.
block: 41fe20, next_1: <SimState @ 0x2908f8c3>, next_2: <SimState @ 0x2908f8c3>
SimEngineError: No bytes in memory for block starting at 0x2908f8c3.
Cause: The user-provided address is not the function entry but an internal basic block. Stack-based jump table base addresses and offset constants have not been initialized (filled with zeros by ZERO_FILL), causing BR to compute an invalid target.
Solution: Confirm the address is the function start. Use the symbol table or IDA/Ghidra to find the function entry:
for sym in proj.loader.main_object.symbols:
if sym.rebased_addr <= target_addr < sym.rebased_addr + sym.size:
print(sym.name, hex(sym.rebased_addr))
block: XXXXX, next_1: <SimState @ 0xABCD>, next_2: <SimState @ 0xABCD>
Same cause as above — starting execution from a non-entry point means the two registers selected by CSEL hold the same value (both zero or the same uninitialized value), producing the same BR target after address calculation.
Solution: Use the correct function entry address.
block XXXXX: expected 1 successor, got 0
Or the script reaches BR without detecting CSEL, so csel_addr is missing from globals.
Cause: The basic block uses CSET instead of CSEL. CSET is a special form of CSEL, equivalent to CSEL Rd, WZR, WZR, invert(cond), selecting 1 or 0 as an index.
Handle the same way as CSEL — identify the condition and force both branch selections. The script must handle both csel and cset:
if insn.mnemonic == 'cset':
# cset Wd, cond is equivalent to csel Wd, #1, #0, cond (condition met=1, not met=0)
dst, cond = parse_cset(insn)
val = 1 if csel_selector == 1 else 0
setattr(state.regs, dst, val)
...