A Frida-Powered Xposed: Injecting Frida into Zygote for Serverless Hooking

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.

A Frida-Powered Xposed: Injecting Frida into Zygote for Serverless Hooking

Background

The main motivation: running a frida-server for any length of time destabilizes the system. It looks like the system restarts every so often — not a real reboot, but my screen-mirroring tool drops, Bluetooth drops, and the frida-server itself hangs, forcing a redo. The root cause is that frida’s server doesn’t just hook the target process — it also hooks system_server (for spawning and process enumeration). So when the server crashes, the whole system environment suffers.

So I wanted to integrate Frida the way Xposed is integrated: already loaded every time an app starts, ready to use directly.

Main features

When an app starts — before any of the app’s own code runs — a small server starts up. It accepts client commands, primarily three: load script, update script, and unload script, providing Frida-like functionality. It also supports configuration so the server only starts for target apps.

This makes Frida much more convenient:

  • No need to launch a server — the detection surface shrinks a lot.
  • Avoids the server’s side effects (destabilizing the system, etc.).
  • It starts before any app code, so you get control of the app at the earliest possible moment — useful for detection-avoidance work later.

Now for the implementation. Following Xposed’s example, we modify zygote — which requires some zygote background.

zygote startup

The corresponding files:

  • frameworks/base/cmds/app_process/app_main.cpp — the main function, corresponds to app_process64.
  • frameworks/base/core/jni/AndroidRuntime.cpp — the start function, corresponds to libandroid_runtime.so.
  • frameworks/base/core/java/com/android/internal/os/ZygoteInit.java — corresponds to framework.jar.

After zygote finishes its job (starting ART, preloading common system resources, forking system services), it starts a server and enters an idle loop, waiting for AMS to send it a fork request.

App startup

When zygote receives AMS’s fork request, it forks, as follows in frameworks/base/core/jni/com_android_internal_os_Zygote.cpp:

com_android_internal_os_Zygote_nativeForkAndSpecialize
    └─ ForkCommon
        └─ SpecializeCommon
            └─ SetThreadName(const std::string& thread_name)

From my observations, SetThreadName has very nice properties:

  1. It runs after the fork, i.e. in the app’s process space — a good place to start our server. If we ran before the fork, the service would die when fork executes, because fork is single-threaded.
  2. It’s before any app code runs, meaning we gain control at the earliest moment — good for detection-avoidance.
  3. It’s trivial to obtain the app name here, which makes filtering easy (start our service only for target apps).

Static injection

Write our logic in C++, compile it into an .so, and statically inject it into app_process64 with LIEF:

def inject_so_tail(src: str | Path,
                   new_so: str,
                   dst: str | Path | None = None) -> Path:
    bin_ = lief.parse(str(src))

    # 1. Record the old dependency order and remove all of them
    old_needed = [lib for lib in bin_.libraries if lib != new_so]
    for lib in list(bin_.libraries):
        bin_.remove_library(lib)

    # 2. Insert the new library first, then rebuild in reverse old-dependency order
    bin_.add_library(new_so)                              # now it's first
    for lib in reversed(old_needed):                      # insert each at the front
        bin_.add_library(lib)

    out = Path(dst) if dst else Path(str(src) + ".patched")
    bin_.write(str(out))
    return out

After patching, we use APatch to perform the replacement. APatch injects a program into the kernel to implement everything — KPM, APM, root, etc. — and exposes a syscall to user space that coordinates all functionality. The following program grants a thread supreme privileges (and the PID is not 0). Note that this thread, when executing commands, doesn’t carry high privileges — only the thread itself does:

#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <errno.h>
#include <stdlib.h>
#include <fcntl.h>    // for O_WRONLY, O_CREAT, O_TRUNC
#include <string.h>   // for strerror
#include <sys/stat.h> // for the stat structure and functions
#include <sys/types.h> // basic type definitions

// the SuperCall syscall number on most Android devices
#define __NR_supercall 45

// thread-level privilege-escalation command
#define SUPERCALL_THREAD_SU 0x1011

static inline int ver_and_cmd(const char *key, int cmd) {
    int v = 0;
    for (int i = 0; key[i]; ++i) {
        v += key[i];
    }
    return ((v & 0xffff) << 16) | (cmd & 0xffff);
}

int main(int argc, char *argv[]) {
    const char *key = "xxxxx";
    int tid = gettid();

    // perform privilege escalation
    long result = syscall(__NR_supercall, key, ver_and_cmd(key, SUPERCALL_THREAD_SU), tid, "u:r:magisk:s0");
    printf("privilege-escalation call return: %ld\n", result);

    // print some core info for a quick check
    printf("\n--- privilege verification info ---\n");
    system("cat /proc/self/status | grep -E 'Uid:|Gid:|CapEff:'");

    return 0;
}

That syscall will probably have big uses someday, but for now I only use the APM module. What APM does is replace app_process64 and mount our main .so into /system/lib64. The .so mainly does this:

Reads the config file, hooks SetThreadName, and checks whether this is a target process:

static void hook_thread() {
    // 1. Initialize Gum
    LOGD("[*] hook_thread() called");
    sleep(2);
    gum_init_embedded();
    GumAddress base_adress = get_module_base("libandroid_runtime.so");

    GumAddress SpecializeCommon = base_adress + (GumAddress)0x20AB70;
    GumAddress SetThreadName = base_adress + (GumAddress)0x2118A0;
    ExtractJString = (ExtractJStringFunc)(base_adress + (GumAddress)0x2115E0);

    auto on_enter = [](GumInvocationContext* ic, gpointer) {
        gpointer raw = gum_invocation_context_get_nth_argument(ic, 0);
        auto str = reinterpret_cast<const std::string*>(raw);
//        LOGD("[on_enter] SetThreadName %d %s", getpid(), str->c_str());
        entry(str->c_str());
    };

    auto on_leave = [](GumInvocationContext* ic, gpointer) {
        gpointer raw = gum_invocation_context_get_nth_argument(ic, 0);
        auto str = reinterpret_cast<const std::string*>(raw);
        LOGD("[on_leave] SetThreadName %d %s", getpid(), str->c_str());
    };

    gum_hook((GumAddress)SetThreadName,
             on_enter,
             nullptr,
             nullptr);

    LOGD("[*] hook_thread() finished");
}

__attribute__((constructor))
void test_entry() {
    std::thread t(hook_thread);
    t.detach();
}

If it’s a target process, we start two threads: one receives external commands (load script, etc.), the other handles the script operations using the frida-gumjs C API. The two threads communicate via ZeroMQ’s inproc; the server thread uses ZeroMQ sockets. ZeroMQ is a lightweight, powerful message middleware — using it avoids raw sockets, which are too low-level and demand handling many tedious details:

//__attribute__((constructor))
void entry(const char* name) {
//    LOGD("entry() called");

    json j = read_config("/system/lib64/dconfig.so");
    if (j.is_null() && !j.contains("process") || !j["process"].is_array()) {
        LOGD("process array not found or wrong type");
        return;
    }

    // iterate the process array
    for (auto& item : j["process"]) {
        std::string pname = item.value("name", "");
        std::string js_content = item.value("js_content", "");
        if (pname.find(name) == std::string::npos) {
            continue;
        } else {
            int port = get_free_port();
            static Server server(port);
            if (!server.initialize().success) {
                LOGD("Server init failed, process: %s", name);
                return;
            }
            LOGD("process name: %s, assigned port: %d", name, port);
            LOGD("process name: %s, script content: %s", pname.c_str(), base64_decode(js_content).c_str());
            void* context = server.getZmqContext();
            Agent::instance().startThread(context, js_content);
            server.start();
            return;
        }
    }
}
#ifndef AGENT_H
#define AGENT_H

#include <string>
#include "../include//frida-gumjs.h"
#include "../common/result.h"
#include "../include//zmq.h"
using common::Result;

class Agent {
public:
    static Agent& instance();

    Result initialize();
    Result load();
    Result unload();
    Result updateScriptContent(const std::string &content);
    Result cleanup();

    // new: handle string commands, start a thread listening on the inproc socket
    std::string handleCommand(const std::string& msg);
    void startThread(void* context, const std::string& init_script = "");  // internally initializes and starts the main loop
private:
    Agent();
    Agent(const Agent&) = delete;
    Agent& operator=(const Agent&) = delete;

    static void onMessage(const gchar* message, GBytes* data, gpointer user_data);
    void loop(void* zmqContext);
    bool isLoaded_;
    bool isInitialized_;
    std::string scriptContent_;
    std::string initScriptContent_;
    GumScriptBackend* backend_;
    GumScript* script_;
};

#endif // AGENT_H
#pragma once
/*
 * Server.h - network service class (simplified)
 *
 * Design notes:
 *  1. Object-oriented design, no centralized state block.
 *  2. Uses ZeroMQ (REP pattern) for external command send/receive.
 *  3. Provides initialize, start, stop, and message-handling interfaces.
 *  4. Simple LOGD logging.
 */

#include <string>
#include <thread>
#include <atomic>
#include "../common/result.h"  // Result class (assumed implemented)
#include <../include//zmq.h>

class Server {
public:
    // constructor: specify the listen port
    explicit Server(int port);

    // destructor: auto-stop the service, release resources
    ~Server();

    // initialize the service (create ZeroMQ context, socket, bind port)
    common::Result initialize();

    // start the network service thread, begin receiving client commands
    common::Result start();

    // stop the service, wait for the thread to exit and clean up
    common::Result stop();

    // handle an incoming JSON command, return a JSON string as the response (synchronous)
    std::string handleMessage(const std::string &msg);

    void* getZmqContext() const;

private:
    // listen port
    int port_;

    // service running flag
    std::atomic<bool> isRunning_;

    // ZeroMQ context and REP socket
    void* zmqContext_;
    void* zmqSocket_;
    void* agentSocket_;  // for inproc://agent

    // network service thread
    std::thread* networkThread_;

    // the network thread's main loop
    void networkLoop();

    // no copying
    Server(const Server&) = delete;
    Server& operator=(const Server&) = delete;
};

Here’s a simple client that sends scripts to the in-app server for execution, and watches the script for changes so it auto-updates the server:

const SERVER_ADDRESS = 'tcp://192.168.1.19:42803';
// main flow: ping -> unload -> update -> load -> watch for script changes
executePing(SERVER_ADDRESS)
  .then(() => executeUnload(SERVER_ADDRESS))
  .then(() => executeUpdate(SERVER_ADDRESS, './test2.js'))
  .then(() => executeLoad(SERVER_ADDRESS))
  .then(() => {
    console.log(' initial update and load done, watching script for changes...');
    monitorScript(SERVER_ADDRESS, './test2.js');
  })
  .catch(err => {
    console.error('❌ error:', err.message);
    suggestTroubleshooting();
    process.exit(1);
  });

The result:

root@DESKTOP:~$ node kagent_client.mjs
→ PING tcp://192.168.1.19:55253
↩︎ server raw response: {"message":"pong","success":true}
✅ ping ok
→ UNLOAD SCRIPT
↩︎ server raw response: {"message":"Script not loaded","success":true}
✅ unload ok
→ UPDATE test2.js (Base64-encoded, 128268 bytes)
↩︎ server raw response: {"message":"Script content updated","success":true}
✅ update ok
→ LOAD SCRIPT
↩︎ server raw response: {"message":"Script loaded successfully","success":true}
✅ load ok
 initial update and load done, watching script for changes...
initial script hash: 35c6525790bb9fd3ca008cbe1b9765b60112bdc03f8e85fc8a4eecae93f97b55

2025-04-26 21:23:20.594 17259-17259 giao  pid-17259  D  process name: com.tencent.mobileqq, assigned port: 55253
2025-04-26 21:23:20.595 17259-17259 giao  pid-17259  D  process name: com.tencent.mobileqq, script content:
2025-04-26 21:23:36.416 17259-17263 giao  com.tencent.mobileqq  D  onMessage: set ------------------------------------------------------------------------------
2025-04-26 21:23:36.416 17259-17263 giao  com.tencent.mobileqq  D  onMessage: set Process.id, 17259, Process.arch, arm64
2025-04-26 21:23:36.417 17259-17263 giao  com.tencent.mobileqq  D  onMessage: set 1111111
2025-04-26 21:23:36.417 17259-17263 giao  com.tencent.mobileqq  D  onMessage: set ------------------------------------------------------------------------------

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