1520 words
8 minutes
Patch 4 — Hiding the `gum-js-loop` Thread Name

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

CAUTION

#FreePalestine

WARNING

This is just for educational purposes.


Patch 4 — Randomizing the gum-js-loop Thread Name#

Introduction#

Every Frida agent that runs JavaScript instrumentation spins up a dedicated OS thread to host the GumJS JavaScript engine’s event loop. Frida names that thread gum-js-loop. On Android, thread names are exposed kernel-side through /proc/<pid>/task/<tid>/comm and show up plainly in the output of ps -T. Because the name is a fixed string baked into the compiled frida-gum library, it is one of the most reliable behavioral fingerprints available to detection engineers — it appears the moment any JavaScript hook is active, regardless of whether the agent .so has been renamed or its symbols obfuscated.

This patch extends src/anti-anti-frida.py (introduced in Patch 3) with a second post-processing step: after the ELF symbol table has been cleaned up, a sed binary in-place replacement swaps the literal bytes gum-js-loop in the compiled binary for an 11-character random lowercase string, eliminating the thread-name fingerprint entirely.


The Original Function / String#

String: gum-js-loop
Location in source: hardcoded inside Frida’s GumJS JavaScript runtime, in the frida-gum subproject (the underlying instrumentation engine that backs Frida’s JavaScript API).
Where it ends up in the binary: as a null-terminated C string literal in the .rodata section of the compiled frida-gum static library, linked into frida-agent.so.

When the Frida agent initialises and the JavaScript engine starts, it calls gum_script_scheduler_start (or equivalent, depending on the Frida version), which creates a GThread and passes "gum-js-loop" as the thread name argument via g_thread_new(). GLib’s g_thread_new() eventually calls pthread_setname_np(), which writes the name into the kernel’s per-thread comm field. The kernel truncates thread names to 15 characters; at 11 characters gum-js-loop fits exactly, so it appears verbatim.

Why it is a detection surface:

  • /proc/<pid>/task/<tid>/comm is a world-readable file (for threads within the same process). Any thread in the target app can iterate all sibling threads and read their names without any special permissions.
  • The name is unique — no Android framework thread, no common SDK thread, uses the name gum-js-loop.
  • Unlike file-based artifacts that only appear during injection, this thread name is persistent — it exists for the entire duration of any JavaScript instrumentation session, actively refreshed by the kernel.
  • The previous patches (renamed .so file, obfuscated ELF symbols) do not touch this string because it lives in .rodata as a data value, not as an ELF symbol name.

Before Patching — Behavior on Android#

Listing threads of the instrumented process using ps -T:

$ 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 it directly from the /proc virtual filesystem:

$ adb shell
android:/ # for tid in $(ls /proc/12345/task/); do
>   echo -n "$tid: "
>   cat /proc/12345/task/$tid/comm
> done
12345: com.example.targ
12346: Jit thread pool
12347: RenderThread
12351: gum-js-loop
12352: gmain

Confirming the string in the compiled agent binary:

$ adb shell strings /data/local/tmp/frida-agent-64.so | grep gum-js-loop
gum-js-loop

The string is present in .rodata, appears at runtime as a live thread name, and is visible from within the process without any privileges.


How Apps Detect It#

Method 1 — Iterating /proc/self/task/<tid>/comm (C++)#

#include <stdio.h>
#include <string.h>
#include <dirent.h>
#include <stdbool.h>

bool detectGumJsLoopThread() {
    char path[64];
    char comm[32];

    // /proc/self/task/ lists all thread IDs of the current process
    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;

        snprintf(path, sizeof(path), "/proc/self/task/%s/comm", entry->d_name);

        FILE *f = fopen(path, "r");
        if (!f) continue;

        if (fgets(comm, sizeof(comm), f)) {
            // Strip trailing newline
            comm[strcspn(comm, "\n")] = '\0';
            if (strcmp(comm, "gum-js-loop") == 0) {
                fclose(f);
                closedir(task_dir);
                return true;  // Frida JS thread detected
            }
        }
        fclose(f);
    }
    closedir(task_dir);
    return false;
}

Method 2 — Java via reflection on Thread.getAllStackTraces() (no native required)#

import java.util.Map;

public class ThreadNameDetector {

    public static boolean hasFridaThread() {
        Map<Thread, StackTraceElement[]> allThreads = Thread.getAllStackTraces();
        for (Thread t : allThreads.keySet()) {
            String name = t.getName();
            if ("gum-js-loop".equals(name)) {
                return true;  // Frida GumJS event loop thread found
            }
        }
        return false;
    }
}

Note: Thread.getAllStackTraces() in Java only enumerates threads managed by the JVM thread group. Frida’s gum-js-loop is a native pthread, so it will not appear here in most configurations. The C++ /proc approach is the reliable path; the Java method is a secondary heuristic some SDKs layer on regardless.

Method 3 — pthread_getname_np sweep over all known TIDs (C++)#

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

bool detectByPthreadName() {
    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;

        long tid = strtol(entry->d_name, NULL, 10);
        // pthread_t handle is not always reliably obtained from TID,
        // so fall back to /proc comm directly — see Method 1.
        // Some SDKs iterate known pthread handles stored in a list instead.
        char comm_path[64];
        char name[16] = {0};
        snprintf(comm_path, sizeof(comm_path), "/proc/self/task/%ld/comm", tid);
        FILE *f = fopen(comm_path, "r");
        if (!f) continue;
        fgets(name, sizeof(name), f);
        fclose(f);
        name[strcspn(name, "\n")] = '\0';

        if (strncmp(name, "gum-js-loop", 11) == 0) {
            closedir(task_dir);
            return true;
        }
    }
    closedir(task_dir);
    return false;
}

All three methods converge on the same invariant: the thread name is exactly gum-js-loop. Change that string and every check fails.


The Patch#

What changed: A second block is appended to src/anti-anti-frida.py, executed after the ELF symbol renaming, that replaces the literal bytes of gum-js-loop in the compiled binary using sed with the binary (-b) and in-place (-i) flags:

## 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}")

Full diff:

--- a/src/anti-anti-frida.py
+++ b/src/anti-anti-frida.py
@@ -24,4 +24,9 @@ if __name__ == "__main__":
         if "FRIDA" in symbol.name:
             symbol.name = symbol.name.replace("FRIDA", random_name)
 
-    binary.write(input_file)
+    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}")

Why sed -b (binary mode) instead of a LIEF symbol rename:

gum-js-loop is not an ELF symbol — it is a plain C string literal stored in .rodata. LIEF’s symbol API only walks .dynsym and .symtab; it has no built-in mechanism to patch arbitrary string data inside sections. A raw binary substitution with sed -b -i (which suppresses newline translation that would corrupt binary data) is the correct tool for replacing a fixed-length string literal directly in the file’s bytes.

Why the replacement string is exactly 11 characters:

random.sample("abcdefghijklmn", 11) draws 11 unique characters from a 14-character lowercase pool, producing names like kcfbdhigjml or aejlmgnbfch. This is the same length as gum-js-loop (11 bytes). Binary sed replaces byte-for-byte; if the replacement were shorter it would shift all subsequent bytes in the file, corrupting the binary entirely. Matching the length means only those 11 bytes change — everything else stays at its original offset.

Sample output during build:

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

Both transforms run in the same script invocation, so the final binary has both clean symbols and a randomized thread name in one pass.


After Patching — Behavior on Android#

Thread listing of the instrumented process — no gum-js-loop:

$ 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 gmain

/proc comm files — name is now the random string:

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

Static strings scan of the patched binary — no hit:

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

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

Detection routine result:

detectGumJsLoopThread();  // returns false — strcmp("kcfbdhigjml", "gum-js-loop") != 0

The JavaScript engine is running, hooks are active, and instrumentation proceeds normally. The thread name the kernel records is an opaque 11-character string that changes with every build and carries no association with Frida.


Noob Section (In Simple Words)#

The problem:

When Frida runs JavaScript hooks inside an app, it creates a background thread (think of it as a worker running in the background) and names it gum-js-loop. On Android, every thread’s name is publicly visible, any code inside the app can list all threads and read their names. Since no normal Android app has a thread called gum-js-loop, finding one is a dead giveaway that Frida is active. Unlike the file-based clues from earlier patches, this one is a live, always-present signal that exists the entire time your hooks are running.

What this patch does:

After the agent library is compiled, a simple find-and-replace tool (sed) swaps the exact bytes of gum-js-loop (11 characters) with a random 11-character string like kcfbdhigjml. The replacement is the same length so the file stays intact, only those specific bytes change.

The result:

When the thread spins up at runtime, Android sees it as kcfbdhigjml instead of gum-js-loop. Any security check looking for a thread named gum-js-loop finds nothing. The thread does its job exactly the same way, it just has a random, meaningless name now.