Building Zyrox: A Custom LLVM Obfuscator (Part II — Indirect Branching V2)

In Part II, I talked about control flow flattening obfuscation, but a simple text-analyzer can regenerate the CFF, unless SipHash was used, which won't always be the case as it is slow.

In Part I, I mentioned Simple Indirect Branching (SIBR), which is a very basic form of indirect branching. In this part, I will introduce an improved version of indirect branching, that uses encrypted jump tables.

It took me days to figure out how to implement this properly. LLVM-IR is not designed for this kind of obfuscation, and it is not possible to modify relocations through a normal plugin. I came up with the idea of using a post-compile python script that finds the jump tables and encrypts them. more about this later.


Relocator Implementation: Theory

What are relocations?

If you used frida before, you know we use libc.getExportByName("open") to get the address of open function. Often, we see something like 0x7f5339849000. This though, is not the actual address of open, it is open_addr + base_addr.

Let's take this very simplified example:

void a();

void (*funcs[1])() = {a};

int main() {
    funcs[0]();
    return 0;
}
expand code

If the compiler puts the virtual address of a directly into the funcs array, then when the binary is loaded at a different base address, the address of a will be wrong.

To solve this, the compiler creates a relocation entry for the funcs array. This entry tells the loader to adjust the address of a based on where the binary is loaded in memory.

When the program starts, the loader looks at these relocation entries and updates the addresses accordingly. This way, no matter where the binary is loaded, all function pointers and references point to the correct locations.

The code above becomes something like this after compilation (simplified version):

relocs:
    // dest = address of funcs start
    // target = offset of the function "a"
    {dest, target}

/*
    Where dest and target are relative to base address 0x0.
    Then at runtime, the linker uses this entry to fix up the address in `funcs` array,
    once the library is loaded::
*/
for reloc in relocs:
    *(reloc.dest + base_address) = reloc.target + base_address;
expand code

How do we even encrypt it?

Right. You might be wondering, if we do not know the address ahead of time, and relocation writes over it, how can we encrypt it? Won't it get corrupted?

Playing with relocations is not a fun game, and definitely not something recommended. I challenged myself to find a way to do it anyway, for educational purposes ;^).

After thinking for a while, I asked myself the following:

Why don't we patch relocations, make them point to a dummy dest address, and we fix base address in-place once it's used at runtime? After all, we are not encrypting random relocations, we are targeting ones that we can manipulate how they are used, for our indirect branching pass.

Fixing the base address

Fixing the base address is the easiest part of this.

Why we want to fix it?

So we can do the "patch in place" after decryption, to get the real address.

Now we can use some function for that, dl library, or even some compiler-support.

But, since we are playing with relocations anyways, we can abuse them here too ;)

I mentioned before, how linker does something like:

*(reloc.dest + base_address) = reloc.target + base_address;
expand code

What if we make reloc.target be 0? Then the linker will do:

*(reloc.dest + base_address) = base_address;
expand code

Exactly what we want! We can use this method to make the linker give us base address ;)


Relocator Implementation: LLVM Pass

Encryption Per Block

for encryption, I used a modified version of xtea algorithm, which is a simple and fast block cipher.

Every jump table used it's own rounds and delta.

Now, for some reason, LLVM refused to inline the decryption function in most places, even if it is small. No error, no warning, just refused to inline it.

I did what any totally sane person would do, I wrote by hand the llvm-ir equivalent of the decryption function using llvm::IRBuilder, making it easier for us to decide if we want to inline it or make it a function call.

Having a single uninlined decryption is better for binary size, but worse for obfuscation, as a simple hook or script can intercept or fake-call it and fix the tables.

struct XteaInfo
{
    Value *xtea_key;
    Value *num_rounds;
    Value *delta;
};

void CryptoUtils::WriteXTEADecipher(IRBuilderBase &builder, XteaInfo &xtea_info,
                                    XteaOptions &, Value *value,
                                    AllocaInst *var_v0, AllocaInst *var_v1,
                                    AllocaInst *var_sum, AllocaInst *var_i)
{
    // ...
}

struct ZyroxTableEntryInfo
{
    uint32_t xtea_key[4];
    uint32_t delta;
    int nb_rounds;
};

struct ZyroxTable
{
    int table_id;
    std::vector<ZyroxTableEntryInfo> entries;
};
expand code

We create a new seed, used for xtea rounds and delta generation, a new table id and push it to the jump table list:

uint32_t seed = Random::UInt32();

CryptoUtils::ZyroxTable zyrox_table = {};
zyrox_table.table_id = CryptoUtils::GetUniqueZyroxTableId();

elems.push_back(ConstantExpr::getIntToPtr(
    ConstantInt::get(pint_ty, zyrox_table.table_id), block_address_ty));
expand code

now we generate per-block jump table entries with the xtea info.

remember when we added meta data to blocks in Part II? we can use that to generate encrypted paths that depend on runtime-updated dispatcher state.

#define IS_ARM32() (ptr_size == 4)
for (auto &[bb, seed] : target_bbs)
{
    // this will be added as a relocation entry!
    elems.push_back(BlockAddress::get(bb));

    ...

    if (auto block_state =
            BasicBlockUtils::GetMetaData(bb, "cff.block_state");
        block_state.has_value())
    {
        if (countBasicBlockUses(bb) == 1)
        {
            Logger::Info("using indirect dispatcher state");
            bb_delta =
                std::any_cast<uint64_t>(block_state.value()) & 0xFFFFFFFF;
        }
    }

    bb_index_map[bb] = zyrox_table.entries.size();
    zyrox_table.entries.push_back({ ... });

    if (IS_ARM32()) { elems.push_back(builder.getInt32(0)); }
}

CryptoUtils::AddZyroxTable(zyrox_table);

assert(elems.size() ==
        target_bbs.size() * (IS_ARM32() ? 2 : 1) + array_marker);
expand code

Encryption Tables

How can we distinguish our relocations from actual relocations though?

Easy! let's just add a marker:

constexpr int array_marker = 3 + 1; // 3 magic integers, 1 for array id

std::vector<Constant *> elems;
// for arm32 we will pad extra 4 bytes for xtea encryption.
elems.reserve(target_bbs.size() * (IS_ARM32() ? 2 : 1) + array_marker);
for (int i = 0; i < array_marker - 1; i++)
{
    elems.push_back(ConstantExpr::getIntToPtr(
        ConstantInt::get(pint_ty, 0xDEADBEEF), block_address_ty));
}

uint32_t seed = Random::UInt32();

CryptoUtils::ZyroxTable zyrox_table = {};
zyrox_table.table_id = CryptoUtils::GetUniqueZyroxTableId();

elems.push_back(ConstantExpr::getIntToPtr(
    ConstantInt::get(pint_ty, zyrox_table.table_id), block_address_ty));
expand code

Now encrytion code itself is too big, showing it in the blog would not be the best idea. You can check it on github if interested, for now I will show the final important part:

bool is_thumb_mode = f.hasFnAttribute("target-features") &&
                        f.getFnAttribute("target-features")
                            .getValueAsString()
                            .contains("+thumb-mode");

...

CryptoUtils::WriteXTEADecipher(builder, xtea_info, xtea_options, casted,
                                var_v0, var_v1, var_sum, var_i);

Value *decrypted_offset =
    builder.CreateLoad(u64_ty, temp_storage, true, "decrypted_offset");

if (IS_ARM32())
{
    Value *low32 = builder.CreateTrunc(decrypted_offset,
                                        builder.getInt32Ty(), "low32");
    decrypted_offset = builder.CreateZExt(low32, pint_ty);
}

DLOG("[DEBUG] Decrypted offset: 0x%lx\n", decrypted_offset);

DLOG("[DEBUG] Runtime base: %p\n", base_ptr);

Value *final_addr_int =
    builder.CreateAdd(base_int, decrypted_offset, "final_addr_int");

if (is_thumb_mode)
{
    final_addr_int =
        builder.CreateOr(final_addr_int, ConstantInt::get(pint_ty, 1),
                            "thumb_adjusted_addr");
}

Value *final_ptr = builder.CreateIntToPtr(
    final_addr_int, PointerType::getUnqual(builder.getInt8Ty()),
    "final_ptr");

DLOG("[DEBUG] Final target: %p\n", final_ptr);
expand code

zyrox_config.txt

Title can be better. I am not that creative when it come to words, you caught me!

When it comes to mad coding though, that's when my creativity shines ;)

Zyrox will emit a zyrox_tables.txt file, to be used with our python script:

void CryptoUtils::FinalizeZyroxTables()
{
    std::ofstream outfile("zyrox_tables.txt");
    if (!outfile.is_open())
    {
        Logger::Error("Error opening output file zyrox_tables.txt");
    }

    for (const auto &pair : zyrox_tables)
    {
        const ZyroxTable &table = pair.second;

        outfile << "@table " << table.table_id << "\n";

        for (const ZyroxTableEntryInfo &entry : table.entries)
        {
            outfile << entry.xtea_key[0] << " " << entry.xtea_key[1] << " "
                    << entry.xtea_key[2] << " " << entry.xtea_key[3] << " "
                    << entry.delta << " " << entry.nb_rounds << "\n";
        }
    }

    outfile.close();
}
expand code

it's output is something like:

@table 1
152785561 2073909935 2670664009 17123957 3769421649 1
2622454768 2936943218 176946232 1662730474 1538581829 1
...
expand code

Relocator Implementation: Python Script

The fun part! pum pum PUM!

we start by loading the obfuscated library:

is_android = args.android
in_file = args.in_file
out_file = args.out_file if args.out_file else in_file

zyrox_tables_info = parse_zyrox_tables(args.tables)

lib = ELFLoader(in_file)

relocations = lib.get_sections_by_type("SHT_RELA") + lib.get_sections_by_type("SHT_REL")

with open(in_file, "rb") as f:
    elf_data = bytearray(f.read())

ptr_size = lib.data_layout
is_32bit = ptr_size == 4
magic_bytes = int(0xDEADBEEF).to_bytes(4, byteorder="little")

if ptr_size > len(magic_bytes):
    magic_bytes += bytes(ptr_size - len(magic_bytes))  # pad to the right

SIG = magic_bytes * 3
header_size = len(SIG) + ptr_size * 1  # id

ptr_fmt = "<Q" if lib.data_layout == 8 else "<I"

rel_entry_size = ptr_size * (2 if is_32bit else 3)  # rel on arm32 and rela on arm64
expand code

and a helper:

def find_relocation_entry_offset(
    sig,
):  # sig is target address + value 8 padded with 7 bytes on 64-bit
    for rel in relocations:
        rel_data = elf_data[rel["offset"] : rel["size"] + rel["offset"]]
        _offset = 0
        while _offset + rel_entry_size <= len(rel_data):
            if (
                rel_data[
                    _offset : _offset + rel_entry_size - (0 if is_32bit else ptr_size)
                ]
                == sig
            ):
                return _offset + rel["offset"]
            _offset += rel_entry_size
    return None
expand code

now, the main logic.

At first, we go over every entry in the table (duh), read the info emitted by Zyrox then replace them with dummy data (with os.urandom) as they are not needed anymore. We also replace the table magic bytes by dummy data.

for zyrox_table in zyrox_tables:
    table_start = lib.offset_to_va(zyrox_table["table_start"])
    table_size = zyrox_table["size"]
    table_id = zyrox_table["id"]
    table_info = zyrox_tables_info[table_id]
    rel_offset = table_start - ptr_size
    offset = lib.va_to_offset(table_start - ptr_size)
    table_header = zyrox_table["sh_offset"]
    elf_data[table_header : table_header + header_size] = os.urandom(header_size)
    elf_data[offset : offset + ptr_size] = bytes(ptr_size)
    print("is_32bit:", is_32bit)
    if is_32bit:
        _r = range(0, table_size, 2)
    else:
        _r = range(table_size)
expand code

next, we go over every relocation entry and read the target and dest, that I talked about earlier:

rel_patch_set = False
index = 0
for i in _r:
    dest = i * ptr_size + table_start
    rel_entry_sig = dest.to_bytes(ptr_size, "little") + rel_sig.to_bytes(
        ptr_size, byteorder="little"
    )
    entry_relocation = find_relocation_entry_offset(rel_entry_sig)
    if entry_relocation is None:
        print(
            f"no relocation found for entry at file offset: 0x{lib.va_to_offset(dest):X}, va: 0x{dest:X}"
        )
        exit()
    # now we have relocation file offset >.<
    if is_32bit:
        target = read_ptr(lib.va_to_offset(dest))
    else:
        target = read_ptr(entry_relocation + ptr_size * 2)
expand code

we then encrypt target, and write it in dest. The obfuscator already replaced a goto A by goto decrypt(table[index]) + base.

block_info = table_info[index]
index += 1

key = block_info["key"]
delta = block_info["delta"]
rounds = block_info["rounds"]

mask = 0xFFFFFFFF

low = target & mask
high = (target >> 32) & mask

v0, v1 = xtea_encipher([low, high], key, rounds, delta)

new = ((v1 & mask) << 32) | (v0 & mask)

print("encrypted:", hex(new), "at:", hex(dest), "targeting:", hex(target))
print(f'    key: {" ".join([hex(i) for i in key])}')
print(f"    delta={hex(delta)}, rounds={rounds}")

dest_offset = lib.va_to_offset(dest)

# inject encrypted data in .data section
elf_data[dest_offset : dest_offset + ptr_size * (2 if is_32bit else 1)] = (
    new.to_bytes(8, "little")
)
expand code

Wait, but Peter, where is the base? We talked about it but still did not add it?

Right! and it is finally here, this code makes the linker write base address to rel_offset by pointing the relocation entry dest to it:

# patch relocation destination to 0x0, emit our debug encrypt info
if not is_32bit:
    elf_data[
        entry_relocation + ptr_size * 2 : entry_relocation + ptr_size * 3
    ] = bytes(
        ptr_size
    )  # patch relocator pointer
if not rel_patch_set:
    elf_data[entry_relocation : entry_relocation + ptr_size] = (
        rel_offset.to_bytes(ptr_size, byteorder="little")
    )
    rel_patch_set = True
else:
    elf_data[entry_relocation : entry_relocation + ptr_size] = (
        rel_offset - ptr_size
    ).to_bytes(ptr_size, byteorder="little")
expand code

More like this

You can check more blogs about Zyrox here.

And feel free to explore different blogs here.

... You can also check this page, which I wrote for a university project and fun.

© 2025 peterr[dot]dev. All rights reserved.