1807 words
9 minutes
Patch 3 — Renaming the `frida_agent_main` Exported Symbol

( بِسْمِ اللَّـهِ الرَّحْمَـٰنِ الرَّحِيمِ )

CAUTION

#FreePalestine

WARNING

This is just for educational purposes.


Patch 3 — Renaming the frida_agent_main Exported Symbol#

Introduction#

When Frida’s injector loads the agent shared library into a target process, it does not simply call dlopen and walk away — it has to locate a specific exported function inside that library to hand control over to the agent. That function has always been named frida_agent_main. The name is exported as a dynamic symbol in the ELF symbol table of frida-agent.so, making it trivially visible to any tool that reads ELF metadata: nm, readelf, objdump, or a custom in-process scanner. It is also the string the host binary passes to dlsym/GetProcAddress at runtime, meaning it must match exactly between the injector and the agent.

This patch, “Florida: symbol_frida_agent_main”, is a two-part solution:

  1. Source-level — every platform’s host-session code is changed to look up "main" instead of "frida_agent_main" as the agent entrypoint.
  2. Binary post-processing — a new Python script src/anti-anti-frida.py uses the LIEF library to walk the compiled frida-agent.so ELF symbol table and rename frida_agent_mainmain, plus obfuscate every other symbol that contains the substring frida or FRIDA with a random five-character uppercase string.

Together these two steps eliminate the frida_agent_main symbol — and all other frida-branded symbols — from the final binary’s exported symbol table.


The Original Function / String#

Primary symbol: frida_agent_main
Symbol type: STT_FUNC, global, exported (STB_GLOBAL / STV_DEFAULT)
Location in source: defined inside frida-core’s agent entry-point translation unit; referenced by name (as a string literal) in every platform host-session file:

FileRole
src/linux/linux-host-session.valaAndroid / Linux injector entrypoint string
src/darwin/darwin-host-session.valamacOS / iOS injector entrypoint string
src/freebsd/freebsd-host-session.valaFreeBSD injector entrypoint string
src/qnx/qnx-host-session.valaQNX injector entrypoint string
src/windows/windows-host-session.valaWindows injector entrypoint string
src/agent-container.valaIn-process agent container symbol lookup
tests/test-agent.valaTest harness symbol lookup
tests/test-injector.valaTest harness injection call

After frida-agent.so is compiled, the dynamic symbol table contains entries like:

00012340 T frida_agent_main

The T means the symbol lives in the .text (code) section and is globally visible. Any process that has the library mapped — including the target app itself — can enumerate its exported symbols and find frida_agent_main immediately, because that is exactly what the dynamic linker is designed to expose.

Why it is a detection surface:

  • The ELF dynamic symbol table (.dynsym section) is always present in a shared object; it cannot be stripped without breaking dlopen/dlsym semantics.
  • frida_agent_main is a globally unique name — no legitimate Android system library exports it.
  • Because the injector must resolve it by name, it must remain exported and visible; obscuring it in the source but leaving it in the binary would break injection entirely.
  • Beyond the single entrypoint, the full symbol table of an unpatched frida-agent.so contains dozens of additional symbols prefixed with frida_ or FRIDA_, painting a very clear picture.

Before Patching — Behavior on Android#

Reading the dynamic symbol table of the pushed agent library:

$ adb push frida-agent-64.so /data/local/tmp/
frida-agent-64.so: 1 file pushed, 0 skipped

$ adb shell nm -D /data/local/tmp/frida-agent-64.so | grep -i frida | head -20
000000000012a340 T frida_agent_main
000000000012b100 T frida_agent_resume
000000000010f220 T frida_error_quark
000000000010f280 T frida_init
000000000010f340 T frida_deinit
000000000010f400 T frida_version
000000000010f4c0 T frida_version_string
0000000000000000 D FRIDA_BUILD_ID
...

Same result using readelf (available on-device via busybox or standalone binary):

$ adb shell readelf -Ws /data/local/tmp/frida-agent-64.so | grep frida_agent_main
    42: 000000000012a340   312 FUNC    GLOBAL DEFAULT   12 frida_agent_main

In-process scanner reading /proc/self/maps then walking .dynsym:

A detector that finds any frida-agent-named mapping (see Patch 2) can immediately follow up by walking the ELF symbol table of that mapping in-memory. Even without the filename, if the agent was loaded under any name, frida_agent_main is still visible in its .dynsym.

$ adb shell cat /proc/$(pidof com.example.targetapp)/maps | grep "\.so"
...
7a8f200000-7a8fb90000 r--p 00000000 fc:20 262144  /data/local/tmp/frida-agent-64.so
...

At load address 0x7a8f200000, the ELF header, .dynstr, and .dynsym sections are all readable from within the process. A native scanner can parse them at runtime and find frida_agent_main without touching the filesystem.


How Apps Detect It#

Method 1 — dlopen + dlsym probe (Java via JNI / C++)#

The most direct in-process check: attempt to resolve the symbol by name inside the current process without opening any file.

#include <dlfcn.h>
#include <stdbool.h>

bool detectFridaAgentMainSymbol() {
    // RTLD_DEFAULT searches all currently loaded shared libraries
    void *sym = dlsym(RTLD_DEFAULT, "frida_agent_main");
    if (sym != NULL) {
        return true;  // frida_agent_main is loaded in this process
    }
    return false;
}

Method 2 — Manual ELF .dynsym walk inside /proc/self/maps (C++)#

Does not rely on dlsym; walks the raw ELF structures directly. Useful even if the library was injected without going through the normal dynamic linker path.

#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <elf.h>

// Reads the ELF dynamic symbol table at the given base address in memory
bool elfHasFridaAgentMain(const uint8_t *base) {
    // Validate ELF magic
    if (memcmp(base, ELFMAG, SELFMAG) != 0) return false;

    const Elf64_Ehdr *ehdr = (const Elf64_Ehdr *)base;
    const Elf64_Shdr *shdr = (const Elf64_Shdr *)(base + ehdr->e_shoff);

    const char *shstrtab = (const char *)(base + shdr[ehdr->e_shstrndx].sh_offset);

    const Elf64_Sym  *dynsym  = NULL;
    const char       *dynstr  = NULL;
    size_t            nsyms   = 0;

    for (int i = 0; i < ehdr->e_shnum; i++) {
        const char *section_name = shstrtab + shdr[i].sh_name;
        if (strcmp(section_name, ".dynsym") == 0) {
            dynsym = (const Elf64_Sym *)(base + shdr[i].sh_offset);
            nsyms  = shdr[i].sh_size / sizeof(Elf64_Sym);
        }
        if (strcmp(section_name, ".dynstr") == 0) {
            dynstr = (const char *)(base + shdr[i].sh_offset);
        }
    }

    if (!dynsym || !dynstr) return false;

    for (size_t i = 0; i < nsyms; i++) {
        const char *sym_name = dynstr + dynsym[i].st_name;
        if (strcmp(sym_name, "frida_agent_main") == 0) {
            return true;  // found
        }
    }
    return false;
}

Method 3 — Filename-agnostic library scan from a watchdog process#

An external watchdog process (a separate privileged service or even another thread via /proc/<pid>/mem) iterates all loaded libraries for the target PID and scans each one for the symbol, without needing to know any filename.

## Python watchdog snippet (runs as root or in a privileged companion process)
import re, os, struct

def find_frida_agent_main(pid: int) -> bool:
    maps_path = f"/proc/{pid}/maps"
    with open(maps_path) as f:
        for line in f:
            m = re.match(r'([0-9a-f]+)-[0-9a-f]+ r--p 00000000 \S+ \S+ +(/.+\.so)', line)
            if not m:
                continue
            base = int(m.group(1), 16)
            path = m.group(2)
            # Read first 64 KB of the mapping (covers ELF header + section headers for most .so)
            try:
                with open(f"/proc/{pid}/mem", "rb") as mem:
                    mem.seek(base)
                    data = mem.read(65536)
                    if b"frida_agent_main" in data:
                        return True
            except OSError:
                continue
    return False

All three methods share the same dependency: the string "frida_agent_main" appears literally in the binary’s dynamic symbol table and is therefore searchable.


The Patch#

This patch operates at two levels that must be applied together.

Part 1 — Source-level: change the looked-up entrypoint name#

Every platform host-session file that passes "frida_agent_main" to the injector’s inject_library_* call is updated to pass "main" instead.

Representative change (Linux, also applied identically to Darwin, FreeBSD, QNX, Windows):

--- a/src/linux/linux-host-session.vala
+++ b/src/linux/linux-host-session.vala
@@ -427,7 +427,7 @@
     uint id;
-    string entrypoint = "frida_agent_main";
+    string entrypoint = "main";
     string parameters = make_agent_parameters (pid, "", options);

Same change in the in-process container:

--- a/src/agent-container.vala
+++ b/src/agent-container.vala
@@ -28,7 +28,7 @@
     void * main_func_symbol;
-    var main_func_found = container.module.symbol ("frida_agent_main", out main_func_symbol);
+    var main_func_found = container.module.symbol ("main", out main_func_symbol);
     assert (main_func_found);

After this change, the injector will call dlsym(handle, "main") on the loaded agent. The agent .so must therefore export a symbol named main — which is exactly what Part 2 arranges.

Part 2 — Binary post-processing: rename symbols in the compiled .so#

A new Python script src/anti-anti-frida.py is introduced. It is run on the compiled frida-agent-<arch>.so as a post-build step:

import lief
import sys
import random
import os

if __name__ == "__main__":
    input_file = sys.argv[1]
    print(f"[*] Patch frida-agent: {input_file}")
    random_name = "".join(random.sample("ABCDEFGHIJKLMNO", 5))
    print(f"[*] Patch `frida` to `{random_name}`")

    binary = lief.parse(input_file)
    if not binary:
        exit()

    for symbol in binary.symbols:
        if symbol.name == "frida_agent_main":
            symbol.name = "main"                          # entrypoint rename

        if "frida" in symbol.name:
            symbol.name = symbol.name.replace("frida", random_name)   # e.g. frida_init → NCHBM_init

        if "FRIDA" in symbol.name:
            symbol.name = symbol.name.replace("FRIDA", random_name)   # e.g. FRIDA_BUILD_ID → NCHBM_BUILD_ID

    binary.write(input_file)

What random.sample("ABCDEFGHIJKLMNO", 5) produces:

It picks 5 unique characters (no repetition) from the 15-character pool ABCDEFGHIJKLMNO, producing names like NCHBM, GIBOA, EKOFL. This string is different on every build, so no two compiled agents share the same symbol-name obfuscation.

Net effect on the symbol table:

BeforeAfter
frida_agent_mainmain
frida_initNCHBM_init
frida_deinitNCHBM_deinit
frida_versionNCHBM_version
frida_version_stringNCHBM_version_string
FRIDA_BUILD_IDNCHBM_BUILD_ID

The critical entrypoint becomes the generic main, and every other frida-branded symbol is replaced with a random, meaningless identifier.

Why this does not break Frida’s functionality:

The injector and the agent are built from the same patched source tree. The injector now looks up "main" and the post-processed binary now exports "main" — the name resolves correctly. All other renamed symbols are internal symbols used for inter-component calls within frida-agent.so itself; their internal callsites are resolved by the dynamic linker through GOT/PLT entries at load time. Because LIEF rewrites the symbol names in .dynsym/.dynstr consistently, the linker resolves them to the same functions and internal calls remain intact.


After Patching — Behavior on Android#

Symbol table of the patched agent — no frida anywhere:

$ adb push frida-agent-patched-64.so /data/local/tmp/
frida-agent-patched-64.so: 1 file pushed, 0 skipped

$ adb shell nm -D /data/local/tmp/frida-agent-patched-64.so | grep -i frida
(no output)

$ adb shell nm -D /data/local/tmp/frida-agent-patched-64.so | grep -i main
000000000012a340 T main

$ adb shell nm -D /data/local/tmp/frida-agent-patched-64.so | grep "NCHBM" | head -10
000000000010f280 T NCHBM_init
000000000010f340 T NCHBM_deinit
000000000010f400 T NCHBM_version
000000000010f4c0 T NCHBM_version_string
0000000000000000 D NCHBM_BUILD_ID

dlsym probe returns null:

void *sym = dlsym(RTLD_DEFAULT, "frida_agent_main");
// sym == NULL  →  detector concludes: not found

In-memory ELF scan of the live process:

$ adb shell grep "frida_agent_main" /proc/$(pidof com.example.targetapp)/mem 2>/dev/null
(no output)

$ python3 watchdog.py $(pidof com.example.targetapp)
[*] No frida_agent_main found in any loaded library.

Build-time console output from anti-anti-frida.py confirming the rename:

$ python3 src/anti-anti-frida.py ./out/frida-agent-64.so
[*] Patch frida-agent: ./out/frida-agent-64.so
[*] Patch `frida` to `NCHBM`

The agent is fully operational — frida_agent_main (now called main) is resolved, the agent initialises, and instrumentation proceeds normally. Every detection method that searched for frida_agent_main or any other frida_* / FRIDA_* symbol now finds nothing.


Noob Section (In Simple Words)#

The problem:

Every shared library (.so file) has a table of contents listing the names of all its functions. Frida’s agent library has a function called frida_agent_main right there in the list, along with dozens of other functions containing the word frida or FRIDA. Any security tool can read that table and instantly see “this library belongs to Frida.” It’s like a book whose table of contents has “Chapter 1: How Frida Hooks Your App” in it.

What this patch does:

Two things happen together. First, the Frida source code is changed so the injector looks for a function called main instead of frida_agent_main. Second, a Python script runs after the library is compiled and rewrites the table of contents: it renames frida_agent_main to main, and replaces every other function name containing frida or FRIDA with a random 5-character string (like NCHBM). So frida_init becomes NCHBM_init, FRIDA_BUILD_ID becomes NCHBM_BUILD_ID, and so on. The random string is different on every build.

The result:

If a security tool reads the library’s function list, it finds nothing with “frida” in it, just generic-looking names. The entry point is now called main, which is one of the most common function names in existence. Frida still works perfectly because both sides (the injector and the agent) agree on the new name.