1523 words
8 minutes
Patch 5 — Hiding the `gmain` Thread Name

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

CAUTION

#FreePalestine

WARNING

This is just for educational purposes.


Patch 5 — Randomizing the gmain Thread Name#

Introduction#

Alongside gum-js-loop, Frida’s agent spawns a second identifiable native thread called gmain. This is GLib’s main event loop thread — the GMainLoop that powers the agent’s control-channel message handling, timer callbacks, and internal IPC. Unlike gum-js-loop which only appears when the JavaScript engine is running, gmain is created earlier in the agent’s startup sequence and persists as long as the agent is alive. Its name appears in /proc/<pid>/task/<tid>/comm just as predictably as gum-js-loop, and it is equally well-known to Frida detection engineers.

This patch adds a third post-processing step to src/anti-anti-frida.py: after the gum-js-loop replacement, gmain is replaced in the compiled binary with a random 5-character lowercase string via the same sed -b -i technique.


The Original Function / String#

String: gmain
Origin: GLib’s GMainLoop implementation. When g_main_loop_run() is called from a dedicated thread, GLib assigns the thread the name gmain via pthread_setname_np(). Frida’s agent startup code creates exactly such a thread to drive the GLib event loop that handles agent lifecycle events.
Location in binary: the string literal "gmain" lives in the .rodata section of frida-agent.so, contributed by GLib which is statically linked into the agent.

The key distinction from gum-js-loop:

ThreadCreated byLifetime
gum-js-loopGumJS schedulerWhile JS engine is active
gmainGLib GMainLoopFrom agent init until agent teardown

gmain has a longer observable window — it is present from the moment the agent is injected and initialised, even before any JavaScript session is established. A detector that polls thread names at injection time will see gmain before it ever sees gum-js-loop.

Why it is a detection surface:

  • gmain is 5 characters — short, memorable, and easy to match with a simple strcmp.
  • No legitimate Android framework thread uses the name gmain. It is not a system thread name.
  • Because it is a GLib-internal name assigned programmatically, it cannot be changed at the Vala/C source level without patching GLib itself — making a binary-level replacement the only viable approach without forking the entire dependency.
  • gmain is shorter than gum-js-loop, which means it also appears as a substring in other GLib-related strings (e.g. gmainloop, internal debug messages). The sed -b -i global replacement handles all occurrences at once.

Before Patching — Behavior on Android#

Thread listing showing both Frida threads:

$ adb shell ps -T -p $(pidof com.example.targetapp)
USER           PID   TID PPID  VSZ   RSS WCHAN         PC S NAME
u0_a123      12345 12345 1234  2.1G  45M futex_wait    0  S com.example.targetapp
u0_a123      12345 12346 1234  2.1G  45M futex_wait    0  S Jit thread pool
u0_a123      12345 12347 1234  2.1G  45M futex_wait    0  S RenderThread
u0_a123      12345 12351 1234  2.1G  45M futex_wait    0  S gum-js-loop
u0_a123      12345 12352 1234  2.1G  45M futex_wait    0  S gmain

Reading gmain directly from the /proc task comm file:

$ adb shell cat /proc/12345/task/12352/comm
gmain

gmain appears even before any script is loaded — checking immediately after injection, before frida.attach() fully completes a session:

$ adb shell
android:/ # cat /proc/12345/task/12352/comm
gmain
android:/ # cat /proc/12345/task/12351/comm
cat: /proc/12345/task/12351/comm: No such file or directory

The gmain thread (TID 12352) is already running; gum-js-loop (TID 12351) has not been created yet because no script has been attached. This makes gmain detectable at the earliest possible point in the injection lifecycle.

Confirming the string in the binary:

$ adb shell strings /data/local/tmp/frida-agent-64.so | grep -w gmain
gmain

How Apps Detect It#

Method 1 — Poll /proc/self/task at startup and periodically (C++)#

Because gmain appears before gum-js-loop, detectors often run a background watchdog that starts polling immediately at JNI_OnLoad time:

#include <stdio.h>
#include <string.h>
#include <dirent.h>
#include <pthread.h>
#include <unistd.h>

static const char *FRIDA_THREAD_NAMES[] = {
    "gum-js-loop",
    "gmain",
    NULL
};

bool detectFridaThreadNames() {
    DIR *task_dir = opendir("/proc/self/task");
    if (!task_dir) return false;

    struct dirent *entry;
    while ((entry = readdir(task_dir)) != NULL) {
        if (entry->d_name[0] == '.') continue;

        char comm_path[64];
        char comm[32] = {0};
        snprintf(comm_path, sizeof(comm_path), "/proc/self/task/%s/comm", entry->d_name);

        FILE *f = fopen(comm_path, "r");
        if (!f) continue;
        fgets(comm, sizeof(comm), f);
        fclose(f);
        comm[strcspn(comm, "\n")] = '\0';

        for (int i = 0; FRIDA_THREAD_NAMES[i] != NULL; i++) {
            if (strcmp(comm, FRIDA_THREAD_NAMES[i]) == 0) {
                closedir(task_dir);
                return true;
            }
        }
    }
    closedir(task_dir);
    return false;
}

// Watchdog thread: keeps polling every 500ms
void *watchdog(void *arg) {
    while (1) {
        if (detectFridaThreadNames()) {
            // Trigger tamper response
            raise(SIGKILL);
        }
        usleep(500000);
    }
    return NULL;
}

Method 2 — Timing-based early detection at JNI_OnLoad (C++ / JNI)#

Since gmain is created before any JS session, a check in JNI_OnLoad can catch Frida before it has had a chance to hook anything:

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
    // Check for Frida's gmain thread immediately at library load
    if (detectFridaThreadNames()) {
        // Self-destruct or trigger enforcement before any hooks land
        abort();
    }

    // ... normal initialisation ...
    return JNI_VERSION_1_6;
}

Method 3 — Combined thread name set check (Kotlin + JNI)#

Some SDKs pair the Java Thread enumeration with a native /proc check, expecting to find a specific set of suspicious thread names together:

object FridaThreadDetector {

    private val SUSPICIOUS_NAMES = setOf("gum-js-loop", "gmain", "frida")

    // Native call drops to C++ /proc scanner for accurate results
    external fun nativeGetThreadNames(): Array<String>

    fun detect(): Boolean {
        val names = nativeGetThreadNames().toSet()
        return names.intersect(SUSPICIOUS_NAMES).isNotEmpty()
    }
}

The Patch#

What changed: A third block is appended to src/anti-anti-frida.py, running immediately after the gum-js-loop replacement:

## gmain thread
random_name = "".join(random.sample("abcdefghijklmn", 5))
print(f"[*] Patch `gmain` to `{random_name}`")
os.system(f"sed -b -i s/gmain/{random_name}/g {input_file}")

Full diff:

--- a/src/anti-anti-frida.py
+++ b/src/anti-anti-frida.py
@@ -29,4 +29,9 @@ if __name__ == "__main__":
     # gum-js-loop thread
     random_name = "".join(random.sample("abcdefghijklmn", 11))
     print(f"[*] Patch `gum-js-loop` to `{random_name}`")
-    os.system(f"sed -b -i s/gum-js-loop/{random_name}/g {input_file}")
+    os.system(f"sed -b -i s/gum-js-loop/{random_name}/g {input_file}")
+
+    # gmain thread
+    random_name = "".join(random.sample("abcdefghijklmn", 5))
+    print(f"[*] Patch `gmain` to `{random_name}`")
+    os.system(f"sed -b -i s/gmain/{random_name}/g {input_file}")

Why random.sample("abcdefghijklmn", 5) for gmain:

gmain is 5 bytes. As with gum-js-loop in Patch 4, the replacement must be exactly the same length as the original string to avoid shifting downstream bytes in the binary. random.sample without repetition from 14 lowercase characters produces names like kecbf, mjald, ngbhc — opaque 5-character strings with no semantic meaning.

Why sed -b is used instead of LIEF:

gmain is a raw C string literal in .rodata, not an ELF symbol entry. LIEF’s symbol iteration API does not reach into section data to find and replace arbitrary byte sequences. sed -b (binary mode, suppresses CRLF translation) replaces all occurrences of the exact byte sequence gmain anywhere in the file — this covers the .rodata occurrence, any occurrence in .dynstr if the symbol also appears there, and any debug section occurrences.

The complete anti-anti-frida.py script at this point in the patch series:

import lief, sys, random, 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"
        if "frida" in symbol.name:
            symbol.name = symbol.name.replace("frida", random_name)
        if "FRIDA" in symbol.name:
            symbol.name = symbol.name.replace("FRIDA", random_name)

    binary.write(input_file)

    # gum-js-loop thread
    random_name = "".join(random.sample("abcdefghijklmn", 11))
    print(f"[*] Patch `gum-js-loop` to `{random_name}`")
    os.system(f"sed -b -i s/gum-js-loop/{random_name}/g {input_file}")

    # gmain thread
    random_name = "".join(random.sample("abcdefghijklmn", 5))
    print(f"[*] Patch `gmain` to `{random_name}`")
    os.system(f"sed -b -i s/gmain/{random_name}/g {input_file}")

Sample build-time console output after both thread patches are in place:

[*] Patch frida-agent: ./out/frida-agent-64.so
[*] Patch `frida` to `NCHBM`
[*] Patch `gum-js-loop` to `kcfbdhigjml`
[*] Patch `gmain` to `kecbf`

After Patching — Behavior on Android#

Thread listing — both Frida threads now show random names:

$ adb shell ps -T -p $(pidof com.example.targetapp)
USER           PID   TID PPID  VSZ   RSS WCHAN         PC S NAME
u0_a123      12345 12345 1234  2.1G  45M futex_wait    0  S com.example.targetapp
u0_a123      12345 12346 1234  2.1G  45M futex_wait    0  S Jit thread pool
u0_a123      12345 12347 1234  2.1G  45M futex_wait    0  S RenderThread
u0_a123      12345 12351 1234  2.1G  45M futex_wait    0  S kcfbdhigjml
u0_a123      12345 12352 1234  2.1G  45M futex_wait    0  S kecbf

/proc comm files:

android:/ # cat /proc/12345/task/12352/comm
kecbf

android:/ # cat /proc/12345/task/12351/comm
kcfbdhigjml

Static strings scan — no gmain:

$ adb shell strings /data/local/tmp/frida-agent-patched-64.so | grep -w gmain
(no output)

$ adb shell strings /data/local/tmp/frida-agent-patched-64.so | grep "kecbf"
kecbf

Early JNI_OnLoad detection — clean:

// Called immediately at library load, before any Frida hooks
detectFridaThreadNames();  // returns false
// "gmain" → "kecbf" and "gum-js-loop" → "kcfbdhigjml" — neither matches

With both gum-js-loop and gmain randomized, the full set of Frida-specific thread names that detection SDKs enumerate is exhausted. The agent is operationally intact; the GLib event loop and GumJS scheduler run normally under their new opaque names.


Noob Section (In Simple Words)#

The problem:

Frida creates a second recognizable thread called gmain. This one handles Frida’s internal messaging and event system (powered by a library called GLib). Unlike gum-js-loop which only appears when JavaScript hooks are running, gmain shows up the moment Frida is injected, even before any script is loaded. That makes it detectable even earlier. Security tools that check thread names right at startup will catch gmain before they ever see gum-js-loop.

What this patch does:

The same sed find-and-replace technique from Patch 4 is used again. The 5 bytes of gmain in the compiled binary are swapped with a random 5-character string like kecbf. Same length, same idea.

The result:

Now both of Frida’s signature threads have random names. gum-js-loop became something like kcfbdhigjml (Patch 4) and gmain became something like kecbf (this patch). A security tool listing all threads in the app sees nothing Frida-related. Both threads still do their jobs normally, they just no longer advertise who they belong to.