De-flattening Mutated OLLVM Control Flow with angr Symbolic Execution

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

OLLVM has always been one of the strongest levers in program protection: it shreds the original control flow and drives execution through a dispatcher. There’s plenty written about the basics, so I’ll assume you already know what a prologue, a successor, and a dispatcher are. What I want to cover here are the details everyone else skips — enough that you can write your own scripts for different mutated OLLVM variants using one general recipe.

On the static side there are IDA plugins like d810, but static approaches break down against mutated OLLVM — double loop heads, convergence blocks merging with loop heads, and so on. Which raises the obvious thought: if it’s control-flow obfuscation, why not just execute it dynamically once and recover the real execution order?

Finding the real blocks

The first step is to find all the real blocks — the actual code blocks — through a fixed pipeline. The classic diagram:

OLLVM flattened control flow

1. Find the loop head.

A -> B -> C -> D -> B

If execution returns to B, then B is the loop head, and BFS finds it:

# ida python
def find_loop_head(start_ea):
    loop_heads = set()
    queue = deque() # BFS queue
    blcok = get_basic_block(start_ea) # get the basic block containing the start address
    queue.append((blcok,[]))
    while len(queue) > 0:
        cur_block, path = queue.popleft()
        if cur_block.start_ea in path:
            loop_heads.add(cur_block.start_ea) # found a loop head
            continue
        path = path + [cur_block.start_ea] # update path
        queue.extend((s, path) for s in cur_block.succs()) # enqueue successors

    all_loop_heads = list(loop_heads)
    all_loop_heads.sort() # sort ascending so the main loop head comes first
    print("[+]Find loop heads:",[hex(lh) for lh in all_loop_heads]," -- total:",len(all_loop_heads))
    return all_loop_heads

The loop head has two predecessors: the prologue (which sets up the stack and initializes variables) and the convergence block. How do you tell which is the convergence block? It has many predecessors — and those predecessors are exactly what we care about: the real blocks.

ps: the prologue is also a real block, don’t forget it.

2. Find the convergence block.

def find_converge_addr(loop_head_addr):
    converge_addr = 0
    block = get_basic_block(loop_head_addr) # loop head
    preds = block.preds() # get predecessor blocks
    pred_list = list(preds)

    if len(pred_list) == 2: # standard ollvm: the loop head has two predecessors — one prologue block and one convergence block
        for pred in pred_list:
            tmp_list = list(pred.preds())
            if len(tmp_list) > 1: # a block with multiple predecessors is the convergence block
                converge_addr = pred.start_ea
    print("[+]Find converge_addr:",hex(converge_addr))
    return converge_addr

Whatever predecessor isn’t the convergence block is the prologue:

    for loop_head_addr in loop_heads:
        loop_head_block = get_basic_block(loop_head_addr)
        converge_addr = find_converge_addr(loop_head_addr)
        real_blocks = []


        # find the prologue
        loop_head_preds = list(loop_head_block.preds())
        loop_head_preds_addr = [b.start_ea for b in loop_head_preds]
        if loop_head_addr != converge_addr:
            loop_head_preds_addr.remove(converge_addr)
            print("prologue block:",[hex(x) for x in loop_head_preds_addr])

3. Find the ret block.

Easy — a block with no successors is the ret block:

def find_ret_block(blocks):
    for block in blocks:
        succs = list(block.succs()) # get successor blocks
        succs_list = list(succs)

        end_ea = block.end_ea # end_ea points to the address after the last instruction of the block
        last_inst_ea = idc.prev_head(end_ea) # get the address of the last instruction
        mnem = idc.print_insn_mnem(last_inst_ea) # get the instruction mnemonic

        if len(succs_list) == 0: # no successor blocks
            if mnem == "retn": # last instruction is ret
                ori_ret_block = block

                # walk upward to find a better ret block
                while True:
                    tmp_block = block.preds()
                    pred_list = list(tmp_block)
                    if len(pred_list) == 1: # single predecessor
                        block = pred_list[0]
                        if get_block_size(block) == 4: # single-instruction block
                            continue
                        else:
                            break
                    else: # multiple predecessors or none
                        break

                # handle the sub-dispatcher case
                block2 = block
                num = 0
                i = 0
                while True:
                    i += 1
                    succs_block = block2.succs()
                    for succ in succs_block:
                        child_succs = succ.succs()
                        succ_list = list(child_succs)
                        if len(succ_list) != 0:
                            block2 = succ
                            num += 1
                    if num > 2:
                        block = ori_ret_block
                        break
                    if i > 2:
                        break
                print("[+]ret block",hex(block.start_ea))
                return block.start_ea
# the ret block may have predecessors, so I count them together to be safe. you don't strictly need all of this — feel free to just eyeball the ret block yourself in some cases.

4. Find all real blocks.

def find_all_real_blocks(fun_ea):
    blocks = idaapi.FlowChart(idaapi.get_func(fun_ea))
    loop_heads = find_loop_head(fun_ea)
    all_real_blocks = []


    for loop_head_addr in loop_heads:
        loop_head_block = get_basic_block(loop_head_addr)
        converge_addr = find_converge_addr(loop_head_addr)
        real_blocks = []


        # find the prologue
        loop_head_preds = list(loop_head_block.preds())
        loop_head_preds_addr = [b.start_ea for b in loop_head_preds]
        if loop_head_addr != converge_addr:
            loop_head_preds_addr.remove(converge_addr)
            print("prologue block:",[hex(x) for x in loop_head_preds_addr])
            real_blocks.extend(loop_head_preds_addr)

        converge_block = get_basic_block(converge_addr)
        list_preds = list(converge_block.preds())




        for pred in list_preds:
            end_ea = pred.end_ea
            last_inst_ea = idc.prev_head(end_ea)
            mnem = idc.print_insn_mnem(last_inst_ea)

            size = get_block_size(pred)
            if size > 5: # larger than a single-instruction block and not a jump instruction
                start_ea = pred.start_ea
                real_blocks.append(start_ea)

        real_blocks.sort() # sort; first is the prologue block
        all_real_blocks.append(real_blocks)

        print("sub loop head and its real blocks", [hex(child_block_ea) for child_block_ea in real_blocks])

    ret_addr = find_ret_block(blocks)
    all_real_blocks.append(ret_addr)
    print("all_real_blocks:",all_real_blocks)


    all_real_block_list = []
    for real_blocks in all_real_blocks:
        if isinstance(real_blocks,list):
            all_real_block_list.extend(real_blocks)
        else:
            all_real_block_list.append(real_blocks)

    print(f"\nall real blocks done, count: {len(all_real_block_list)}")
    print(all_real_block_list)

The multiple-loop-head case is handled here too — adjust as needed. I’ll use the RoarCTF 2019 polyre challenge as the running example. The IDA run produces:

[+]Find loop heads: ['0x40063f']  -- total: 1
[+]Find converge_addr: 0x4020cc
prologue block: ['0x400620']
sub loop head and its real blocks ['0x400620', '0x401121', '0x401198', '0x4011de', '0x40124f', '0x40125e', '0x4012a4', '0x4012f6', '0x401305', '0x401326', '0x40136c', '0x4013b2', '0x4013cf', '0x4013ef', '0x401435', '0x401481', '0x401490', '0x4014ae', '0x4014d2', '0x4014e8', '0x4014f7', '0x401506', '0x401521', '0x401567', '0x4015b6', '0x4015c5', '0x4015d4', '0x4015ed', '0x4015fc', '0x401642', '0x401691', '0x4016a0', '0x4016e6', '0x401739', '0x401748', '0x401765', '0x4017ab', '0x4017fc', '0x40180b', '0x401830', '0x401849', '0x401861', '0x4018a7', '0x4018fa', '0x401909', '0x401926', '0x401940', '0x401960', '0x40197d', '0x40199b', '0x4019e1', '0x401a3d', '0x401a4c', '0x401a73', '0x401a8d', '0x401ad3', '0x401b25', '0x401b34', '0x401b4e', '0x401b5d', '0x401b75', '0x401bbb', '0x401c0d', '0x401c1c', '0x401c2b', '0x401c46', '0x401c69', '0x401caf', '0x401d03', '0x401d12', '0x401d2d', '0x401d45', '0x401d54', '0x401d9a', '0x401e00', '0x401e0f', '0x401e2d', '0x401e73', '0x401eb9', '0x401ed6', '0x401efa', '0x401f09', '0x401f2d', '0x401f3c', '0x401f60', '0x401f97', '0x401fa6', '0x401fb5', '0x401fcd', '0x401fe5', '0x401ff4', '0x40200c', '0x40201b', '0x402033', '0x40204d', '0x402072', '0x402096', '0x4020b3', '0x4020c2']
[+]ret block 0x401f54
all_real_blocks: [[4195872, 4198689, 4198808, 4198878, 4198991, 4199006, 4199076, 4199158, 4199173, 4199206, 4199276, 4199346, 4199375, 4199407, 4199477, 4199553, 4199568, 4199598, 4199634, 4199656, 4199671, 4199686, 4199713, 4199783, 4199862, 4199877, 4199892, 4199917, 4199932, 4200002, 4200081, 4200096, 4200166, 4200249, 4200264, 4200293, 4200363, 4200444, 4200459, 4200496, 4200521, 4200545, 4200615, 4200698, 4200713, 4200742, 4200768, 4200800, 4200829, 4200859, 4200929, 4201021, 4201036, 4201075, 4201101, 4201171, 4201253, 4201268, 4201294, 4201309, 4201333, 4201403, 4201485, 4201500, 4201515, 4201542, 4201577, 4201647, 4201731, 4201746, 4201773, 4201797, 4201812, 4201882, 4201984, 4201999, 4202029, 4202099, 4202169, 4202198, 4202234, 4202249, 4202285, 4202300, 4202336, 4202391, 4202406, 4202421, 4202445, 4202469, 4202484, 4202508, 4202523, 4202547, 4202573, 4202610, 4202646, 4202675, 4202690], 4202324]

Recovering the execution order

This is the crux of the article: finding each block’s successor. For every real block, the rule is:

prologue → main dispatcher → jump straight to the real block we want → keep executing and see where it lands.

Two things matter:

  1. You must execute the prologue first — it’s the initialization, the stack setup.
  2. Jump straight to the real block you’re after, instead of executing the whole program every time until you reach it — I solve that with a hook.

You might wonder how OLLVM implements something as common as if/branching. This is exactly the detail most writeups skip. On x86 it’s the cmovxx family; on ARM64 it’s csel — a topic for another day. Take one real block:

loc_40199B:
mov     eax, ds:dword_603054
mov     ecx, ds:dword_603058
mov     edx, eax
sub     edx, 1
imul    eax, edx
and     eax, 1
cmp     eax, 0
setz    sil
cmp     ecx, 0Ah
setl    dil
or      sil, dil
test    sil, 1
mov     eax, 0F37184F0h
mov     ecx, 0A105D2C4h
cmovnz  ecx, eax
mov     [rbp+var_114], ecx
jmp     loc_4020CC

cmovxx changes a register’s value, which influences the dispatcher upstream and steers execution to different blocks. The catch: angr won’t split a cmovxx into two branches — it just accumulates a constraint. So I manually split into two states: one where the move executes and one where it doesn’t, each landing in a different block. I store the result like this:

{'0xaaaa':['0xbbb','0xccc']}

Left is ZF=1, right is ZF=0 — that ordering matters when we patch later.

import logging
import angr
from tqdm import tqdm

logging.getLogger('angr').setLevel(logging.ERROR)

def capstone_decode_cmovxx(insn):
    operands = insn.op_str.replace(" ", "").split(",")
    dst_reg = operands[0]
    src_reg = operands[1]
    print(f"cmovxx parse result: dst_reg:{dst_reg}, src_reg:{src_reg}")
    return dst_reg, src_reg

def find_state_succ_cmovxx(proj, base, local_state, flag, real_blocks, real_block_addr, path):
    # only called when find_block_succ identifies a cmov
    ins = local_state.block().capstone.insns[0]
    dst_reg, src_reg = capstone_decode_cmovxx(ins)

    # logic fix:
    # flag == True  -> ZF=1 (Zero) -> cmovnz (Not Zero) condition false -> no move -> Pass
    # flag == False -> ZF=0 (Not Zero) -> cmovnz (Not Zero) condition true  -> move

    if not flag: # need to execute the move
        try:
            # fix: state.regs has no .get(); use getattr
            src_val = getattr(local_state.regs, src_reg)
            setattr(local_state.regs, dst_reg, src_val)
        except Exception as e:
            print(f"register access error: {e}")

    # key fix: manually skip this cmov instruction so angr doesn't execute it again
    local_state.regs.ip += ins.size

    sm = proj.factory.simgr(local_state)

    while(len(sm.active)):
        for active_state in sm.active:
            try:
                ins_offset = active_state.addr - base
                if ins_offset in real_blocks:
                    value = path[real_block_addr]
                    if ins_offset not in value:
                        value.append(ins_offset)
                    return ins_offset
            except:
                pass
        sm.step(num_inst=1)


def find_block_succ(proj, base, func_offset, state, real_block_addr, real_blocks, path):
    msm = proj.factory.simgr(state)  # build the simulator
    while len(msm.active):
        for active_state in msm.active:
            #print(active_state.block().capstone.insns[0])
            offset = active_state.addr - base
            #print("current offset:", hex(offset),"searching for real block:", hex(real_block_addr))
            if offset == real_block_addr:  # found the real block
                print("found real block:", hex(real_block_addr))
                mstate = active_state.copy()  # copy the state to prep for successor discovery
                msm2 = proj.factory.simgr(mstate)
                msm2.step(num_inst=1)  # advance into the next instruction in the block, to avoid mixing with the outer state

                while len(msm2.active):

                    for mactive_state in msm2.active:
                        #print(mactive_state.block().capstone.insns[0])
                        ins_offset = mactive_state.addr - base
                        if ins_offset in real_blocks:  # branchless block (or unconditional jump)
                            # on an unconditional jump, when at least two paths reach real blocks at once, take the non-ret real block
                            msm2_len = len(msm2.active)
                            if msm2_len > 1:
                                tmp_addrs = []
                                for s in msm2.active:
                                    moffset = s.addr - base
                                    tmp_value = path[real_block_addr]
                                    if moffset in real_blocks and moffset not in tmp_value:
                                        tmp_addrs.append(moffset)
                                if len(tmp_addrs) > 1:
                                    print("at least two paths reached real blocks simultaneously:", [hex(tmp_addr) for tmp_addr in tmp_addrs])
                                    ret_addr = real_blocks[len(real_blocks) - 1]
                                    if ret_addr in tmp_addrs:
                                        tmp_addrs.remove(ret_addr)
                                    ins_offset = tmp_addrs[0]
                                    print("after two paths reached real blocks, chose:", hex(ins_offset))

                            value = path[real_block_addr]
                            if ins_offset not in value:
                                value.append(ins_offset)
                            print(f"unconditional jump relation:{hex(real_block_addr)}-->{hex(ins_offset)}")
                            return
                        # possibly a cmovnz branch instruction
                        ins = mactive_state.block().capstone.insns[0]
                        if ins.mnemonic == 'cmovnz' or ins.mnemonic == 'cmovne':
                            print("found cmovnz/cmovne, splitting branches:", hex(ins_offset))
                            #split the two cases
                            state_true = mactive_state.copy()
                            state_true_succ_addr = find_state_succ_cmovxx(proj, base, state_true, True, real_blocks, real_block_addr, path)

                            state_false = mactive_state.copy()
                            state_false_succ_addr = find_state_succ_cmovxx(proj, base, state_false, False, real_blocks, real_block_addr, path)
                            if state_true_succ_addr is None or state_false_succ_addr is None:
                                print("cmovnz/cmovne error at:", hex(ins_offset))
                                print(f"cmovnz/cmovne successor error:{hex(real_block_addr)}-->{hex(state_true_succ_addr) if state_true_succ_addr is not None else state_true_succ_addr},"
                                      f"{hex(state_false_succ_addr) if state_false_succ_addr is not None else state_false_succ_addr}")
                                return "erro"
                        #cmovne
                            print(f"cmovnz/cmovne branch relation:{hex(real_block_addr)}-->{hex(state_true_succ_addr)} zf = 1,  {hex(state_false_succ_addr)} zf != 1")
                            #print(f"csel branch relation:{hex(real_block_addr)}-->{hex(state_true_succ_addr)},{hex(state_false_succ_addr)}")
                            return
                        #opposite of cmovnz
                        if ins.mnemonic == 'cmovz' or ins.mnemonic == 'cmove':
                            print("found cmovz/cmove, splitting branches:", hex(ins_offset))
                            state_true = mactive_state.copy()
                            state_true_succ_addr = find_state_succ_cmovxx(proj, base, state_true, False, real_blocks, real_block_addr, path)

                            state_false = mactive_state.copy()
                            state_false_succ_addr = find_state_succ_cmovxx(proj, base, state_false, True, real_blocks, real_block_addr, path)
                            if state_true_succ_addr is None or state_false_succ_addr is None:
                                print("cmovz/cmove error at:", hex(ins_offset))
                                print(f"cmovz/cmove successor error:{hex(real_block_addr)}-->{hex(state_true_succ_addr) if state_true_succ_addr is not None else state_true_succ_addr},"
                                      f"{hex(state_false_succ_addr) if state_false_succ_addr is not None else state_false_succ_addr}")
                                return "erro"
                        #cmovne
                            print(f"cmovz/cmove branch relation:{hex(real_block_addr)}-->{hex(state_true_succ_addr)} zf = 1,  {hex(state_false_succ_addr)} zf != 1")
                            #print(f"csel branch relation:{hex(real_block_addr)}-->{hex(state_true_succ_addr)},{hex(state_false_succ_addr)}")
                            return

                    msm2.step(num_inst=1)
                # if the last block in the real-block set never found a successor, it's the return block; just return
                return
        msm.step(num_inst=1)

def angr_main(real_blocks,func_offset,file_path):
    proj = angr.Project(file_path, auto_load_libs=False)
    base = 0
    func_addr = base + func_offset
    init_state = proj.factory.blank_state(addr=func_addr)
    init_state.options.add(angr.options.CALLLESS)



    path = {addr: [] for addr in real_blocks}

    ret_addr = real_blocks[len(real_blocks) - 1]

    first_block = proj.factory.block(func_addr)
    first_block_insns = first_block.capstone.insns
    # last instruction of the main prologue
    first_block_last_ins = first_block_insns[len(first_block_insns) - 1]
    print(hex(first_block_last_ins.address))


    for real_block_addr in tqdm(real_blocks):
        if ret_addr == real_block_addr:
            continue

        state = init_state.copy()
        print("searching:",hex(real_block_addr))

        def jump_to_address(state):
            #print(state.regs.pc)

            state.regs.pc = base + real_block_addr - 6
            print("jumping to:", hex(base + real_block_addr - 6))
            proj.unhook(0x400675)
        print(hex(real_block_addr),hex(func_offset))

        if real_block_addr != func_offset:
            print("prologue done")
            proj.hook(0x400675, jump_to_address, first_block_last_ins.size)

        ret = find_block_succ(proj, base, func_offset, state, real_block_addr, real_blocks, path)

        if ret == "erro":
            return

    hex_dict = {
        hex(key): [hex(value) for value in values]
        for key, values in path.items()
    }

    for i in hex_dict.keys():
        print(f"{i}:  {hex_dict[i]}")
    print(hex_dict)
    return hex_dict

all_real_blocks: list[int] =[4195872, 4198689, 4198808, 4198878, 4198991, 4199006, 4199076, 4199158, 4199173, 4199206, 4199276, 4199346, 4199375, 4199407, 4199477, 4199553, 4199568, 4199598, 4199634, 4199656, 4199671, 4199686, 4199713, 4199783, 4199862, 4199877, 4199892, 4199917, 4199932, 4200002, 4200081, 4200096, 4200166, 4200249, 4200264, 4200293, 4200363, 4200444, 4200459, 4200496, 4200521, 4200545, 4200615, 4200698, 4200713, 4200742, 4200768, 4200800, 4200829, 4200859, 4200929, 4201021, 4201036, 4201075, 4201101, 4201171, 4201253, 4201268, 4201294, 4201309, 4201333, 4201403, 4201485, 4201500, 4201515, 4201542, 4201577, 4201647, 4201731, 4201746, 4201773, 4201797, 4201812, 4201882, 4201984, 4201999, 4202029, 4202099, 4202169, 4202198, 4202234, 4202249, 4202285, 4202300, 4202336, 4202391, 4202406, 4202421, 4202445, 4202469, 4202484, 4202508, 4202523, 4202547, 4202573, 4202610, 4202646, 4202675, 4202690, 4202324]


angr_main(all_real_blocks, 0x400620, "polyre")

A few key points.

def jump_to_address(state):
    #print(state.regs.pc)

    state.regs.pc = base + real_block_addr - 6
    print("jumping to:", hex(base + real_block_addr - 6))
    proj.unhook(0x400675)

We hook the last instruction of the main dispatcher, run until we jump straight to the block we want, then unhook — otherwise when execution loops back it jumps there again and spins forever. As for the -6: in theory it shouldn’t need an offset, but in practice without it angr mis-decodes the instruction and capstone produces garbage. I found -6 by trial and error; if it doesn’t work for you, try something else and print the disassembly to compare (I left it commented out). The result:

0x400620:  ['0x401121']
0x401121:  ['0x401198']
0x401198:  ['0x401f60', '0x4011de']
0x4011de:  ['0x401f60', '0x40124f']
0x40124f:  ['0x40125e']
0x40125e:  ['0x401f97', '0x4012a4']
0x4012a4:  ['0x401f97', '0x4012f6']
0x4012f6:  ['0x401305']
0x401305:  ['0x401326']
0x401326:  ['0x401fa6', '0x40136c']
0x40136c:  ['0x401fa6', '0x4013b2']
0x4013b2:  ['0x4015d4', '0x4013cf']
0x4013cf:  ['0x4013ef']
0x4013ef:  ['0x401fb5', '0x401435']
0x401435:  ['0x401fb5', '0x401481']
0x401481:  ['0x401490']
0x401490:  ['0x4014ae', '0x4014f7']
0x4014ae:  ['0x4014d2']
0x4014d2:  ['0x4014e8']
0x4014e8:  ['0x4015d4']
0x4014f7:  ['0x401506']
0x401506:  ['0x401521']
0x401521:  ['0x401fcd', '0x401567']
0x401567:  ['0x401fcd', '0x4015b6']
0x4015b6:  ['0x4015c5']
0x4015c5:  ['0x40125e']
0x4015d4:  ['0x4015ed']
0x4015ed:  ['0x4015fc']
0x4015fc:  ['0x401fe5', '0x401642']
0x401642:  ['0x401fe5', '0x401691']
0x401691:  ['0x4016a0']
0x4016a0:  ['0x401ff4', '0x4016e6']
0x4016e6:  ['0x401ff4', '0x401739']
0x401739:  ['0x401748']
0x401748:  ['0x401d54', '0x401765']
0x401765:  ['0x40200c', '0x4017ab']
0x4017ab:  ['0x40200c', '0x4017fc']
0x4017fc:  ['0x40180b']
0x40180b:  ['0x401830']
0x401830:  ['0x401849']
0x401849:  ['0x401861']
0x401861:  ['0x40201b', '0x4018a7']
0x4018a7:  ['0x40201b', '0x4018fa']
0x4018fa:  ['0x401909']
0x401909:  ['0x401c2b', '0x401926']
0x401926:  ['0x401940']
0x401940:  ['0x401960']
0x401960:  ['0x401a73', '0x40197d']
0x40197d:  ['0x40199b']
0x40199b:  ['0x402033', '0x4019e1']
0x4019e1:  ['0x402033', '0x401a3d']
0x401a3d:  ['0x401a4c']
0x401a4c:  ['0x401b4e']
0x401a73:  ['0x401a8d']
0x401a8d:  ['0x40204d', '0x401ad3']
0x401ad3:  ['0x40204d', '0x401b25']
0x401b25:  ['0x401b34']
0x401b34:  ['0x401b4e']
0x401b4e:  ['0x401b5d']
0x401b5d:  ['0x401b75']
0x401b75:  ['0x402072', '0x401bbb']
0x401bbb:  ['0x402072', '0x401c0d']
0x401c0d:  ['0x401c1c']
0x401c1c:  ['0x401849']
0x401c2b:  ['0x401c46']
0x401c46:  ['0x401c69']
0x401c69:  ['0x402096', '0x401caf']
0x401caf:  ['0x402096', '0x401d03']
0x401d03:  ['0x401d12']
0x401d12:  ['0x401d2d']
0x401d2d:  ['0x401d45']
0x401d45:  ['0x4015fc']
0x401d54:  ['0x4020b3', '0x401d9a']
0x401d9a:  ['0x4020b3', '0x401e00']
0x401e00:  ['0x401e0f']
0x401e0f:  ['0x401e2d']
0x401e2d:  ['0x4020c2', '0x401e73']
0x401e73:  ['0x4020c2', '0x401eb9']
0x401eb9:  ['0x401ed6', '0x401f09']
0x401ed6:  ['0x401efa']
0x401efa:  ['0x401f3c']
0x401f09:  ['0x401f2d']
0x401f2d:  ['0x401f3c']
0x401f3c:  ['0x401f54']
0x401f60:  ['0x4011de']
0x401f97:  ['0x4012a4']
0x401fa6:  ['0x40136c']
0x401fb5:  ['0x401435']
0x401fcd:  ['0x401567']
0x401fe5:  ['0x401642']
0x401ff4:  ['0x4016e6']
0x40200c:  ['0x4017ab']
0x40201b:  ['0x4018a7']
0x402033:  ['0x4019e1']
0x40204d:  ['0x401ad3']
0x402072:  ['0x401bbb']
0x402096:  ['0x401caf']
0x4020b3:  ['0x401d9a']
0x4020c2:  ['0x401e73']
0x401f54:  []

Patching

  1. If a block has a single successor, patch its last instruction to:
jmp <successor>
  1. If it has two successors — left is ZF=1, right is ZF=0, starting at the cmovxx — patch to:
jz <left address>
jmp <right address>

Fill the rest with NOPs, then NOP the blocks that never got used.

from collections import deque

import ida_funcs
import idaapi
import idautils
import idc
import keystone


def patch_ins_to_nop(ins):
    size = idc.get_item_size(ins)
    for i in range(size):
        idc.patch_byte(ins + i,0x90)


def patch_bytes(addr, data):
    for i, b in enumerate(data):
        idc.patch_byte(addr + i, b)


def fill_nop(start_ea, end_ea):
    # [FIX 1] should be end - start, otherwise negative
    size = end_ea - start_ea
    if size > 0:
        # [FIX 2] use patch_bytes for bulk write
        patch_bytes(start_ea, b'\x90' * size)

def get_block_by_address(ea):
    func = idaapi.get_func(ea)
    blocks = idaapi.FlowChart(func)
    for block in blocks:
        if block.start_ea <= ea < block.end_ea:
            return block
    return None

def generate_jmp_code(src, dst):
    # E9 xx xx xx xx
    offset = dst - (src + 5)
    return b'\xE9' + offset.to_bytes(4, 'little', signed=True)

def generate_jz_code(src, dst):
    # 0F 84 xx xx xx xx
    offset = dst - (src + 6)
    return b'\x0F\x84' + offset.to_bytes(4, 'little', signed=True)

def patch_branch(patch_dict):
    for ea in patch_dict:
        values = patch_dict[ea]
        if len(values) == 0:# if there are zero successors, it's basically a return block — no patch needed, skip
            continue
        block = get_block_by_address(int(ea, 16))
        start_ea = block.start_ea
        end_ea = block.end_ea
        last_ins_ea = idc.prev_head(end_ea)# block.end_ea is the address after the block's last instruction, so step back one
        if len(values) == 2:
            for ins in idautils.Heads(start_ea,end_ea):
                if idc.print_insn_mnem(ins).startswith("cmov"):
                    print("find cmov")
                    jz_code = generate_jz_code(ins, int(values[0],16))
                    jmp_code = generate_jmp_code(ins + len(jz_code), int(values[1],16))

                    # [FIX 2] actually write to memory!
                    patch_bytes(ins, jz_code)
                    patch_bytes(ins + len(jz_code), jmp_code)

                    # 3. fill with NOP
                    nop_start = ins + len(jz_code) + len(jmp_code)
                    fill_nop(nop_start, end_ea)
        if len(values) == 1:
            mnem = idc.print_insn_mnem(last_ins_ea)
            if mnem.startswith("jmp"):
                jmp_code = generate_jmp_code(last_ins_ea, int(values[0],16))
                patch_bytes(last_ins_ea, jmp_code)
                nop_start = last_ins_ea + len(jmp_code)
                fill_nop(nop_start, end_ea)

def find_all_useless_block(func_ea,real_blocks):
    blocks = idaapi.FlowChart(idaapi.get_func(func_ea))
    local_real_blocks = real_blocks.copy()
    useless_blocks = []

        # local_real_blocks.extend(succ.start_ea for succ in cur_block.succs())
    for block in blocks:
        start_ea = block.start_ea
        end_ea = block.end_ea
        if start_ea not in local_real_blocks:
            useless_blocks.append([start_ea,end_ea])

    print("all useless blocks:",[b for b in useless_blocks])
    return useless_blocks


def patch_useless_blocks(useless_blocks):

    # print(useless_blocks)
    for useless_block in useless_blocks:

        print(f"Nop-ing useless block from {hex(useless_block[0])} to {hex(useless_block[1])}")
        fill_nop(useless_block[0], useless_block[1])
    print("useless blocks nop'd")


func_ea = 0x400620
all_real_blocks =[4195872, 4198689, 4198808, 4198878, 4198991, 4199006, 4199076, 4199158, 4199173, 4199206, 4199276, 4199346, 4199375, 4199407, 4199477, 4199553, 4199568, 4199598, 4199634, 4199656, 4199671, 4199686, 4199713, 4199783, 4199862, 4199877, 4199892, 4199917, 4199932, 4200002, 4200081, 4200096, 4200166, 4200249, 4200264, 4200293, 4200363, 4200444, 4200459, 4200496, 4200521, 4200545, 4200615, 4200698, 4200713, 4200742, 4200768, 4200800, 4200829, 4200859, 4200929, 4201021, 4201036, 4201075, 4201101, 4201171, 4201253, 4201268, 4201294, 4201309, 4201333, 4201403, 4201485, 4201500, 4201515, 4201542, 4201577, 4201647, 4201731, 4201746, 4201773, 4201797, 4201812, 4201882, 4201984, 4201999, 4202029, 4202099, 4202169, 4202198, 4202234, 4202249, 4202285, 4202300, 4202336, 4202391, 4202406, 4202421, 4202445, 4202469, 4202484, 4202508, 4202523, 4202547, 4202573, 4202610, 4202646, 4202675, 4202690, 4202324]
useless_blocks = find_all_useless_block(func_ea,all_real_blocks)
patch_branch({'0x400620': ['0x401121'], '0x401121': ['0x401198'], '0x401198': ['0x401f60', '0x4011de'], '0x4011de': ['0x401f60', '0x40124f'], '0x40124f': ['0x40125e'], '0x40125e': ['0x401f97', '0x4012a4'], '0x4012a4': ['0x401f97', '0x4012f6'], '0x4012f6': ['0x401305'], '0x401305': ['0x401326'], '0x401326': ['0x401fa6', '0x40136c'], '0x40136c': ['0x401fa6', '0x4013b2'], '0x4013b2': ['0x4015d4', '0x4013cf'], '0x4013cf': ['0x4013ef'], '0x4013ef': ['0x401fb5', '0x401435'], '0x401435': ['0x401fb5', '0x401481'], '0x401481': ['0x401490'], '0x401490': ['0x4014ae', '0x4014f7'], '0x4014ae': ['0x4014d2'], '0x4014d2': ['0x4014e8'], '0x4014e8': ['0x4015d4'], '0x4014f7': ['0x401506'], '0x401506': ['0x401521'], '0x401521': ['0x401fcd', '0x401567'], '0x401567': ['0x401fcd', '0x4015b6'], '0x4015b6': ['0x4015c5'], '0x4015c5': ['0x40125e'], '0x4015d4': ['0x4015ed'], '0x4015ed': ['0x4015fc'], '0x4015fc': ['0x401fe5', '0x401642'], '0x401642': ['0x401fe5', '0x401691'], '0x401691': ['0x4016a0'], '0x4016a0': ['0x401ff4', '0x4016e6'], '0x4016e6': ['0x401ff4', '0x401739'], '0x401739': ['0x401748'], '0x401748': ['0x401d54', '0x401765'], '0x401765': ['0x40200c', '0x4017ab'], '0x4017ab': ['0x40200c', '0x4017fc'], '0x4017fc': ['0x40180b'], '0x40180b': ['0x401830'], '0x401830': ['0x401849'], '0x401849': ['0x401861'], '0x401861': ['0x40201b', '0x4018a7'], '0x4018a7': ['0x40201b', '0x4018fa'], '0x4018fa': ['0x401909'], '0x401909': ['0x401c2b', '0x401926'], '0x401926': ['0x401940'], '0x401940': ['0x401960'], '0x401960': ['0x401a73', '0x40197d'], '0x40197d': ['0x40199b'], '0x40199b': ['0x402033', '0x4019e1'], '0x4019e1': ['0x402033', '0x401a3d'], '0x401a3d': ['0x401a4c'], '0x401a4c': ['0x401b4e'], '0x401a73': ['0x401a8d'], '0x401a8d': ['0x40204d', '0x401ad3'], '0x401ad3': ['0x40204d', '0x401b25'], '0x401b25': ['0x401b34'], '0x401b34': ['0x401b4e'], '0x401b4e': ['0x401b5d'], '0x401b5d': ['0x401b75'], '0x401b75': ['0x402072', '0x401bbb'], '0x401bbb': ['0x402072', '0x401c0d'], '0x401c0d': ['0x401c1c'], '0x401c1c': ['0x401849'], '0x401c2b': ['0x401c46'], '0x401c46': ['0x401c69'], '0x401c69': ['0x402096', '0x401caf'], '0x401caf': ['0x402096', '0x401d03'], '0x401d03': ['0x401d12'], '0x401d12': ['0x401d2d'], '0x401d2d': ['0x401d45'], '0x401d45': ['0x4015fc'], '0x401d54': ['0x4020b3', '0x401d9a'], '0x401d9a': ['0x4020b3', '0x401e00'], '0x401e00': ['0x401e0f'], '0x401e0f': ['0x401e2d'], '0x401e2d': ['0x4020c2', '0x401e73'], '0x401e73': ['0x4020c2', '0x401eb9'], '0x401eb9': ['0x401ed6', '0x401f09'], '0x401ed6': ['0x401efa'], '0x401efa': ['0x401f3c'], '0x401f09': ['0x401f2d'], '0x401f2d': ['0x401f3c'], '0x401f3c': ['0x401f54'], '0x401f60': ['0x4011de'], '0x401f97': ['0x4012a4'], '0x401fa6': ['0x40136c'], '0x401fb5': ['0x401435'], '0x401fcd': ['0x401567'], '0x401fe5': ['0x401642'], '0x401ff4': ['0x4016e6'], '0x40200c': ['0x4017ab'], '0x40201b': ['0x4018a7'], '0x402033': ['0x4019e1'], '0x40204d': ['0x401ad3'], '0x402072': ['0x401bbb'], '0x402096': ['0x401caf'], '0x4020b3': ['0x401d9a'], '0x4020c2': ['0x401e73'], '0x401f54': []})

patch_useless_blocks(useless_blocks)
ida_funcs.reanalyze_function(ida_funcs.get_func(func_ea))# refresh the function's control-flow graph
print("control-flow graph refreshed")

One small ordering detail here: first find all the useless blocks (their start/end addresses), then patch the control flow, and only then NOP the useless blocks using that earlier list. If you patch the control flow first and then go looking for useless blocks, the ones you find will be wrong.

Here’s the recovered code:

__int64 __fastcall main(int a1, char **a2, char **a3)
{
  signed __int64 v4; // [rsp+1E0h] [rbp-110h]
  int n6; // [rsp+1E8h] [rbp-108h]
  int n64; // [rsp+1ECh] [rbp-104h]
  int n64_1; // [rsp+1ECh] [rbp-104h]
  _BYTE s1[48]; // [rsp+1F0h] [rbp-100h] BYREF
  _BYTE s[60]; // [rsp+220h] [rbp-D0h] BYREF
  unsigned int v10; // [rsp+25Ch] [rbp-94h]
  _BYTE *p_s; // [rsp+260h] [rbp-90h]
  int n64_3; // [rsp+26Ch] [rbp-84h]
  bool v13; // [rsp+272h] [rbp-7Eh]
  unsigned __int8 n10; // [rsp+273h] [rbp-7Dh]
  int n10_1; // [rsp+274h] [rbp-7Ch]
  _BYTE *v16; // [rsp+278h] [rbp-78h]
  int v17; // [rsp+284h] [rbp-6Ch]
  int n6_1; // [rsp+288h] [rbp-68h]
  bool v19; // [rsp+28Fh] [rbp-61h]
  _BYTE *p_s_1; // [rsp+290h] [rbp-60h]
  int n64_4; // [rsp+298h] [rbp-58h]
  bool v22; // [rsp+29Fh] [rbp-51h]
  signed __int64 v23; // [rsp+2A0h] [rbp-50h]
  bool v24; // [rsp+2AFh] [rbp-41h]
  __int64 v25; // [rsp+2B0h] [rbp-40h]
  __int64 v26; // [rsp+2B8h] [rbp-38h]
  signed __int64 v27; // [rsp+2C0h] [rbp-30h]
  __int64 v28; // [rsp+2C8h] [rbp-28h]
  int n64_2; // [rsp+2D0h] [rbp-20h]
  int v30; // [rsp+2D4h] [rbp-1Ch]
  signed __int64 *v31; // [rsp+2D8h] [rbp-18h]
  int v32; // [rsp+2E0h] [rbp-10h]
  int v33; // [rsp+2E4h] [rbp-Ch]
  bool v34; // [rsp+2EBh] [rbp-5h]

  v10 = 0;
  memset(s, 0, 0x30u);
  memset(s1, 0, sizeof(s1));
  printf("Input:");
  p_s = s;
  __isoc99_scanf("%s", s);
  for ( n64 = 0; ; ++n64 )
  {
    n64_3 = n64;
    v13 = n64 < 64;
    if ( n64 >= 64 )
      break;
    n10 = s[n64];
    n10_1 = n10;
    if ( n10 == 10 )
    {
      v16 = &s[n64];
      *v16 = 0;
      break;
    }
    v17 = n64 + 1;
  }
  for ( n6 = 0; ; ++n6 )
  {
    n6_1 = n6;
    v19 = n6 < 6;
    if ( n6 >= 6 )
      break;
    p_s_1 = s;
    v4 = *(_QWORD *)&s[8 * n6];
    for ( n64_1 = 0; ; ++n64_1 )
    {
      n64_4 = n64_1;
      v22 = n64_1 < 64;
      if ( n64_1 >= 64 )
        break;
      v23 = v4;
      v24 = v4 < 0;
      if ( v4 >= 0 )
      {
        v27 = v4;
        v28 = 2 * v4;
        v4 *= 2LL;
      }
      else
      {
        v25 = 2 * v4;
        v26 = 2 * v4;
        v4 = (2 * v4) ^ 0xB0004B7679FA26B3LL;
      }
      n64_2 = n64_1;
    }
    v30 = 8 * n6;
    v31 = (signed __int64 *)&s1[8 * n6];
    *v31 = v4;
    v32 = n6 + 1;
  }
  v33 = memcmp(s1, &s2_, 0x30u);
  v34 = v33 != 0;
  if ( v33 )
    puts("Wrong!");
  else
    puts("Correct!");
  return v10;
}

And the final exploit:

import struct

# Data from s2_
s2_bytes = [
    0x96, 0x62, 0x53, 0x43, 0x6d, 0xf2, 0x8f, 0xbc,
    0x16, 0xee, 0x30, 0x5, 0x78, 0x0, 0x1, 0x52,
    0xec, 0x8, 0x5f, 0x93, 0xea, 0xb5, 0xc0, 0x4d,
    0x50, 0xf4, 0x53, 0xd8, 0xaf, 0x90, 0x2b, 0x34,
    0x81, 0x36, 0x2c, 0xaa, 0xbc, 0xe, 0x25, 0x8b,
    0xe4, 0x8a, 0xc6, 0xa2, 0x81, 0x9f, 0x75, 0x55
]

POLY = 0xB0004B7679FA26B3

# Convert bytes to QWORDs (little endian)
qwords = []
for i in range(0, len(s2_bytes), 8):
    chunk = s2_bytes[i:i+8]
    val = struct.unpack('<Q', bytes(chunk))[0]
    qwords.append(val)

start_vals = []
for val in qwords:
    curr = val
    for _ in range(64):
        if curr & 1:
            curr = (curr ^ POLY) >> 1
            curr |= 0x8000000000000000
        else:
            curr = curr >> 1
    start_vals.append(curr)

# Convert back to bytes
flag = b''
for val in start_vals:
    flag += struct.pack('<Q', val)

print(flag)
try:
    print("Flag:", flag.decode('utf-8'))
except:
    pass

#flag{6ff29390-6c20-4c56-ba70-a95758e3d1f8}

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch