A First Look at Android CRC Detection and Bypass

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.

A First Look at Android CRC Detection and Bypass

1. What CRC detection actually is

Before we start, let’s pin down what CRC detection means. In plain terms: the app computes a CRC over data read from a local file, computes a CRC over the same data as it lives in memory, and compares the two. If they don’t match, the app concludes the data has been tampered with.

A concrete example: take libc.so as the target. The first time I inject Frida in spawn mode and hook it, as long as I haven’t actually modified any function in libc.so, the app doesn’t quit. The moment I modify a function or instruction in libc.so (setting inline-hook concerns aside), the app crashes straight away. That’s basically tamper detection — CRC detection — firing.

That’s the overview. From here I’ll walk through the different detection routes and how to bypass each one.

The target for this analysis is libc.so, and all of the bypasses below are done with Frida.

2. Analysis environment

The app I use is a small CRC-detection demo I put together (linked at the end).

The demo app

  • Frida version: 16.5.9
  • Target app: LinkerDemo
  • SO under analysis: libc.so
  • ELF tool: 010 Editor
  • Architecture: arm64

3. Analysis and bypass

3.1 The two detection directions

CRC detection splits into two broad directions:

  1. Compare the local file against the SO’s memory range as reported in /proc/{id}/maps.
  2. Compare the local file against the SO’s memory range as obtained from the linker.

3.2 Local file vs. maps memory

Here’s the logic of this one: extract the executable-segment data from the local file /apex/com.android.runtime/lib64/bionic/libc.so, and CRC it against the executable-segment memory that the app maps for libc.so under /proc/{id}/maps.

1. Open libc.so in 010 Editor

libc.so in 010 Editor

Grab p_offset (offset within the file) and p_filesz (size within the file) from the executable segment table. Everything after this uses those values as the reference for the CRC comparison against memory.

2. Get the executable segment of libc.so from maps memory

The r-xp segment in maps

(Normally there’d be just a single r-xp line; because I’m running Frida, the memory layout shows up like this.) Extract the memory region that carries the x flag.

3. The comparison

Finally, a relevant algorithm (usually CRC32; occasionally something like MD5 or AES — rare) computes the two results and compares them.

4. The hook symptom

Open the app, tap “libc maps crc”, and the console shows a normal environment. Now inject Frida and hook pthread_create in libc.so — the output clearly shows the in-memory executable-segment CRC no longer matches the file’s, and the app flags the environment as hooked.

5. The bypass

For this detection, we can simulate an executable-segment region in maps, and erase the name of libc.so’s original executable segment so it becomes anonymous memory. The app then reads our simulated region instead of the real one.

function hiddenSoExecSegmentInMaps(so_path) {
    //      /apex/com.android.runtime/lib64/bionic/libc.so
    let mmap_addr = Module.findExportByName("libc.so", "mmap");
    let mmapFunc = new NativeFunction(mmap_addr, 'pointer', ['pointer', 'int', 'int', 'int', 'int', 'int'])

    let munmap_addr = Module.findExportByName("libc.so", "munmap");
    let munmapFunc = new NativeFunction(munmap_addr, 'int', ['pointer', 'int'])

    let mremap_addr = Module.findExportByName("libc.so", "mremap");
    let mremapFunc = new NativeFunction(mremap_addr, 'pointer', ['pointer', 'int64', 'int64', 'int64', 'pointer'])

    let open_addr = Module.findExportByName("libc.so", "open");
    var openFunc = new NativeFunction(open_addr, 'int', ['pointer', 'int']);

    let memset_addr = Module.findExportByName("libc.so", "memset");
    var memsetFunc = new NativeFunction(memset_addr, 'pointer', ['pointer', 'int', 'int'])

    let close_addr = Module.findExportByName("libc.so", "close");
    var closeFunc = new NativeFunction(close_addr, 'int', ['int'])

    const parts = so_path.split('/');
    const so_name = parts.pop();
    let soExecSegmentRangeFromMaps = findSoExecSegmentRangeFromMaps(so_name);
    let startAddress = soExecSegmentRangeFromMaps.base;
    let size = soExecSegmentRangeFromMaps.size;

    if (startAddress === 0 || size === 0) {
        console.log("executable segment not found:", startAddress, size)
        return;
    }

    let soExecSegmentFromFile = findSoExecSegmentFromFile(so_path);

    // create anonymous memory to temporarily hold the SO's executable segment
    let new_addr = mmapFunc(ptr(-1), size, 7, 0x20 | 2, -1, 0);//0x20: MAP_ANONYMOUS, 2: MAP_PRIVATE
    console.log("anonymous executable-segment memory start address:" + new_addr);

    // copy the SO's executable-segment memory into the anonymous memory
    Memory.copy(new_addr, startAddress, size);
    console.log("copy done")

    // remap so the passed SO executable segment becomes anonymous memory
    let ret = mremapFunc(new_addr, size, size, 1 | 2, startAddress);
    if (ret === -1) {
        console.log("mremap failed")
        return;
    }
    console.log("anonymous target SO executable segment done ret:" + ret)

    // open the file path to simulate, used later to create a memory region with the desired name in maps
    let moniter_path = so_path;
    let moniter_path_addr = Memory.allocUtf8String(moniter_path);
    var fd = openFunc(moniter_path_addr, 0);
    if (fd === -1) {
        console.log("open " + moniter_path + " is error")
        return -1;
    }

    // create a memory region named after the passed SO path in maps
    let target_addr = mmapFunc(ptr(-1), size, 7, 2, fd, 0);
    console.log("simulated executable-segment memory start address:" + target_addr);

    closeFunc(fd)

    // zero out the created SO memory region
    memsetFunc(target_addr, 0, size);

    // copy the executable segment from the file into the memory region named after the SO
    Memory.copy(target_addr, soExecSegmentFromFile.start, soExecSegmentFromFile.size)
    Memory.protect(target_addr, size, "r-x");

    // unmap the anonymous memory
    // munmapFunc(new_addr, size);
    console.log("executable segment hidden in maps done")

}

After injecting the bypass

Inject the code above, tap the button again, and the console shows:

The two values now match

The values from both sources now match, and the environment reads as clean.

The trick uses mmap to map a region named libc.so in maps, then mremap to turn the original executable segment into anonymous memory.

The remapped segment

Here you can see libc.so’s executable segment is now anonymous memory, and maps also contains our simulated executable-segment region.

3.3 Local file vs. linker-obtained memory

The local-file side is the same as before, so let’s go straight to how the memory is obtained from the linker.

1. Getting the soinfo struct of libc.so

The linker is the SO loader, so it holds every loaded SO. It stores them one by one in a solist variable — a linked list of soinfo structs, one per loaded SO. You can walk solist to get whichever SO you want.

So how do we get libc.so’s struct? First let’s understand the soinfo layout.

 struct soinfo {
  #if defined(__work_around_b_24465209__)
   private:
    char old_name_[SOINFO_NAME_LEN];
  #endif
   public:
    const ElfW(Phdr)* phdr; //0
    size_t phnum;	//1
  #if defined(__work_around_b_24465209__)
    ElfW(Addr) unused0; // DO NOT USE, maintained for compatibility.
  #endif
    ElfW(Addr) base;	//2
    size_t size;	//3

  #if defined(__work_around_b_24465209__)
    uint32_t unused1;  // DO NOT USE, maintained for compatibility.
  #endif

    ElfW(Dyn)* dynamic;	//4

  #if defined(__work_around_b_24465209__)
    uint32_t unused2; // DO NOT USE, maintained for compatibility
    uint32_t unused3; // DO NOT USE, maintained for compatibility
  #endif

    soinfo* next;	//5
   private:
    uint32_t flags_;	//6

    const char* strtab_;	//7 .dynstr
    ElfW(Sym)* symtab_;	//8		.dynsym

    size_t nbucket_;
    size_t nchain_;
    uint32_t* bucket_; //11
    uint32_t* chain_; //12

  #if defined(__mips__) || !defined(__LP64__)
    // This is only used by mips and mips64, but needs to be here for
    // all 32-bit architectures to preserve binary compatibility.
    ElfW(Addr)** plt_got_;	// unused on arm64
  #endif

  #if defined(USE_RELA)
    ElfW(Rela)* plt_rela_; //.rela.plt	13
    size_t plt_rela_count_;

    ElfW(Rela)* rela_;	//15 //rela.dyn
    size_t rela_count_; 			//16
  #else
    ElfW(Rel)* plt_rel_;
    size_t plt_rel_count_;

    ElfW(Rel)* rel_;
    size_t rel_count_;
  #endif

    linker_ctor_function_t* preinit_array_;// empty 17
    size_t preinit_array_count_;// empty	18

    linker_ctor_function_t* init_array_;
    size_t init_array_count_;
    linker_dtor_function_t* fini_array_;
    size_t fini_array_count_;

    linker_ctor_function_t init_func_; // empty 23
    linker_dtor_function_t fini_func_;//	empty 24

  #if defined(__arm__)	// arm64 doesn't enter this
   public:
    // ARM EABI section used for stack unwinding.
    uint32_t* ARM_exidx;
    size_t ARM_exidx_count;
   private:
  #elif defined(__mips__)// arm64 doesn't enter this
    uint32_t mips_symtabno_;
    uint32_t mips_local_gotno_;
    uint32_t mips_gotsym_;
    bool mips_relocate_got(const VersionTracker& version_tracker,
                           const soinfo_list_t& global_group,
                           const soinfo_list_t& local_group);
  #if !defined(__LP64__)// arm64 doesn't enter this
    bool mips_check_and_adjust_fp_modes();
  #endif
  #endif
    size_t ref_count_;	//25
   public:
    link_map link_map_head;	//26 27 28 29 30
      // struct link_map {
            //     ElfW(Addr) l_addr;
            //     char* l_name;
            //     ElfW(Dyn)* l_ld;
            //     struct link_map* l_next;
            //     struct link_map* l_prev;
            // };
    bool constructors_called;

    // When you read a virtual address from the ELF file, add this
    // value to get the corresponding address in the process' address space.
    ElfW(Addr) load_bias; //32

  #if !defined(__LP64__)
    bool has_text_relocations;
  #endif
    bool has_DT_SYMBOLIC;

   public:
    soinfo(android_namespace_t* ns, const char* name, const struct stat* file_stat,
           off64_t file_offset, int rtld_flags);
    ~soinfo();

    void call_constructors();
    void call_destructors();
    void call_pre_init_constructors();
    bool prelink_image();
    bool link_image(const soinfo_list_t& global_group, const soinfo_list_t& local_group,
                    const android_dlextinfo* extinfo, size_t* relro_fd_offset);
    bool protect_relro();

    void add_child(soinfo* child);
    void remove_all_links();

    ino_t get_st_ino() const;
    dev_t get_st_dev() const;
    off64_t get_file_offset() const;

    uint32_t get_rtld_flags() const;
    uint32_t get_dt_flags_1() const;
    void set_dt_flags_1(uint32_t dt_flags_1);

    soinfo_list_t& get_children();
    const soinfo_list_t& get_children() const;

    soinfo_list_t& get_parents();

    bool find_symbol_by_name(SymbolName& symbol_name,
                             const version_info* vi,
                             const ElfW(Sym)** symbol) const;

    ElfW(Sym)* find_symbol_by_address(const void* addr);
    ElfW(Addr) resolve_symbol_address(const ElfW(Sym)* s) const;

    const char* get_string(ElfW(Word) index) const;
    bool can_unload() const;
    bool is_gnu_hash() const;

    bool inline has_min_version(uint32_t min_version __unused) const {
  #if defined(__work_around_b_24465209__)
      return (flags_ & FLAG_NEW_SOINFO) != 0 && version_ >= min_version;
  #else
      return true;
  #endif
    }

    bool is_linked() const;
    bool is_linker() const;
    bool is_main_executable() const;

    void set_linked();
    void set_linker_flag();
    void set_main_executable();
    void set_nodelete();

    size_t increment_ref_count();
    size_t decrement_ref_count();
    size_t get_ref_count() const;

    soinfo* get_local_group_root() const;

    void set_soname(const char* soname);
    const char* get_soname() const;
    const char* get_realpath() const;
    const ElfW(Versym)* get_versym(size_t n) const;
    ElfW(Addr) get_verneed_ptr() const;
    size_t get_verneed_cnt() const;
    ElfW(Addr) get_verdef_ptr() const;
    size_t get_verdef_cnt() const;

    int get_target_sdk_version() const;

    void set_dt_runpath(const char *);
    const std::vector<std::string>& get_dt_runpath() const;
    android_namespace_t* get_primary_namespace();
    void add_secondary_namespace(android_namespace_t* secondary_ns);
    android_namespace_list_t& get_secondary_namespaces();

    soinfo_tls* get_tls() const;

    void set_mapped_by_caller(bool reserved_map);
    bool is_mapped_by_caller() const;

    uintptr_t get_handle() const;
    void generate_handle();
    void* to_handle();

   private:
    bool is_image_linked() const;
    void set_image_linked();

    bool elf_lookup(SymbolName& symbol_name, const version_info* vi, uint32_t* symbol_index) const;
    ElfW(Sym)* elf_addr_lookup(const void* addr);
    bool gnu_lookup(SymbolName& symbol_name, const version_info* vi, uint32_t* symbol_index) const;
    ElfW(Sym)* gnu_addr_lookup(const void* addr);

    bool lookup_version_info(const VersionTracker& version_tracker, ElfW(Word) sym,
                             const char* sym_name, const version_info** vi);

    template<typename ElfRelIteratorT>
    bool relocate(const VersionTracker& version_tracker, ElfRelIteratorT&& rel_iterator,
                  const soinfo_list_t& global_group, const soinfo_list_t& local_group);
    bool relocate_relr();
    void apply_relr_reloc(ElfW(Addr) offset);

   private:
    // This part of the structure is only available
    // when FLAG_NEW_SOINFO is set in this->flags.
    uint32_t version_;

    // version >= 0
    dev_t st_dev_;
    ino_t st_ino_;

    // dependency graph
    soinfo_list_t children_;
    soinfo_list_t parents_;

    // version >= 1
    off64_t file_offset_;
    uint32_t rtld_flags_;
    uint32_t dt_flags_1_;
    size_t strtab_size_;

    // version >= 2

    size_t gnu_nbucket_;
    uint32_t* gnu_bucket_;
    uint32_t* gnu_chain_;
    uint32_t gnu_maskwords_;
    uint32_t gnu_shift2_;
    ElfW(Addr)* gnu_bloom_filter_;

    soinfo* local_group_root_;

    uint8_t* android_relocs_;
    size_t android_relocs_size_;

    const char* soname_;
    std::string realpath_;

    const ElfW(Versym)* versym_;

    ElfW(Addr) verdef_ptr_;
    size_t verdef_cnt_;

    ElfW(Addr) verneed_ptr_;
    size_t verneed_cnt_;

    int target_sdk_version_;

    // version >= 3
    std::vector<std::string> dt_runpath_;
    android_namespace_t* primary_namespace_;
    android_namespace_list_t secondary_namespaces_;
    uintptr_t handle_;

    friend soinfo* get_libdl_info(const char* linker_path, const soinfo& linker_si);

    // version >= 4
    ElfW(Relr)* relr_;
    size_t relr_count_;

    // version >= 5
    std::unique_ptr<soinfo_tls> tls_;
    std::vector<TlsDynamicResolverArg> tlsdesc_args_;
  }

I’ve annotated the in-memory pointer indices of the relevant fields above. It describes the SO’s base address, its section tables, and a pile of methods. Those aren’t our focus here — we only care about these fields:

phdr:         program header table (segment table)
base:         SO base address
size:         SO size
dynamic:      .dynamic section
next:         next soinfo struct
strtab_:      .dynstr section
symtab_:      .dynsym section
plt_rela_:    .rela.plt section
rela_:        .rela.dyn section
link_map_head: stores the SO base and name (dl_iterate_phdr can obtain it)
load_bias:    SO base address

2. The hook symptom

The detection logic:

  • lib base mem crc: take the executable-segment offset from the local file, add it to the base field of the soinfo struct to get the in-memory executable-segment address, then compare the in-memory executable-segment data against the local file’s.
  • lib func mem crc: get the linker map via dl_iterate_phdr, take dlpi_addr from the linker map as the SO base, add the executable-segment offset from the local file, then compare in-memory data against the file.

Tap “libc base mem crc” and “libc func mem crc” respectively — the console shows:

Both CRC checks firing

3. The bypass

For this one, point the base and link_map_head fields in the soinfo struct at the address we mapped in maps.

function hiddenSobaseInMem(so_path) {
    //  /apex/com.android.runtime/lib64/bionic/libc.so
    let mmap_addr = Module.findExportByName("libc.so", "mmap");
    let mmapFunc = new NativeFunction(mmap_addr, 'pointer', ['pointer', 'int', 'int', 'int', 'int', 'int'])

    const parts = so_path.split('/');
    const so_name = parts.pop();

    let soExecSegmentFromFile = findSoExecSegmentFromFile(so_path);

    // get the SO's memory from maps
    let soRangeFromMaps = findSoRangeFromMaps(so_name);
    let startAddress = soRangeFromMaps.base;
    let size = soRangeFromMaps.size;
    console.log(startAddress,size)
    // create anonymous memory to hold the SO's mapped memory
    let new_addr = mmapFunc(ptr(-1), size, 7, 0x20 | 2, -1, 0);
    console.log("anonymous memory start address:" + new_addr);

    // copy the SO's mapped memory into the anonymous memory
    Memory.copy(new_addr, startAddress, size);
    console.log("copy done")

    // copy the file's executable segment into the anonymous memory
    Memory.copy(ptr(new_addr).add(soExecSegmentFromFile.p_offset), ptr(soExecSegmentFromFile.start), soExecSegmentFromFile.size);
    console.log("real section copied")

    // get the soinfo linked list from the linker
    let solist = getSolist();

    let num = 0;
    let soinfo_next;
    let realpath;

    console.log("start iterating")
    do {
        realpath = getRealpath(solist);// get the soinfo's name
        console.log(num + "-->" + realpath);

        if (realpath.indexOf(so_name) !== -1) {

            Memory.protect(ptr(solist).add(Process.pointerSize * 2), 4, "rw");
            ptr(solist).add(Process.pointerSize * 2).writePointer(new_addr);// so base

            Memory.protect(ptr(solist).add(Process.pointerSize * 26), 4, "rw");
            ptr(solist).add(Process.pointerSize * 26).writePointer(new_addr);// linker map

            // load_bias can't be modified directly, or symbol lookup crashes
            break;
        }
        soinfo_next = ptr(solist).add(Process.pointerSize * 5).readPointer();// soinfo struct
        num++;
        solist = soinfo_next;
    } while (soinfo_next.toUInt32() !== 0);
    console.log("iteration done:")
    console.log("so base address hidden in memory done");
}

After injecting the bypass

Inject the code, tap the two buttons again, and the console shows:

Both checks pass now

The environment reads clean again.

Note: modifying base and load_bias carries a crash risk for the app.

4. Bypassing “libc section mem crc”

Let me sketch this one briefly — the detection and the bypass idea.

Detection: get the section-table address from the soinfo struct (using strtab_ as the anchor), subtract the section-table offset from the local file to recover the SO base, add the executable-segment offset to that base to get the executable-segment address, and finally compare in-memory executable-segment data against the local file.

Bypass: point the section-table pointers in the soinfo struct at the address we mapped in maps.

Hook symptom:

Section CRC firing

Section CRC firing

Inject the code, tap again:

Section CRC bypassed

Summary

This writeup is meant to give you a direction rather than a recipe. The same approach works for libart.so, too. The broad categories of CRC detection are the two I laid out above; below that, the smaller variations mostly nest detail upon detail.


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