Three Ways to Protect Android Native Libraries - Function Encryption, Program Header Obfuscation, and a Custom ELF Format

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.

Here are three ways to protect an Android native library (.so) that I implemented over about two weeks. Working through them taught me a lot about ELF internals and the Android loader. The first two build on an earlier writeup on SO-encryption anti-analysis; the third — a custom ELF format plus a custom linker — is my own extension.

Before reading, it helps to be familiar with the ELF format and the Android SO loading flow.

1. Technique one: encrypting a function

We start from this source, compiled into libmathlib.so, and we want to protect int mymyadd(int a, int b):

#include <android/log.h>

#define LOG_TAG "MathLib"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)

extern "C"
int mymyadd(int a, int b) {
    int result = a + b;
    LOGD("Add: %d + %d = %d", a, b, result);
    return result;
}

extern "C"
int subtract(int a, int b) {
    int result = a - b;
    LOGD("Subtract: %d - %d = %d", a, b, result);
    return result;
}

1.1 Effect

Before encryption:

Before encryption

After encryption, IDA naturally can no longer recognize the function:

After encryption

1.2 Principle

To encrypt a function, you first need to locate it in the ELF file and know its size. The cleanest way is to mirror what the dynamic linker does when resolving dlsym.

dlsym() resolves a symbol through the GNU hash table (or the classic ELF hash table when GNU hash is absent). The linker’s lookup chain is dlsym() → __loader_dlsym() → dlsym_impl() → do_dlsym(), which ends at find_symbol_by_name():

// bionic/linker/linker_soinfo.cpp
const ElfW(Sym)* soinfo::find_symbol_by_name(SymbolName& symbol_name,
                                             const version_info* vi) const {
  return is_gnu_hash() ? gnu_lookup(symbol_name, vi) : elf_lookup(symbol_name, vi);
}

The GNU-hash variant:

// bionic/linker/linker_soinfo.cpp
const ElfW(Sym)* soinfo::gnu_lookup(SymbolName& symbol_name, const version_info* vi) const {
  const uint32_t hash = symbol_name.gnu_hash();

  constexpr uint32_t kBloomMaskBits = sizeof(ElfW(Addr)) * 8;
  const uint32_t word_num = (hash / kBloomMaskBits) & gnu_maskwords_;
  const ElfW(Addr) bloom_word = gnu_bloom_filter_[word_num];
  const uint32_t h1 = hash % kBloomMaskBits;
  const uint32_t h2 = (hash >> gnu_shift2_) % kBloomMaskBits;

  // test against bloom filter
  if ((1 & (bloom_word >> h1) & (bloom_word >> h2)) == 0) {
    return nullptr;
  }

  // bloom test says "probably yes"...
  uint32_t n = gnu_bucket_[hash % gnu_nbucket_];

  if (n == 0) {
    return nullptr;
  }

  const ElfW(Versym) verneed = find_verdef_version_index(this, vi);
  const ElfW(Versym)* versym = get_versym_table();

  do {
    ElfW(Sym)* s = symtab_ + n;
    if (((gnu_chain_[n] ^ hash) >> 1) == 0 &&
        check_symbol_version(versym, n, verneed) &&
        strcmp(get_string(s->st_name), symbol_name.get_name()) == 0 &&
        is_symbol_global_and_defined(this, s)) {
      return symtab_ + n;
    }
  } while ((gnu_chain_[n++] & 1) == 0);

  return nullptr;
}

The GNU hash of a symbol name is straightforward to compute:

static uint32_t gnu_hash(const char *s0)
{
	const unsigned char *s = (void *)s0;
	uint_fast32_t h = 5381;
	for (; *s; s++)
		h += h*32 + *s;
	return h;
}

The rest of the metadata (gnu_maskwords_, gnu_bloom_filter_, symtab_, etc.) comes from the dynamic section. The linker walks the program header table to find PT_DYNAMIC, then reads the DT_GNU_HASH, DT_SYMTAB, DT_STRTAB, and DT_STRSZ entries:

// bionic/linker/linker.cpp
case DT_GNU_HASH:
        gnu_nbucket_ = reinterpret_cast<uint32_t*>(load_bias + d->d_un.d_ptr)[0];
        // skip symndx
        gnu_maskwords_ = reinterpret_cast<uint32_t*>(load_bias + d->d_un.d_ptr)[2];
        gnu_shift2_ = reinterpret_cast<uint32_t*>(load_bias + d->d_un.d_ptr)[3];

        gnu_bloom_filter_ = reinterpret_cast<ElfW(Addr)*>(load_bias + d->d_un.d_ptr + 16);
        gnu_bucket_ = reinterpret_cast<uint32_t*>(gnu_bloom_filter_ + gnu_maskwords_);
        // amend chain for symndx = header[1]
        gnu_chain_ = gnu_bucket_ + gnu_nbucket_ -
            reinterpret_cast<uint32_t*>(load_bias + d->d_un.d_ptr)[1];

        if (!powerof2(gnu_maskwords_)) {
          DL_ERR("invalid maskwords for gnu_hash = 0x%x, in \"%s\" expecting power to two",
              gnu_maskwords_, get_realpath());
          return false;
        }
        --gnu_maskwords_;

        flags_ |= FLAG_GNU_HASH;
        break;

1.3 Implementation

With the symbol located, we can encrypt the function bytes in place. harden_handle reads the ELF header, program headers, and dynamic section, resolves the target symbol through GNU hash, then RC4-encrypts FUNC_SIZE bytes at the symbol’s st_value:

int harden_handle(char *src_path, char *name, char *dst_path, int mode) {
    Elf64_Ehdr header_;
    Elf64_Phdr phdr_;
    Elf64_Dyn dyn_;
    int dyn_off;
    int dyn_size;
    int dyn_count;
    Elf64_Addr dyn_symtab;
    Elf64_Addr dyn_strtab;
    Elf64_Addr dyn_gnuhash;
    int dyn_strsz;
    uint32_t symndex;
    uint32_t gnu_nbucket_;
    uint32_t gnu_maskwords_;
    uint32_t gnu_shift2_;
    Elf64_Addr *gnu_bloom_filter_;
    uint32_t* gnu_bucket_;
    uint32_t* gnu_chain_;
    bool has_gnu_hash = false;

    int fd = open(src_path, O_RDONLY);
    if (fd == -1) {
        LOGE("error opening source file");
        return -1;
    }

    // read ELF header
    int ret = read(fd, &header_, sizeof(header_));
    if (ret < 0) {
        LOGE("error read file!");
    }

    // read program headers, find PT_DYNAMIC
    lseek(fd, header_.e_phoff, SEEK_SET);
    for (int i = 0; i < header_.e_phnum; i++) {
        ret = read(fd, &phdr_, sizeof(phdr_));
        if (ret < 0) {
            LOGE("error read file!");
        }

        if (phdr_.p_type != PT_DYNAMIC) {
            continue;
        }
        dyn_off = phdr_.p_offset;
        dyn_size = phdr_.p_filesz;
        dyn_count = phdr_.p_memsz / (8 * 2);
    }

    // read dynamic section
    lseek(fd, dyn_off, SEEK_SET);
    for (int i = 0; i < dyn_count; i++) {
        ret = read(fd, &dyn_, sizeof(dyn_));
        if (ret < 0) {
            LOGI("error read file!");
        }

        switch (dyn_.d_tag) {
            case DT_GNU_HASH:
                dyn_gnuhash = dyn_.d_un.d_ptr;
                break;
            case DT_SYMTAB:
                dyn_symtab = dyn_.d_un.d_ptr;
                break;
            case DT_STRTAB:
                dyn_strtab = dyn_.d_un.d_ptr;
                break;
            case DT_STRSZ:
                dyn_strsz = dyn_.d_un.d_val;
                break;
        }
    }

    char *dynstr = (char*) malloc(dyn_strsz);
    if (dynstr == NULL) {
        LOGE("malloc failed");
    }

    lseek(fd, dyn_strtab, SEEK_SET);
    ret = read(fd, dynstr, dyn_strsz);
    if (ret < 0) {
        LOGE("read .dynstr failed");
    }

    // GNU hash bucket lookup
    gnu_nbucket_ = dyn_gnuhash[0];
    uint32_t symndx = dyn_gnuhash[1];
    gnu_maskwords_ = dyn_gnuhash[2];
    gnu_shift2_ = dyn_gnuhash[3];

    gnu_bloom_filter_ = reinterpret_cast<Elf64_Addr *>(dyn_gnuhash + 16);
    gnu_bucket_ = reinterpret_cast<uint32_t*>(gnu_bloom_filter_ + gnu_maskwords_);
    gnu_chain_ = gnu_bucket_ + gnu_nbucket_ - symndx;

    uint32_t hash = gnu_hash(name);
    uint32_t h2 = hash >> gnu_shift2_;
    uint32_t bloom_mask_bits = sizeof(Elf64_Addr) * 8;
    uint32_t word_num = (hash / bloom_mask_bits) & gnu_maskwords_;

    uint32_t val = hash % gnu_nbucket_;

    uint32_t n = gnu_bucket_[hash % gnu_nbucket_];
    if (n == 0) {
        printf("symbol '%s' hash bucket is empty\n", name);
        return false;
    }

    Elf64_Sym s;
    uint32_t chain;
    do {
        lseek(fd, dyn_symtab + n * sizeof(Elf64_Sym), SEEK_SET);
        ret = read(fd, &s, sizeof(Elf64_Sym));
        if (ret < 0) {
            LOGI("read gnuhash failed");
        }
        LOGI("name = %d %s", s.st_name, dynstr + s.st_name);

        lseek(fd, reinterpret_cast<uint64_t>(gnu_chain_ + n), SEEK_SET);
        ret = read(fd, &chain, sizeof(chain));
        if (ret < 0) {
            LOGI("read gnuhash failed");
        }

        if (((chain ^ hash) >> 1) == 0 && strcmp(dynstr + s.st_name, name) == 0) {
            LOGI("found function(%s) at %p(%zd)", name, reinterpret_cast<void*>(s.st_value), static_cast<size_t>(s.st_size));
            break;
        }

        n++;
        lseek(fd, reinterpret_cast<uint64_t>(gnu_chain_ + n), SEEK_SET);
        ret = read(fd, &chain, sizeof(chain));
        if (ret < 0) {
            LOGI("read gnuhash failed");
        }
    } while ((chain & 1) == 0);

    uint32_t size = get_file_size(src_path);
    char *file_buf = (char *)malloc(size);
    if (file_buf == NULL) {
        LOGI("file buf malloc failed");
    }

    lseek(fd, 0, SEEK_SET);
    ret = read(fd, file_buf, size);
    if (ret < 0) {
        LOGI("read file buf failed");
    }

    close(fd);

    // modify and save the so
    char save_path[128] = {0};
    fd = open(dst_path, O_RDWR | O_CREAT, 0644);
    if (fd == -1) {
        LOGI("error opening file, %s", dst_path);
        return -1;
    }
    char *encrypt_buf = (char *)malloc(s.st_size);

    encrypt((unsigned char *) RC4_KEY, reinterpret_cast<unsigned char *>(encrypt_buf),
            reinterpret_cast<unsigned char *>(&file_buf[s.st_value]), FUNC_SIZE);
    memcpy(&file_buf[s.st_value], encrypt_buf, FUNC_SIZE);

    if (mode == MODE_DUPLEX) {
        for (int i = 0; i < header_.e_phnum; i++) {
            file_buf[header_.e_phoff + i * sizeof(phdr_)]     = file_buf[header_.e_phoff + i * sizeof(phdr_)]     ^ XOR_MAGIC;
            file_buf[header_.e_phoff + i * sizeof(phdr_) + 1] = file_buf[header_.e_phoff + i * sizeof(phdr_) + 1] ^ XOR_MAGIC;
            file_buf[header_.e_phoff + i * sizeof(phdr_) + 2] = file_buf[header_.e_phoff + i * sizeof(phdr_) + 2] ^ XOR_MAGIC;
            file_buf[header_.e_phoff + i * sizeof(phdr_) + 3] = file_buf[header_.e_phoff + i * sizeof(phdr_) + 3] ^ XOR_MAGIC;
        }
    }

    write(fd, file_buf, size);
    free(encrypt_buf);
    close(fd);

    return 0;
}

Note the original author hardcoded FUNC_SIZE. If you don’t want that, you have to parse the ELF dynamic section yourself to walk the symbol table and recover each function’s size — the same work the encryptor already does.

1.4 Runtime call flow

At runtime, the function is decrypted on demand, executed, then re-encrypted:

  1. dlopen the library and dlsym the function address.
  2. Change memory permissions, decrypt over the function range, restore permissions.
  3. Call the function.
  4. Change permissions, re-encrypt over the function range, restore permissions.
extern "C"
JNIEXPORT void JNICALL
Java_com_example_mylinkerwith21_MainActivity_loadSO(JNIEnv *env, jobject thiz,
                                                    jstring jEncryptedFileDirectoryPath) {
    void *lib;
    uint64_t start;
    uint64_t end;
    ssize_t page;
    FUNC lib_func = NULL;
    RC4_CTX ctx;
    char buf[512] = {0};
    int result;

    LOGI("loadSO called!");

    const char *encryptedFileDirectoryPath = env->GetStringUTFChars(jEncryptedFileDirectoryPath, nullptr);
    if (encryptedFileDirectoryPath == nullptr) {
        LOGE("Failed to get string characters for encrypted file directory path");
        return;
    }

    const char* encryptedFileName = "libmathlib_encrypt.so";
    size_t requiredPathSize = strlen(encryptedFileDirectoryPath) + 1 + strlen(encryptedFileName) + 1;
    char* encryptedLoadPath = (char*)malloc(requiredPathSize);
    if (encryptedLoadPath == nullptr) {
        LOGE("Failed to allocate memory for load path");
        env->ReleaseStringUTFChars(jEncryptedFileDirectoryPath, encryptedFileDirectoryPath);
        return;
    }

    sprintf(encryptedLoadPath, "%s/%s", encryptedFileDirectoryPath, encryptedFileName);
    LOGI("Loading from path: %s", encryptedLoadPath);

    lib = dlopen(encryptedLoadPath, RTLD_LAZY);
    free(encryptedLoadPath);
    env->ReleaseStringUTFChars(jEncryptedFileDirectoryPath, encryptedFileDirectoryPath);

    if (lib == NULL) {
        LOGE("%s dlopen failed: %s\n", encryptedFileName, dlerror());
        return;
    }

    lib_func = reinterpret_cast<FUNC>(dlsym(lib, "mymyadd"));
    if (!lib_func) {
        LOGI("can't find module symbol 'mymyadd': %s\n", dlerror());
        dlclose(lib);
        return;
    }

    LOGI("loadso lib_func = %p", lib_func);

    parse_maps_for_lib("libmathlib_encrypt.so", &start, &end);
    if (start == 0 && end == 0) {
        LOGI("Failed to find memory map for libmathlib_encrypt.so");
        dlclose(lib);
        return;
    }

    page = end - start;
    // mprotect requires page-aligned address and size
    if (mprotect((void*)start, page, PROT_READ | PROT_WRITE | PROT_EXEC) == -1) {
        LOGI("mprotect failed: %s", strerror(errno));
        dlclose(lib);
        return;
    }

    // decrypt and overwrite
    rc4_init(&ctx, (unsigned char *) RC4_KEY, strlen(reinterpret_cast<const char *>(RC4_KEY)));
    rc4_run(&ctx, reinterpret_cast<unsigned char *>(buf), reinterpret_cast<unsigned char *>(lib_func), FUNC_SIZE);
    memcpy((void*)lib_func, buf, FUNC_SIZE);

    // restore permissions
    if (mprotect((void*)start, page, PROT_READ | PROT_EXEC) == -1) {
        LOGI("mprotect restore failed: %s", strerror(errno));
    }

    // call the function
    result = lib_func(1, 2);
    LOGI("loadso add result = %d", result);

    // re-encrypt
    if (mprotect((void*)start, page, PROT_READ | PROT_WRITE | PROT_EXEC) == -1) {
        LOGI("mprotect re-encrypt failed: %s", strerror(errno));
    } else {
        rc4_init(&ctx, (unsigned char *) RC4_KEY, strlen(reinterpret_cast<const char *>(RC4_KEY)));
        rc4_run(&ctx, reinterpret_cast<unsigned char *>(buf), reinterpret_cast<unsigned char *>(lib_func), FUNC_SIZE);
        memcpy((void*)lib_func, buf, FUNC_SIZE);

        if (mprotect((void*)start, page, PROT_READ | PROT_EXEC) == -1) {
            LOGI("mprotect restore after re-encrypt failed: %s", strerror(errno));
        }
    }

    dlclose(lib);
}

2. Technique two: XOR the program-header type

2.1 Effect

XOR program header type

2.2 Principle

On top of technique one, each program header’s p_type is XORed with a magic constant. The code is the MODE_DUPLEX branch already shown above.

The catch: this only modifies the program header table, not the section header table, so IDA can still open the file and recover most information statically through the section headers. That’s the motivation for technique three.

3. Technique three: a custom ELF format and a custom linker

3.1 Effect

A file packed this way is unparseable by IDA:

IDA can’t parse the custom format

Left is the standard unencrypted so; right is the custom-format, RC4-encrypted so:

Custom format, encrypted

And after RC4 decryption:

Custom format, decrypted

3.2 Implementation

The custom file header doesn’t have to match the standard ELF header. I kept it mostly identical for convenience — in practice you can reorder fields, compute offsets on the fly, or drop unused parts entirely. The magic number is replaced with .csf, and since we implement the linker ourselves, there’s no external validation to satisfy.

The packing logic (do_pack) copies the original file, writes a custom file header at the front, RC4-encrypts the ELF header and program header table into a trailing area, RC4-encrypts every PT_LOAD segment in place, and zeroes out the original header and program header table. The unpacker (unpack / restore_elf) reverses it:

#pragma pack(push, 1)
typedef struct custom_elf64_file{
    Elf64_Off   elf_header_off;
    Elf64_Half  elf_header_size;
    Elf64_Off   elf_program_header_table_off;
    Elf64_Half  elf_program_header_table_num;
    Elf64_Half  elf_program_header_table_size;
} Custom_Elf64_File;
#pragma pack(pop)

typedef struct
{
    unsigned char   e_ident[EI_NIDENT];
    Elf64_Half  e_type;
    Elf64_Half  e_machine;
    Elf64_Word  e_version;
    Elf64_Addr  e_entry;
    Elf64_Off   e_phoff;
    Elf64_Off   e_shoff;
    Elf64_Word  e_flags;
    Elf64_Half  e_ehsize;
    Elf64_Half  e_phentsize;
    Elf64_Half  e_phnum;
    Elf64_Half  e_shentsize;
    Elf64_Half  e_shnum;
    Elf64_Half  e_shstrndx;
} Custom_Elf64_Ehdr;

int do_pack(char *inputfile_buffer, size_t inputfile_size, char *outfile_buffer, size_t outfile_size)
{
    if (NULL == inputfile_buffer || 0 == inputfile_size || NULL == outfile_buffer) {
        return -1;
    }

    Elf64_Ehdr* orig_ehdr = (Elf64_Ehdr *)inputfile_buffer;

    if (memcmp(orig_ehdr->e_ident, ELFMAG, SELFMAG) != 0) {
        return -2;
    }

    size_t phdr_table_size = orig_ehdr->e_phnum * orig_ehdr->e_phentsize;
    size_t enc_ehdr_offset = inputfile_size;
    size_t enc_phdr_offset = enc_ehdr_offset + orig_ehdr->e_ehsize;
    size_t final_size = enc_phdr_offset + phdr_table_size;

    if (final_size > outfile_size) {
        return -3;
    }

    memcpy(outfile_buffer, inputfile_buffer, inputfile_size);

    // build the custom file header
    Custom_Elf64_File custom_file = {0};
    custom_file.elf_header_off = enc_ehdr_offset;
    custom_file.elf_header_size = orig_ehdr->e_ehsize;
    custom_file.elf_program_header_table_off = enc_phdr_offset;
    custom_file.elf_program_header_table_num = orig_ehdr->e_phnum;
    custom_file.elf_program_header_table_size = phdr_table_size;

    Custom_Elf64_Ehdr custom_ehdr = {0};
    memcpy(&custom_ehdr, orig_ehdr, sizeof(Elf64_Ehdr));

    // change the magic number, keep the rest of e_ident
    memcpy(custom_ehdr.e_ident, ".csf", 4);

    // encrypt and append the modified ELF header
    encrypt((unsigned char *)RC4_KEY, RC4_KEY_LEN,
            reinterpret_cast<unsigned char *>(outfile_buffer + enc_ehdr_offset),
            reinterpret_cast<unsigned char *>(&custom_ehdr),
            orig_ehdr->e_ehsize);

    // encrypt and append the program header table
    encrypt((unsigned char *)RC4_KEY, RC4_KEY_LEN,
            reinterpret_cast<unsigned char *>(outfile_buffer + enc_phdr_offset),
            reinterpret_cast<unsigned char *>(inputfile_buffer + orig_ehdr->e_phoff),
            phdr_table_size);

    // encrypt every PT_LOAD segment in place
    Elf64_Phdr *phdr = (Elf64_Phdr *)(inputfile_buffer + orig_ehdr->e_phoff);
    for (int i = 0; i < orig_ehdr->e_phnum; i++) {
        if (phdr[i].p_type == PT_LOAD) {
            if (phdr[i].p_offset + phdr[i].p_filesz > inputfile_size) {
                return -4;
            }
            LOGI("PT_LOAD #%d offset=%llu size=%llu", i, phdr[i].p_offset, phdr[i].p_filesz);

            encrypt((unsigned char *)RC4_KEY, RC4_KEY_LEN,
                    reinterpret_cast<unsigned char *>(outfile_buffer + phdr[i].p_offset),
                    reinterpret_cast<unsigned char *>(outfile_buffer + phdr[i].p_offset),
                    phdr[i].p_filesz);
        }
    }

    // zero the original ELF header and program header table
    memset(outfile_buffer, 0, sizeof(Elf64_Ehdr));
    memset(outfile_buffer + orig_ehdr->e_phoff, 0, phdr_table_size);

    // write the custom file header at the front
    memcpy(outfile_buffer, &custom_file, sizeof(Custom_Elf64_File));

    return 0;
}

int unpack(char *elf_pack_data, size_t file_size, Custom_Elf64_File my_elf64_file)
{
    if (!elf_pack_data) {
        return -1;
    }

    if (my_elf64_file.elf_header_size == 0 ||
        my_elf64_file.elf_program_header_table_size == 0 ||
        my_elf64_file.elf_program_header_table_num == 0) {
        return -2;
    }

    if (my_elf64_file.elf_header_off >= file_size ||
        my_elf64_file.elf_program_header_table_off >= file_size) {
        return -3;
    }

    Custom_Elf64_File original_custom_file;
    memcpy(&original_custom_file, elf_pack_data, sizeof(Custom_Elf64_File));

    char *decrypted_ehdr = (char *)malloc(my_elf64_file.elf_header_size);
    if (!decrypted_ehdr) {
        return -4;
    }

    char *decrypted_phdr = (char *)malloc(my_elf64_file.elf_program_header_table_size);
    if (!decrypted_phdr) {
        free(decrypted_ehdr);
        return -5;
    }

    // decrypt the ELF header
    decrypt((unsigned char *)RC4_KEY, RC4_KEY_LEN,
            reinterpret_cast<unsigned char *>(decrypted_ehdr),
            reinterpret_cast<unsigned char *>(elf_pack_data + my_elf64_file.elf_header_off),
            my_elf64_file.elf_header_size);

    Elf64_Ehdr *decrypted_elf_header = (Elf64_Ehdr *)decrypted_ehdr;
    if (memcmp(decrypted_elf_header->e_ident, ".csf", 4) != 0) {
        free(decrypted_ehdr);
        free(decrypted_phdr);
        return -6;
    }

    // decrypt the program header table
    decrypt((unsigned char *)RC4_KEY, RC4_KEY_LEN,
            reinterpret_cast<unsigned char *>(decrypted_phdr),
            reinterpret_cast<unsigned char *>(elf_pack_data + my_elf64_file.elf_program_header_table_off),
            my_elf64_file.elf_program_header_table_size);

    Elf64_Phdr *decrypted_ph_table = (Elf64_Phdr *)decrypted_phdr;

    if (decrypted_elf_header->e_phnum != my_elf64_file.elf_program_header_table_num) {
        free(decrypted_ehdr);
        free(decrypted_phdr);
        return -7;
    }

    for (int i = 0; i < my_elf64_file.elf_program_header_table_num; i++) {
        Elf64_Phdr *current_phdr = &decrypted_ph_table[i];
        if (current_phdr->p_type == PT_LOAD) {
            if (current_phdr->p_offset + current_phdr->p_filesz > file_size) {
                free(decrypted_ehdr);
                free(decrypted_phdr);
                return -8;
            }
        }
    }

    // decrypt all LOAD segments
    for (int i = 0; i < my_elf64_file.elf_program_header_table_num; i++) {
        Elf64_Phdr *current_phdr = &decrypted_ph_table[i];
        if (current_phdr->p_type == PT_LOAD && current_phdr->p_filesz > 0) {
            decrypt((unsigned char *)RC4_KEY, RC4_KEY_LEN,
                    reinterpret_cast<unsigned char *>(elf_pack_data + current_phdr->p_offset),
                    reinterpret_cast<unsigned char *>(elf_pack_data + current_phdr->p_offset),
                    current_phdr->p_filesz);
        }
    }

    // store the decrypted header and program header table back for restore
    memcpy(elf_pack_data + my_elf64_file.elf_header_off, decrypted_ehdr, my_elf64_file.elf_header_size);
    memcpy(elf_pack_data + my_elf64_file.elf_program_header_table_off, decrypted_phdr, my_elf64_file.elf_program_header_table_size);

    // restore the original custom file header
    memcpy(elf_pack_data, &original_custom_file, sizeof(Custom_Elf64_File));

    free(decrypted_ehdr);
    free(decrypted_phdr);

    return 0;
}

int restore_elf(char *decrypted_data, size_t file_size, Custom_Elf64_File my_elf64_file,
                char **restored_data, size_t *restored_size)
{
    if (!decrypted_data || !restored_data || !restored_size) {
        return -1;
    }

    Elf64_Ehdr *decrypted_elf_header = (Elf64_Ehdr *)(decrypted_data + my_elf64_file.elf_header_off);
    Elf64_Phdr *decrypted_phdr_table = (Elf64_Phdr *)(decrypted_data + my_elf64_file.elf_program_header_table_off);

    if (memcmp(decrypted_elf_header->e_ident, ".csf", 4) != 0) {
        return -2;
    }

    // compute the restored file size from segment and section table ends
    size_t max_segment_end = 0;
    for (int i = 0; i < my_elf64_file.elf_program_header_table_num; i++) {
        Elf64_Phdr *current_phdr = &decrypted_phdr_table[i];
        size_t segment_end = current_phdr->p_offset + current_phdr->p_filesz;
        if (segment_end > max_segment_end) {
            max_segment_end = segment_end;
        }
    }

    size_t section_table_end = 0;
    if (decrypted_elf_header->e_shoff > 0 && decrypted_elf_header->e_shnum > 0) {
        section_table_end = decrypted_elf_header->e_shoff +
                            decrypted_elf_header->e_shnum * decrypted_elf_header->e_shentsize;
    }

    size_t new_file_size = (max_segment_end > section_table_end) ? max_segment_end : section_table_end;

    size_t min_size = decrypted_elf_header->e_phoff +
                      decrypted_elf_header->e_phnum * decrypted_elf_header->e_phentsize;
    if (new_file_size < min_size) {
        new_file_size = min_size;
    }

    if (new_file_size > my_elf64_file.elf_header_off && my_elf64_file.elf_header_off > min_size) {
        new_file_size = my_elf64_file.elf_header_off;
    }

    *restored_data = (char *)malloc(new_file_size);
    if (!*restored_data) {
        return -3;
    }

    memcpy(*restored_data, decrypted_data, new_file_size);

    Elf64_Ehdr restored_ehdr = {0};
    memcpy(&restored_ehdr, decrypted_elf_header, sizeof(Elf64_Ehdr));

    // restore the standard ELF magic
    memcpy(restored_ehdr.e_ident, ELFMAG, SELFMAG);

    memcpy(*restored_data, &restored_ehdr, sizeof(Elf64_Ehdr));

    Elf64_Off phdr_restore_offset = restored_ehdr.e_phoff;
    if (phdr_restore_offset < sizeof(Elf64_Ehdr) || phdr_restore_offset >= new_file_size) {
        phdr_restore_offset = sizeof(Elf64_Ehdr);
        ((Elf64_Ehdr*)(*restored_data))->e_phoff = phdr_restore_offset;
    }

    size_t phdr_table_size = decrypted_elf_header->e_phnum * decrypted_elf_header->e_phentsize;
    if (phdr_restore_offset + phdr_table_size > new_file_size) {
        free(*restored_data);
        *restored_data = NULL;
        *restored_size = 0;
        return -4;
    }

    memcpy(*restored_data + phdr_restore_offset, decrypted_phdr_table, phdr_table_size);

    *restored_size = new_file_size;

    Elf64_Ehdr *final_ehdr = (Elf64_Ehdr *)(*restored_data);
    if (memcmp(final_ehdr->e_ident, ELFMAG, SELFMAG) != 0) {
        free(*restored_data);
        *restored_data = NULL;
        *restored_size = 0;
        return -5;
    }

    return 0;
}

At this point you have two choices: restore to a standard ELF and load it, or load the custom format directly. I chose the latter. If you’re learning a custom-format loader, it’s easier to first restore to a standard ELF, get that loading, then adapt.

3.3 Principle: a custom loader, following the Android SO flow

The custom loader mirrors the Android linker’s ElfReader flow, with a few deliberate changes.

The key insight is that the custom format’s ELF header lives at the end of the file, not offset 0. DIY_Read keeps file_offset_ at 0 (so later memory mapping lines up) and stashes the custom header offset in origin_file_size_:

bool ElfReader::DIY_Read(const char* name, int fd, off64_t file_offset, off64_t file_size) {
    if (did_read_) {
        return true;
    }
    name_ = name;
    fd_ = fd;
    file_offset_ = 0;
    file_size_ = file_size;
    origin_file_size_ = file_offset;

    if (DIY_ReadElfHeader() &&
        DIY_VerifyElfHeader() &&
        ReadProgramHeaders() &&
        ReadSectionHeaders() &&
        DIY_ReadDynamicSection()) {
        did_read_ = true;
    }

    return did_read_;
}

DIY_ReadElfHeader then uses that saved offset to read the header from the right place:

bool ElfReader::DIY_ReadElfHeader() {
    LOGI("enter DIY_ReadElfHeader");
    ssize_t rc = TEMP_FAILURE_RETRY(pread64(fd_, &header_, sizeof(header_), origin_file_size_));
    if (rc < 0) {
        LOGE("can't read file \"%s\": %s", name_.c_str(), strerror(errno));
        return false;
    }
    if (rc != sizeof(header_)) {
        LOGE("\"%s\" is too small to be an ELF executable: only found %zd bytes", name_.c_str(),
               static_cast<size_t>(rc));
        return false;
    }
    return true;
}

Header verification is the same as the original except the magic is .csf (and could be skipped entirely). Program-header and section-header reads are unchanged from AOSP, since the custom file’s offsets still point at the original tables.

The one genuinely tricky spot is FindPhdr(). When a PT_PHDR segment exists, its p_vaddr/p_offset describe the original program header table location, so si_->phdr = elfreader->loaded_phdr() ends up pointing at the wrong memory. My fix is to detect the custom format and set loaded_phdr_ directly:

bool ElfReader::FindPhdr() {
  const ElfW(Phdr)* phdr_limit = phdr_table_ + phdr_num_;
  if (memcmp(header_.e_ident, reinterpret_cast<const void *>(DIY_ELFMAG), SELFMAG) == 0) {
    loaded_phdr_ = phdr_table_;
    return true;
  }

  // If there is a PT_PHDR, use it directly.
  for (const ElfW(Phdr)* phdr = phdr_table_; phdr < phdr_limit; ++phdr) {
    if (phdr->p_type == PT_PHDR) {
      return CheckPhdr(load_bias_ + phdr->p_vaddr);
    }
  }

  // Otherwise, check the first loadable segment.
  for (const ElfW(Phdr)* phdr = phdr_table_; phdr < phdr_limit; ++phdr) {
    if (phdr->p_type == PT_LOAD) {
      if (phdr->p_offset == 0) {
        ElfW(Addr)  elf_addr = load_bias_ + phdr->p_vaddr;
        const ElfW(Ehdr)* ehdr = reinterpret_cast<const ElfW(Ehdr)*>(elf_addr);
        ElfW(Addr)  offset = ehdr->e_phoff;
        return CheckPhdr(reinterpret_cast<ElfW(Addr)>(ehdr) + offset);
      }
      break;
    }
  }

  DL_ERR("can't find loaded phdr for \"%s\"", name_.c_str());
  return false;
}

3.4 The shell-and-swap trick

The last step reuses the system’s soinfo machinery. We dlopen an empty shell so, resolve its soinfo, then overwrite its fields with our custom ELF’s data — a “borrow the shell, swap the body” approach. The catch is that soinfo_from_handle is not exported, so we have to implement it.

It only needs one unknown global, g_soinfo_handles_map, so we parse the linker64 ELF to resolve that symbol’s offset (this makes it robust across Android versions):

uintptr_t get_soinfo_handles_map_offset(uint64_t start, uint64_t end) {
    int fd = open("/system/bin/linker64", O_RDONLY);
    if (fd < 0) {
        LOGI("Failed to open linker64: %s", strerror(errno));
        return 0;
    }

    struct stat sb;
    fstat(fd, &sb);
    void* file_start = mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    if (file_start == MAP_FAILED) {
        LOGI("mmap failed: %s", strerror(errno));
        close(fd);
        return 0;
    }

    Elf64_Ehdr header;
    memcpy(&header, file_start, sizeof(Elf64_Ehdr));
    int secoff = header.e_shoff;
    int secsize = header.e_shentsize;
    int secnum = header.e_shnum;
    int secstr = header.e_shstrndx;

    Elf64_Shdr strtab;
    memcpy(&strtab, (char*)file_start + secoff + secstr * secsize, sizeof(Elf64_Shdr));
    int strtaboff = strtab.sh_offset;
    char* strtabchar = new char[strtab.sh_size];
    memcpy(strtabchar, (char*)file_start + strtaboff, strtab.sh_size);

    Elf64_Shdr enumsec;
    int symtab_off = 0, symtab_size = 0;
    int strtab_off = 0, strtab_size = 0;

    for (int n = 0; n < secnum; n++) {
        memcpy(&enumsec, (char*)file_start + secoff + n * secsize, sizeof(Elf64_Shdr));
        if (strcmp(&strtabchar[enumsec.sh_name], ".symtab") == 0) {
            symtab_off = enumsec.sh_offset;
            symtab_size = enumsec.sh_size;
            LOGI("Found .symtab: offset=0x%x, size=%d", symtab_off, symtab_size);
        }
        if (strcmp(&strtabchar[enumsec.sh_name], ".strtab") == 0) {
            strtab_off = enumsec.sh_offset;
            strtab_size = enumsec.sh_size;
            LOGI("Found .strtab: offset=0x%x, size=%d", strtab_off, strtab_size);
        }
    }

    delete[] strtabchar;

    if (symtab_off == 0 || strtab_off == 0) {
        LOGI("Required sections not found");
        munmap(file_start, sb.st_size);
        close(fd);
        return 0;
    }

    uintptr_t symbol_offset = 0;
    char* symbol_strtab = new char[strtab_size];
    memcpy(symbol_strtab, (char*)file_start + strtab_off, strtab_size);

    for (int n = 0; n < symtab_size; n += sizeof(Elf64_Sym)) {
        Elf64_Sym sym;
        memcpy(&sym, (char*)file_start + symtab_off + n, sizeof(Elf64_Sym));

        if (sym.st_name != 0) {
            const char* name = symbol_strtab + sym.st_name;
            if (strstr(name, "soinfo_handles_map")) {
                symbol_offset = sym.st_value;
                LOGI("Found symbol %s: offset=0x%lx", name, symbol_offset);
                break;
            }
        }
    }

    delete[] symbol_strtab;
    munmap(file_start, sb.st_size);
    close(fd);

    return symbol_offset;
}

Then resolve the shell’s soinfo and fill it with our data:

FILE *fp = fopen("/proc/self/maps", "r");
if (!fp) {
    LOGI("Failed to open /proc/self/maps");
    close(fd);
    return;
}

parse_maps_for_lib("linker64", &start, &end);
LOGI("linker64 start = 0x%lx end = 0x%lx", start, end);
uintptr_t sym_offset = get_soinfo_handles_map_offset(start, end);
if (sym_offset == 0) {
    LOGE("Failed to get offset");
    return;
} else {
    LOGI("soinfo_handles_map offset = 0x%lx", sym_offset);
}

soinfo_handles_map = reinterpret_cast<std::unordered_map<uintptr_t, soinfo*>*>((char*)start + sym_offset);
if (soinfo_handles_map == nullptr) {
    LOGI("failed to get soinfo_handles_map");
    return;
}

void *handle = dlopen(path_fake, RTLD_LAZY);  // open the shell so, get its soinfo
if (handle == NULL) {
    LOGI("%s dlopen failed: %s\n", path_fake, dlerror());
    fclose(fp);
    close(fd);
    return;
}

si_ = soinfo_from_handle(soinfo_handles_map, static_cast<std::unordered_map<uintptr_t, soinfo*>*>(handle));
LOGI("si_ %p\n", si_);

// backfill the soinfo with our custom ELF's data
si_->base = elfreader->load_start();
si_->size = elfreader->load_size();
si_->load_bias = elfreader->load_bias();
si_->phnum = elfreader->phdr_count();
si_->phdr = elfreader->loaded_phdr();

The pre-link and link steps after this are the same as the original flow. Here’s the loader’s log output:

Loader log

3.5 Verifying in the soinfo list

We use libjoke.so as the shell, so we look for it in the soinfo list via Frida. The upper half shows the soinfo-list traversal; the lower half is the first 64 bytes of the file — exactly the RC4-decrypted data from section 3.1:

soinfo list

Summary

These three techniques compose — they aren’t mutually exclusive. Once you’ve gone to the trouble of a custom linker, the protected so can also live somewhere less obvious. I left call_constructors unimplemented; that’s a natural extension if you want to try it.

The full code isn’t pasted here in its entirety — it’s littered with debug output from the development process, and it’s messy. The interesting parts are the three techniques above.

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