( بِسْمِ اللَّـهِ الرَّحْمَـٰنِ الرَّحِيمِ )
CAUTION#FreePalestine
WARNINGThis 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>/commis 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
.sofile, obfuscated ELF symbols) do not touch this string because it lives in.rodataas 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 gmainReading 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: gmainConfirming the string in the compiled agent binary:
$ adb shell strings /data/local/tmp/frida-agent-64.so | grep gum-js-loop
gum-js-loopThe 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
kcfbdhigjmlStatic 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"
kcfbdhigjmlDetection routine result:
detectGumJsLoopThread(); // returns false — strcmp("kcfbdhigjml", "gum-js-loop") != 0The 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.
