How PRoot Works: A Source-Level Walkthrough

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. The target sample is anonymized throughout.

How PRoot Works: A Source-Level Walkthrough

1. Getting the source

PRoot is a userspace chroot-like tool built on ptrace. It lets you run programs against an alternate root filesystem without root privileges, by intercepting every filesystem-related syscall and rewriting its path arguments.

Source: https://github.com/proot-me/proot

2. The theory matters first

ptrace basics

  • Trace another process — let one process (the tracer) observe and control the execution of another (the tracee).
  • Read/write memory and registers — modify the tracee’s memory and register state.
  • Intercept signals and syscalls — pause the tracee when a syscall (or a signal like SIGTRAP) fires, so the tracer can handle it.

Common ptrace requests

You send requests through ptrace(request, pid, addr, data). The common ones:

  • PTRACE_TRACEME — the tracee declares itself traceable by its parent (typically a child process).
  • PTRACE_ATTACH — the tracer attaches to a running process (needs permission).
  • PTRACE_DETACH — detach and let the tracee continue.
  • PTRACE_PEEKTEXT/PTRACE_POKETEXT — read/write the tracee’s memory.
  • PTRACE_GETREGS/PTRACE_SETREGS — read/write the tracee’s registers (e.g. eax, ebx).
  • PTRACE_SYSCALL — stop the tracee at the next syscall entry and exit (for syscall interception).
  • PTRACE_CONT — resume the tracee.

Syscall interception

  • Intercepting syscalls — via PTRACE_SYSCALL, the tracer gets control before the syscall (entry) and after it (exit).
  • Modifying arguments/return values — at entry, you can rewrite the syscall arguments (through registers — eax holds the syscall number, ebx/ecx etc. hold the arguments); at exit, you can rewrite the return value (e.g. fake success or failure).

The key APIs come straight from the official documentation: https://man7.org/linux/man-pages/man2/ptrace.2.html

That’s enough theory. Let’s get hands-on.

3. A demo: tracing the main process’s openat calls

#include <string>
#include <unistd.h>
#include <android/log.h>
#include <linux/ptrace.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/reg.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/prctl.h>
#include <fcntl.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <linux/elf.h>
#include "Syscall_arm64.h"

#define LOG_TAG "NDK_LOG"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)

void test();

void ptrace_attach_pid(int pid);
void install_seccomp_filter(){
    struct sock_filter filter[] = {
            BPF_STMT(BPF_LD | BPF_W | BPF_ABS, (offsetof(struct seccomp_data, nr))),
            BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_openat, 0, 1),
            BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_TRACE),
            BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    };

    struct sock_fprog prog = {
            .len = (unsigned short) (sizeof(filter) / sizeof(filter[0])),
            .filter = filter,
    };
    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) {
        LOGD("prctl(PR_SET_NO_NEW_PRIVS)");
    }
    if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) == -1) {
        LOGD("when setting seccomp filter");
    }
}
void test() {
    LOGD("test begin ...");
    int mainPid = getpid();
    int childPid = fork();
    switch (childPid) {
        case -1:
            LOGE("fork fail");
            break;
        case 0:
            LOGD("child logic %d", childPid);
            LOGD("mainPid=%d", mainPid);
            ptrace_attach_pid(mainPid);
            break;
        default:
            LOGD("main process logic %d", mainPid);
            install_seccomp_filter();
            kill(getpid(), SIGSTOP);
            kill(getpid(), SIGCONT);
            LOGD("waitpid");
            break;
    }
    LOGD("test end ...");

}

void ptrace_attach_pid(int pid) {
    int status;

    if(ptrace(PTRACE_ATTACH,pid,0,0) == -1){
        LOGE("ptrace attach fail");
    }
    // set ptrace options
    const unsigned long default_ptrace_options = (
            PTRACE_O_TRACESYSGOOD |   // send SIGTRAP on syscall, with si_code set to SYS_SECCOMP
            PTRACE_O_TRACEFORK |      // trace fork/clone events
            PTRACE_O_TRACEVFORK |     // trace fork/clone events
            PTRACE_O_TRACEVFORKDONE | // trace fork/clone events
            PTRACE_O_TRACECLONE |     // trace fork/clone events
            PTRACE_O_TRACEEXEC |      // trace fork/clone events
            PTRACE_O_TRACEEXIT );     // trigger on tracee exit
    int state;
    state = ptrace(PTRACE_SETOPTIONS, pid, 0, default_ptrace_options | PTRACE_O_TRACESECCOMP);  // this option is especially important: it lets us trace seccomp-triggered events. When the tracee triggers a SIGSYS due to a seccomp rule, a SIGTRAP is sent with si_code set to SYS_SECCOMP.
    if(state == -1){
        LOGE("PTRACE_SETOPTIONS failed");
    }
    waitpid(pid, &status, 0);
    if ( WIFSTOPPED(status) && WSTOPSIG(status) == SIGSTOP){

        while (1){
            struct user_regs_struct regs;
            struct iovec io;
            io.iov_base = &regs;
            io.iov_len = sizeof(regs);
            ptrace(PTRACE_CONT, pid, 0, 0);
            waitpid(pid, &status, 0);
            ptrace(PTRACE_GETREGSET, pid, (void*)NT_PRSTATUS, &io);
            if (status >> 8 == (SIGTRAP | (PTRACE_EVENT_SECCOMP << 8)) ){
                LOGD("seccomp svc interrupt syscall number: %lx",regs.regs[8]);
                LOGD("seccomp svc reg0: %lx",regs.regs[0]);
                LOGD("seccomp svc reg1: %lx",regs.regs[1]);
                LOGD("seccomp svc reg2: %lx",regs.regs[2]);
                LOGD("seccomp svc path: %s",regs.regs[1]);
            }
            if (WIFEXITED(status)){
                break;
            }

        }
    }


}

That successfully monitors the main process’s openat syscall on Android using ptrace.

4. Reading the source

Let’s start from main.

main()

/* Main function: program entry point
 * @argc number of arguments
 * @argv argument array
 * @return program exit status */
int main(int argc, char *const argv[])
{
    Tracee *tracee;   // context object for the tracee
    int status;       // operation status return value

    /* Configure the memory allocator - enable leak reporting */
    talloc_enable_leak_report();

    /* TALLOC 2.0+ requires logging to stderr */
#if defined(TALLOC_VERSION_MAJOR) && TALLOC_VERSION_MAJOR >= 2
    talloc_set_log_stderr();
#endif

    /* Pre-create the first tracee (initial pid 0) */
    tracee = get_tracee(NULL, 0, true);
    if (tracee == NULL)
        goto error;
    tracee->pid = getpid();  // set the real process id

    /* Parse configuration */
    status = parse_config(tracee, argc, argv);
    if (status < 0)
        goto error;

    /* Launch the tracee */
    status = launch_process(tracee, &argv[status]);
    if (status < 0) {
        print_execve_help(tracee, tracee->exe, status); // show help on exec failure
        goto error;
    }

    /* Enter the event loop to trace the process and its children */
    exit(event_loop());  // exit when the event loop returns

/* Error handling */
error:
    TALLOC_FREE(tracee);  // free the tracee

    /* Exit based on error status */
    if (exit_failure) {
        fprintf(stderr, "fatal error: see `%s --help`.\n", basename(argv[0]));
        exit(EXIT_FAILURE);
    }
    else
        exit(EXIT_SUCCESS);
}

Right away we hit an unfamiliar struct, Tracee. Let’s understand it first.

The Tracee struct

typedef struct tracee {
	/**********************************************************************
	 * Private resources                                                  *
	 **********************************************************************/

	/* Link in the list of all tracees. */
	LIST_ENTRY(tracee) link;

	/* Process identifier. */
	pid_t pid;

	/* Is it currently running? */
	bool running;

	/* Parent of this tracee, or NULL if none. */
	struct tracee *parent;

	/* Is this a "clone" process (shares a parent with its creator)? */
	bool clone;

	/* ptrace emulation support (tracer side). */
	struct {
		size_t nb_ptracees;              // number of ptracees
		LIST_HEAD(zombies, tracee) zombies; // zombie process list

		struct direct_ptracees *direct_ptracees; // directly traced processes

		pid_t wait_pid;       // process id being waited on
		word_t wait_options;  // wait options

		/* Wait state: */
		enum {
			DOESNT_WAIT = 0,   // not waiting
			WAITS_IN_KERNEL,   // waiting in the kernel
			WAITS_IN_PROOT     // waiting in PRoot
		} waits_in;
	} as_ptracer;

	/* ptrace emulation support (tracee side). */
	struct {
		struct tracee *ptracer;  // the corresponding tracer

		struct {
			#define STRUCT_EVENT struct { int value; bool pending; }
			STRUCT_EVENT proot;    // PRoot-related event
			STRUCT_EVENT ptracer;  // tracer-related event
		} event4;

		bool tracing_started;            // has tracing started
		bool ignore_loader_syscalls;      // ignore loader syscalls
		bool ignore_syscalls;            // ignore all syscalls
		word_t options;                   // ptrace options
		bool is_zombie;                  // is it a zombie
	} as_ptracee;

	/* Current state:
	 *        0: syscall entry
	 *        1: normal syscall exit (no error)
	 *   -errno: syscall error exit (errno) */
	int status;

#define IS_IN_SYSENTER(tracee) ((tracee)->status == 0)        // at syscall entry
#define IS_IN_SYSEXIT(tracee) (!IS_IN_SYSENTER(tracee))        // at syscall exit
#define IS_IN_SYSEXIT2(tracee, sysnum) (IS_IN_SYSEXIT(tracee) \
				     && get_sysnum(tracee, ORIGINAL) == sysnum) // at the exit of the given syscall

	/* How this tracee is restarted. */
	enum __ptrace_request restart_how;

	/* General-purpose register values (multiple versions stored). */
	struct user_regs_struct _regs[NB_REG_VERSION];
	bool _regs_were_changed;           // were registers modified
	bool restore_original_regs;        // restore original register values

	/* Special SIGSTOP handling state. */
	enum {
		SIGSTOP_IGNORED = 0,  // ignore SIGSTOP (parent known)
		SIGSTOP_ALLOWED,       // allow SIGSTOP (parent known)
		SIGSTOP_PENDING,       // block SIGSTOP until the parent is unknown
	} sigstop;

	/* Context for temporary dynamic allocation. */
	TALLOC_CTX *ctx;

	/* Context for lifetime dynamic allocation (freed automatically at process release). */
	TALLOC_CTX *life_context;

	/* Note: "ctx" could be renamed "event_span", "life_context" to "life_span". */

	/* Type of the final glue component, set during bind-path init (defined by bind_path(), used by build_glue()). */
	mode_t glue_type;

	/* During child reconfiguration, the new config relative to this tracee's filesystem namespace.
	 * @paths holds its $PATH environment variable to emulate execvp(3) behavior. */
	struct {
		struct tracee *tracee;  // associated tracee
		const char *paths;      // PATH environment value
	} reconf;

	/* Chain of unsolicited syscalls inserted by PRoot after the real syscall. */
	struct {
		struct chained_syscalls *syscalls;  // chained syscalls
		bool force_final_result;            // force the final result
		word_t final_result;                // forced final result
	} chain;

	/* Load info generated at the execve syscall entry, used at exit. */
	struct load_info *load_info;

	/* Current state of the loading process. */
	struct {
		enum {
			LOADING_STEP_NONE = 0,  // not loading
			LOADING_STEP_OPEN,      // opening file
			LOADING_STEP_MMAP,      // memory mapping
			LOADING_STEP_CLOSE      // closing file
		} step;

		struct load_info *info;  // load info pointer
		size_t index;            // current step index
	} loading;

	/**********************************************************************
	 * Private but inheritable resources                                    *
	 **********************************************************************/

	/* Verbosity level. */
	int verbose;

	/* Seccomp acceleration state of this tracee. */
	enum { DISABLED = 0, DISABLING, ENABLED } seccomp;

	/* Ensure the syscall exit stage always fires under seccomp. */
	bool sysexit_pending;

	/**********************************************************************
	 * Shared or private resources (depending on CLONE_FS/VM flags)        *
	 **********************************************************************/

	/* Filesystem namespace info. */
	FileSystemNameSpace *fs;

	/* Virtual heap (simulated through regular memory mapping). */
	Heap *heap;

	/**********************************************************************
	 * Shared resources (until this process calls execve())                *
	 **********************************************************************/
	/* Executable path (like /proc/self/exe). */
	char *exe;

	/**********************************************************************
	 * Shared or private resources (depending on config/reconfig)          *
	 **********************************************************************/


	/* Emulator (QEMU) command-line arguments. */
	char **qemu;


	/* Glue path between the host and guest root filesystems. */
	const char *glue;
  /* Extensions enabled for this tracee. */ struct extensions *extensions;

/**********************************************************************
 * Shared but read-only resources                                      *
 **********************************************************************/
/* In mixed mode, the host LD_LIBRARY_PATH is saved during the "guest -> host"
 * translation so it can be restored on "host -> guest" translation (only if
 * the host LD_LIBRARY_PATH hasn't changed). */
const char *host_ldso_paths;
const char *guest_ldso_paths;
/* For diagnostic purposes */
const char *tool_name;
} Tracee;

With the struct translated above, it’s clear: Tracee is the observed process. We don’t care about its memory management — only its main execution flow.

Next: how get_tracee(NULL, 0, true) creates a Tracee.

get_tracee()

The definition lives in tracee.h:

/* Find or create the Tracee struct for the given process id */
Tracee *get_tracee(const Tracee *current_tracee, pid_t pid, bool create)
{
	Tracee *tracee;

	/* If the currently traced process is the one we want,
	 * return it directly to avoid resetting the memory collector,
	 * because the caller may hold pointers to sub-allocations */
	if (current_tracee != NULL && current_tracee->pid == pid)
		return (Tracee *)current_tracee;

	/* Walk the tracees list looking for a matching pid */
	LIST_FOREACH(tracee, &tracees, link) {
		if (tracee->pid == pid) {
			/* Free the old memory context and create a new allocator
			 * using the talloc memory management library */
			TALLOC_FREE(tracee->ctx);
			tracee->ctx = talloc_new(tracee);

			return tracee;
		}
	}

	/* Not found and creation is allowed -> new Tracee, else NULL */
	return (create ? new_tracee(pid) : NULL);
}

That’s enough depth. Next: parse_config.

parse_config()

/**
 * Configure @tracee according to the command-line arguments in @argv[].
 * This function returns the index in @argv[] of the command to launch, or -1 on error.
 */
static int parse_config(Tracee *tracee, size_t argc, char *const argv[])
{
option_handler_t handler = NULL; // handler for the current option
const Option *options; // CLI option list
const Cli *cli = NULL; // current CLI tool (CARE or PRoot)
size_t argc_offset; // start position of the command in argv
size_t i, j, k; // loop counters
int status; // operation status code

/* Check for a self-extracting CARE archive */
if (get_care_cli != NULL) {
// try to extract the archive from "/proc/self/exe" (is the current executable a CARE self-extracting package?)
status = extract_archive_from_file("/proc/self/exe");
if (status == 0) { // extracted successfully -> exit
exit_failure = 0; // mark normal exit
return -1;
}

 /* Check if the tool name is "care" (e.g. "care-3.4") */
 if (strncasecmp(basename(argv[0]), "care", strlen("care")) == 0)
     cli = get_care_cli(tracee->ctx); // get CARE's CLI config
}

/* Default to the PRoot CLI */
if (cli == NULL)
cli = get_proot_cli(tracee->ctx); // get PRoot's CLI config
tracee->tool_name = cli->name; // set the tool name (e.g. "proot")

/* Print usage when there are too few arguments */
if (argc == 1) {
print_usage(tracee, cli, false); // show help
return -1;
}

/* Iterate over all arguments */
for (i = 1; i < argc; i++) {
const char *arg = argv[i];

 /* Handle short option values (e.g. -o value, where handler already points to the handler) */
 if (handler != NULL) {
     status = handler(tracee, cli, arg); // call the previously set handler
     if (status < 0) return -1;     // error -> exit
     handler = NULL;                // reset the handler
     continue;
 }

 /* Stop parsing at the first non-option argument (e.g. the command) */
 if (arg[0] != '-') break;

 /* Iterate over all supported options */
 options = cli->options;             // get the option list for the current CLI
 for (j = 0; options[j].class != NULL; j++) { // iterate over each option class
     const Option *option = &options[j];

     /* Check all option aliases (e.g. -v and --verbose) */
     for (k = 0; ; k++) {            // iterate over the option's aliases
         const Argument *argument = &option->arguments[k];
         size_t length;

         /* No more aliases -> break */
         if (!argument->name) break;

         length = strlen(argument->name);
         if (strncmp(arg, argument->name, length) != 0)
             continue;              // no match -> skip

         /* Handle the option/value separator (e.g. -I/usr or -I /usr) */
         if (strlen(arg) > length && arg[length] != argument->separator) {
             print_error_separator(tracee, argument); // separator error (e.g. -I=usr)
             return -1;
         }

         /* Options without a value (e.g. --help) */
         if (!argument->value) {
             status = option->handler(tracee, cli, NULL); // call the handler
             if (status < 0) return -1;
             goto known_option;      // jump to follow-up processing
         }

         /* Options with a joined value (e.g. -I/usr, separator '/') */
         if (argument->separator == arg[length]) {
             status = option->handler(tracee, cli, &arg[length + 1]); // extract value
             if (status < 0) return -1;
             goto known_option;
         }

         /* Separator must be a space (e.g. -I /usr) */
         if (argument->separator != ' ') {
             print_error_separator(tracee, argument);
             return -1;
         }

         /* Short options need a following argument as value (e.g. -o value) */
         handler = option->handler; // set handler, wait for next argument
         goto known_option;
     }
 }

 /* Unknown option handling */
 note(tracee, ERROR, USER, "unknown option '%s'.", arg);
 return -1;
known_option:
/* Check for a missing option value (e.g. -o with no argument) */
if (handler != NULL && i == argc - 1) {
note(tracee, ERROR, USER, "missing value for option '%s'.", arg);
return -1;
}
}
argc_offset = i; // record the command start position (e.g. argv[3] is the command name)

/* Initialization-stage config via hook callbacks */
#define HOOK_CONFIG(callback)
do {
if (cli->callback != NULL) {
status = cli->callback(tracee, cli, argc, argv, i);
if (status < 0) return -1;
i = status; /* may adjust the argument index */
}
} while (0)

HOOK_CONFIG(pre_initialize_bindings); // pre-bindings-init handling

/* Parse user-bound paths (e.g. -b /host:/guest) */
status = initialize_bindings(tracee);
if (status < 0) return -1;

HOOK_CONFIG(post_initialize_bindings); // post-bindings-init handling
HOOK_CONFIG(pre_initialize_cwd); // pre-cwd-init handling

/* Set the current working directory */
status = initialize_cwd(tracee);
if (status < 0) return -1;

HOOK_CONFIG(post_initialize_cwd); // post-cwd-init handling
HOOK_CONFIG(pre_initialize_exe); // pre-exe-init handling

/* Resolve the target executable path */
status = initialize_exe(tracee, argv[argc_offset]);
if (status < 0) return -1;

HOOK_CONFIG(post_initialize_exe); // post-exe-init handling
#undef HOOK_CONFIG

print_config(tracee, &argv[argc_offset]); // print the final configuration

return argc_offset; // return the argv index where the command starts (e.g. argv[3])
}

This initializes based on the arguments — i.e. it initializes whatever comes after proot on the command line.

Then it launches the process.

launch_process()

/**
 * Launch @tracee->exe with the given @argv[] arguments.
 * This function returns -errno on error, otherwise 0.
 */
int launch_process(Tracee *tracee, char *const argv[])
{
	char *const default_argv[] = { "-", NULL };
	long status;
	pid_t pid;

	/* Warn about open file descriptors. They won't be
	 * translated until they are closed. */
	if (tracee->verbose > 0)
		list_open_fd(tracee);

	pid = fork();
	switch(pid) {
	case -1:
		note(tracee, ERROR, SYSTEM, "fork()");
		return -errno;

	case 0: /* child */
		/* Declare myself as ptraceable before executing the
		 * requested program. */
		status = ptrace(PTRACE_TRACEME, 0, NULL, NULL);
		if (status < 0) {
			note(tracee, ERROR, SYSTEM, "ptrace(TRACEME)");
			return -errno;
		}

		/* Synchronize with the tracer's event loop.  Without
		 * this trick the tracer only sees the "return" from
		 * the next execve(2) so PRoot wouldn't handle the
		 * interpreter/runner.  I also verified that strace
		 * does the same thing. */
		kill(getpid(), SIGSTOP);

		/* Improve performance by using seccomp mode 2, unless
		 * this support is explicitly disabled.  */
		if (getenv("PROOT_NO_SECCOMP") == NULL)
			(void) enable_syscall_filtering(tracee);

		/* Now process is ptraced, so the current rootfs is already the
		 * guest rootfs.  Note: Valgrind can't handle execve(2) on
		 * "foreign" binaries (ENOEXEC) but can handle execvp(3) on such
		 * binaries.  */
		execvp(tracee->exe, argv[0] != NULL ? argv : default_argv);
		return -errno;

	default: /* parent */
		/* We know the pid of the first tracee now.  */
		tracee->pid = pid;
		return 0;
	}

	/* Never reached.  */
	return -ENOSYS;
}

Here it forks a child process and turns on seccomp. Let’s look at enable_syscall_filtering.

enable_syscall_filtering()

/**
 * Tell the kernel to trace only the syscalls handled by PRoot and its extensions.
 * The filter applies to the given @tracee and all its future children.
 * Returns -errno on error, otherwise 0.
 */
int enable_syscall_filtering(const Tracee *tracee)
{
	FilteredSysnum *filtered_sysnums = NULL; // filtered syscall number list
	Extension *extension;                     // extension module pointer
	int status;                              // operation status code

	// ensure the tracee and its context are non-NULL
	assert(tracee != NULL && tracee->ctx != NULL);

	/* Add the syscall numbers PRoot needs to the filter list.
	 * TODO: only add them when path translation is needed */
	status = merge_filtered_sysnums(tracee->ctx, &filtered_sysnums, proot_sysnums);
	if (status < 0)
		return status;

	/* Merge the syscall numbers needed by the extension modules into the filter list */
	if (tracee->extensions != NULL) {
		// iterate over all extension modules
		LIST_FOREACH(extension, tracee->extensions, link) {
			if (extension->filtered_sysnums == NULL)
				continue;

			// merge the current extension's filter list
			status = merge_filtered_sysnums(tracee->ctx, &filtered_sysnums,
							extension->filtered_sysnums);
			if (status < 0)
				return status;
		}
	}

	// set the seccomp filter
	status = set_seccomp_filters(filtered_sysnums);
	if (status < 0)
		return status;

	return 0; // success
}

This is where the seccomp filter is set.

Once that’s done, the main process starts looping.

event_loop()

/**
 * Wait for and handle events from all tracees. Returns the exit status of the last program to terminate.
 */
int event_loop()
{
	struct sigaction signal_action;
	long status;
	int signum;

	/* Kill all tracees on exit */
	status = atexit(kill_all_tracees);
	if (status != 0)
		note(NULL, WARNING, INTERNAL, "atexit() failed");

	/* Block all signals while the signal handler runs.
	 * SIGINFO marks the signal source process, RESTART restarts waitpid(2) seamlessly */
	bzero(&signal_action, sizeof(signal_action));
	signal_action.sa_flags = SA_SIGINFO | SA_RESTART;
	status = sigfillset(&signal_action.sa_mask);
	if (status < 0)
		note(NULL, WARNING, SYSTEM, "sigfillset() error");

	/* Iterate over all possible signals and configure handling */
	for (signum = 0; signum < SIGRTMAX; signum++) {
		switch (signum) {
		case SIGQUIT:  // termination request
		case SIGILL:   // illegal instruction
		case SIGABRT:  // abnormal abort
		case SIGFPE:   // arithmetic error
		case SIGSEGV:  // segmentation fault
			/* Kill all tracees on abnormal termination to ensure no leftovers */
			signal_action.sa_sigaction = kill_all_tracees2;
			break;

		case SIGUSR1:  // user signal 1
		case SIGUSR2:  // user signal 2
			/* Debug: print the full talloc memory hierarchy to stderr */
			signal_action.sa_sigaction = print_talloc_hierarchy;
			break;

		case SIGCHLD:   // child status change
		case SIGCONT:   // continue execution
		case SIGSTOP:   // stop process
		case SIGTSTP:   // terminal stop request
		case SIGTTIN:   // background read from terminal
		case SIGTTOU:   // background write to terminal
			/* Keep default behavior for terminal and job-control signals */
			continue;

		default:
			/* Ignore other signals (e.g. termination signal SIGINT/^C) */
			signal_action.sa_sigaction = (void *)SIG_IGN;
			break;
		}

		/* Register the signal handler */
		status = sigaction(signum, &signal_action, NULL);
		if (status < 0 && errno != EINVAL)
			note(NULL, WARNING, SYSTEM, "sigaction(%d) error", signum);
	}

	/* Main event loop */
	while (1) {
		int tracee_status;  // tracee status
		Tracee *tracee;      // tracee object
		int signal;          // received signal
		pid_t pid;           // process id

		/* Wait for any tracee's status change */
		pid = waitpid(-1, &tracee_status, __WALL);
		if (pid < 0) {
			if (errno != ECHILD) {  // no children -> normal exit
				note(NULL, ERROR, SYSTEM, "waitpid() error");
				return EXIT_FAILURE;
			}
			break;  // all children exited, end the loop
		}

		/* Get the info for the stopped process */
		tracee = get_tracee(NULL, pid, true);
		assert(tracee != NULL);

		tracee->running = false;  // mark as stopped

		/* Notify extension modules of the new status */
		status = notify_extensions(tracee, NEW_STATUS, tracee_status, 0);
		if (status != 0)
			continue;

		/* Handle ptrace events */
		if (tracee->as_ptracee.ptracer != NULL) {
			bool keep_stopped = handle_ptracee_event(tracee, tracee_status);
			if (keep_stopped)
				continue;  // keep stopped, don't restart the process
		}

		/* Handle the event and get the signal to deliver */
		signal = handle_tracee_event(tracee, tracee_status);
		/* Restart the tracee (possibly with a signal) */
		(void) restart_tracee(tracee, signal);
	}

	return last_exit_status;  // return the last exit status code
}

Let’s follow handle_ptracee_event to see how it handles ptrace calls.

handle_ptracee_event()

/**
 * For the given @ptracee, if its tracer is waiting on the current @event,
 * forward the event to the tracer; otherwise put @ptracee in the "waiting for tracer" state.
 * The return value indicates whether @ptracee should stay stopped.
 */
bool handle_ptracee_event(Tracee *ptracee, int event)
{
    bool handled_by_proot_first = false;  // should PRoot handle this event first
    Tracee *ptracer = PTRACEE.ptracer;    // get the tracer (parent process)
    bool keep_stopped;                    // return value: stay stopped

    assert(ptracer != NULL);              // ensure the tracer exists

    /* Save the original event info for PRoot's later processing */
    PTRACEE.event4.proot.value   = event;  // store the event value
    PTRACEE.event4.proot.pending = true;   // mark the event as pending

    /* By default keep the ptracee stopped until the tracer resumes it */
    keep_stopped = true;

    /* Handle signal-stop events (WIFSTOPPED) */
    if (WIFSTOPPED(event)) {
        switch ((event & 0xfff00) >> 8) {  // extract the high signal type
        case SIGTRAP | 0x80:              // syscall exit event (with syscall-good flag)
            if (PTRACEE.ignore_syscalls || PTRACEE.ignore_loader_syscalls)
                return false;              // ignore syscalls -> return without staying stopped

            if ((PTRACEE.options & PTRACE_O_TRACESYSGOOD) == 0)  // TRACESYSGOOD not enabled
                event &= ~(0x80 << 8);     // clear the high flag

            handled_by_proot_first = IS_IN_SYSEXIT(ptracee);  // PRoot handles the syscall exit stage
            break;

        // Macro: handle specific trace events (FORK/VFORK/etc.)
#define PTRACE_EVENT_VFORKDONE PTRACE_EVENT_VFORK_DONE  // compatibility definition
#define CASE_FILTER_EVENT(name) \
        case SIGTRAP | PTRACE_EVENT_ ##name << 8:       \
            if ((PTRACEE.options & PTRACE_O_TRACE ##name) == 0) \  // corresponding trace option not enabled
                return false;                           \
            PTRACEE.tracing_started = true;             \  // mark tracing started
            handled_by_proot_first = true;               \  // PRoot handles first
            break;

        CASE_FILTER_EVENT(FORK);     // handle fork
        CASE_FILTER_EVENT(VFORK);    // handle vfork
        CASE_FILTER_EVENT(VFORKDONE);// handle vfork-done
        CASE_FILTER_EVENT(CLONE);    // handle clone
        CASE_FILTER_EVENT(EXIT);     // handle process exit
        CASE_FILTER_EVENT(EXEC);     // handle exec

        /* Unreachable code, trigger an assertion */
        assert(0);

        // unsupported event types (e.g. seccomp)
        case SIGTRAP | PTRACE_EVENT_SECCOMP2 << 8:
        case SIGTRAP | PTRACE_EVENT_SECCOMP << 8:
            return false;  // return without handling

        default:  // other signal-stop events
            PTRACEE.tracing_started = true;  // mark tracing started
            break;
        }
    }
    /* Handle process exit or signal-termination events */
    else if (WIFEXITED(event) || WIFSIGNALED(event)) {
        PTRACEE.tracing_started = true;  // mark tracing started
        keep_stopped = false;            // process terminated, no need to stay stopped
    }

    /* If tracing hasn't started yet (e.g. first event after TRACEME), return */
    if (!PTRACEE.tracing_started)
        return false;

    /* PRoot handles the event first (e.g. syscall exit) */
    if (handled_by_proot_first) {
        int signal = handle_tracee_event(ptracee, PTRACEE.event4.proot.value);
        PTRACEE.event4.proot.value = signal;  // update the handled signal value
        assert(signal == 0);  // under current logic the signal should be 0 (sysexit needs no signal)
    }

    /* Save the handled event info for the tracer */
    PTRACEE.event4.ptracer.value   = event;    // record the final event value
    PTRACEE.event4.ptracer.pending = true;     // mark the event pending for the tracer

    /* Asynchronously notify the tracer (send SIGCHLD to mimic kernel behavior) */
    kill(ptracer->pid, SIGCHLD);

    /* If the tracer is waiting on this ptracee's event */
    if ((PTRACER.wait_pid == -1 || PTRACER.wait_pid == ptracee->pid) &&
        EXPECTED_WAIT_CLONE(PTRACER.wait_options, ptracee)) {
        bool restarted;
        int status = update_wait_status(ptracer, ptracee);  // update the wait status
        poke_reg(ptracer, SYSARG_RESULT, (word_t) status);  // modify the register return value

        (void) push_regs(ptracer);  // write back the register cache

        /* Restart the tracer */
        PTRACER.wait_pid = 0;
        restarted = restart_tracee(ptracer, 0);  // try to resume the tracer
        if (!restarted)  // restart failed -> no longer stay stopped
            keep_stopped = false;
    }

    return keep_stopped;  // return whether the ptracee stays stopped
}

// Key logic notes:
// 1. Layered event handling:
//    - the original event (e.g. syscall exit) is handled by PRoot first (e.g. cleanup after emulation)
//    - the handled event is then forwarded to the tracer (e.g. GDB), mimicking the kernel's ptrace event delivery
//
// 2. State synchronization:
//    - SIGCHLD notifies the tracer that an event is pending
//    - if the tracer is already in waitpid, directly update its register state and wake it, reducing latency
//
// 3. Stop-state decisions:
//    - on process termination (EXITED/SIGNALED) we must return false to avoid lingering zombies
//    - by default stay stopped until the tracer explicitly calls PTRACE_CONT
//
// Potential issues:
// 1. Event mask computation:
//    - `(event & 0xfff00) >> 8` assumes the high bits hold the event type; must stay consistent with the kernel
//    - if PTRACE_EVENT_* encoding changes, this could filter incorrectly
//
// 2. Async race condition:
//    - after kill() sends SIGCHLD, the tracer may not have entered waitpid yet, losing the signal
//    - relies on the kernel's ptrace semantics to guarantee the event isn't lost
//
// 3. Macro expansion risk:
//    - CASE_FILTER_EVENT(EXEC) depends on PTRACE_O_TRACEEXEC existing
//    - if the ptrace implementation is incompatible with some options, conditional compilation is needed

Next is handling the tracee: handle_tracee_event.

handle_tracee_event()

/**
 * Handle the current event (@tracee_status) of the tracee.
 * Returns the "computed" signal used to resume the process.
 */
int handle_tracee_event(Tracee *tracee, int tracee_status)
{
	static bool seccomp_detected = false;  // static flag: is seccomp detected
	pid_t pid = tracee->pid;               // tracee pid
	long status;                           // syscall status
	int signal;                            // signal to send


	/* If the restart method wasn't explicitly set (e.g. in single-step ptrace emulation),
	   set the default */
	if (tracee->restart_how == 0) {
		/* When seccomp is enabled, all events restart in non-stop mode, but this may need
		   to change later. Check "sysexit_pending" to make sure other events (e.g. the
		   PTRACE_EVENT_EXEC of an execve exit) don't clear the PTRACE_SYSCALL used for
		   the seccomp exit stage */
		if (tracee->seccomp == ENABLED && !tracee->sysexit_pending)
			tracee->restart_how = PTRACE_CONT;   // continue execution
		else
			tracee->restart_how = PTRACE_SYSCALL; // stop at the next syscall
	}


	/* Not a signal-triggered stop by default */
	signal = 0;


	// handle process exit
	if (WIFEXITED(tracee_status)) {
		last_exit_status = WEXITSTATUS(tracee_status);
		VERBOSE(tracee, 1, "pid %d: exited with status %d", pid, last_exit_status);
	}
	// handle signal termination
	else if (WIFSIGNALED(tracee_status)) {
		check_architecture(tracee);
		VERBOSE(tracee, (int) (last_exit_status != -1),
			"pid %d: terminated by signal %d", pid, WTERMSIG(tracee_status));
	}
	// handle stop state
	else if (WIFSTOPPED(tracee_status)) {
		/* Don't use WSTOPSIG() to extract the signal, because it clears the PTRACE_EVENT_* flags */
		signal = (tracee_status & 0xfff00) >> 8;  // decode the signal


		switch (signal) {
			static bool deliver_sigtrap = false;  // static flag controlling SIGTRAP delivery


		case SIGTRAP: {  // breakpoint / single-step exception
			// default ptrace monitoring options
			const unsigned long default_ptrace_options = (
				PTRACE_O_TRACESYSGOOD	|  // send SIGTRAP|0x80 on syscall
				PTRACE_O_TRACEFORK	    |  // trace fork
				PTRACE_O_TRACEVFORK	    |  // trace vfork
				PTRACE_O_TRACEVFORKDONE	|  // trace vfork-done
				PTRACE_O_TRACEEXEC	    |  // trace exec
				PTRACE_O_TRACECLONE	    |  // trace clone
				PTRACE_O_TRACEEXIT);      // trace process exit


			/* Distinguish event types; automatically set the same trace options
			   for new processes. Only the first pure SIGTRAP is related to the trace
			   loop; the other SIGTRAPs carry TRACE*FORK/CLONE/EXEC info */
			if (deliver_sigtrap)
				break;  // deliver the signal directly


			deliver_sigtrap = true;


			/* Try to enable seccomp mode 2... */
			status = ptrace(PTRACE_SETOPTIONS, tracee->pid, NULL,
					default_ptrace_options | PTRACE_O_TRACESECCOMP);
			if (status < 0) {
				/* ...otherwise use only the default options */
				status = ptrace(PTRACE_SETOPTIONS, tracee->pid, NULL,
						default_ptrace_options);
				if (status < 0) {
					note(tracee, ERROR, SYSTEM, "ptrace(PTRACE_SETOPTIONS failed)");
					exit(EXIT_FAILURE);
				}
			}
		}
			/* continue processing */
		case SIGTRAP | 0x80:  // SIGTRAP with the syscall flag
			signal = 0;


			/* When the tracee was released at syscall entry but the kernel still reports
			   the exit stage, drop this invalid tracee/event */
			if (tracee->exe == NULL) {
				tracee->restart_how = PTRACE_CONT;
				return 0;
			}


			// handle the syscall based on seccomp state
			switch (tracee->seccomp) {
			case ENABLED:  // seccomp enabled
				if (IS_IN_SYSENTER(tracee)) {  // syscall entry stage
					tracee->restart_how = PTRACE_SYSCALL; // capture the exit stage
					tracee->sysexit_pending = true;       // mark exit stage pending
				}
				else {                          // syscall exit stage
					tracee->restart_how = PTRACE_CONT;    // continue directly
					tracee->sysexit_pending = false;      // clear the exit flag
				}
				/* continue processing */
			case DISABLED:  // seccomp disabled
				translate_syscall(tracee);  // translate the syscall


				/* The current syscall has disabled seccomp */
				if (tracee->seccomp == DISABLING) {
					tracee->restart_how = PTRACE_SYSCALL;
					tracee->seccomp = DISABLED;
				}


				break;


			case DISABLING:  // seccomp disabling
				/* The previous syscall disabled seccomp,
				   but its entry stage has already been handled */
				tracee->seccomp = DISABLED;
				if (IS_IN_SYSENTER(tracee))
					tracee->status = 1;
				break;
			}
			break;


		// handle seccomp events (mode 2 or raw mode)
		case SIGTRAP | PTRACE_EVENT_SECCOMP2 << 8:
		case SIGTRAP | PTRACE_EVENT_SECCOMP << 8: {
			unsigned long flags = 0;


			signal = 0;


			if (!seccomp_detected) {
				VERBOSE(tracee, 1, "ptrace acceleration enabled (seccomp mode 2)");
				tracee->seccomp = ENABLED;
				seccomp_detected = true;
			}


			/* If this tracee has explicitly disabled seccomp, use the normal ptrace flow */
			if (tracee->seccomp != ENABLED)
				break;


			status = ptrace(PTRACE_GETEVENTMSG, tracee->pid, NULL, &flags);
			if (status < 0)
				break;


			/* When the syscall exit stage must be handled, use the normal ptrace flow */
			if ((flags & FILTER_SYSEXIT) != 0) {
				tracee->restart_how = PTRACE_SYSCALL;
				break;
			}


			/* Otherwise handle the syscall entry stage immediately */
			tracee->restart_how = PTRACE_CONT;
			translate_syscall(tracee);


			/* If this call disabled seccomp, switch back to the normal flow to handle the exit stage */
			if (tracee->seccomp == DISABLING)
				/* set the tracee's syscall restart method to PTRACE_SYSCALL (stop at entry and exit) */
                tracee->restart_how = PTRACE_SYSCALL;

/* Mark this tracee's seccomp state as disabled */
                tracee->seccomp = DISABLED;

                break; // exit the current switch branch

case DISABLING: // handle the seccomp-disabling state
    /*
     * The previous syscall disabled seccomp,
     * but its sysenter stage (syscall entry) was already handled.
     */
    tracee->seccomp = DISABLED; // update the state to fully disabled

    /* If currently in the sysenter stage, set the status flag to 1 */
    if (IS_IN_SYSENTER(tracee))
        tracee->status = 1;
    break;

break; // exit the outer switch

/* Handle SECCOMP-related ptrace events */
case SIGTRAP | PTRACE_EVENT_SECCOMP2 << 8:
case SIGTRAP | PTRACE_EVENT_SECCOMP << 8: {
    unsigned long flags = 0;
    signal = 0; // reset the signal: don't deliver a signal to the tracee

    /* Initialize on the first seccomp detection */
    if (!seccomp_detected) {
        VERBOSE(tracee, 1, "ptrace acceleration enabled (seccomp mode 2)");
        tracee->seccomp = ENABLED; // enable seccomp tracing
        seccomp_detected = true;   // set the global detection flag
    }

    /* If this tracee hasn't enabled seccomp, use the normal ptrace flow */
    if (tracee->seccomp != ENABLED)
        break;

    /* Get the filter flags from the event message */
    status = ptrace(PTRACE_GETEVENTMSG, tracee->pid, NULL, &flags);
    if (status < 0)
        break;

    /* When sysexit (syscall exit) must be handled, use the normal flow */
    if ((flags & FILTER_SYSEXIT) != 0) {
        tracee->restart_how = PTRACE_SYSCALL; // capture entry and exit
        break;
    }

    /* Otherwise handle the sysenter stage immediately */
    tracee->restart_how = PTRACE_CONT; // continue until the next event
    translate_syscall(tracee);         // handle the syscall arguments / emulation

    /* If this syscall disabled seccomp, switch back to the normal path to handle sysexit */
    if (tracee->seccomp == DISABLING)
        tracee->restart_how = PTRACE_SYSCALL;
    break;
}

/* Handle vfork events */
case SIGTRAP | PTRACE_EVENT_VFORK << 8:
    signal = 0;
    (void) new_child(tracee, CLONE_VFORK); // create a vfork child tracee
    break;

/* Handle fork/clone events */
case SIGTRAP | PTRACE_EVENT_FORK  << 8:
case SIGTRAP | PTRACE_EVENT_CLONE << 8:
    signal = 0;
    (void) new_child(tracee, 0); // create a normal child tracee
    break;

/* Handle other events (no special action) */
case SIGTRAP | PTRACE_EVENT_VFORK_DONE << 8:
case SIGTRAP | PTRACE_EVENT_EXEC  << 8:
case SIGTRAP | PTRACE_EVENT_EXIT  << 8:
    signal = 0; // just clear the signal
    break;

/* Handle SIGSTOP */
case SIGSTOP:
    /* When the process image isn't set, suspend tracing until a fork/clone notification arrives */
    if (tracee->exe == NULL) {
        tracee->sigstop = SIGSTOP_PENDING; // set the pending state
        signal = -1; // block signal delivery
    }

    /* For each tracee, the first SIGSTOP only notifies the tracer */
    if (tracee->sigstop == SIGSTOP_IGNORED) {
        tracee->sigstop = SIGSTOP_ALLOWED; // mark as handled
        signal = 0; // allow subsequent delivery
    }
    break;

default:
    /* Other signals are delivered directly to the tracee */
    break;
}

Next comes syscall handling.

translate_syscall()

/**
 * Syscall translation core function - handles register operations at syscall entry/exit.
 * @param tracee the tracee's context
 */
void translate_syscall(Tracee *tracee)
{
    const bool is_enter_stage = IS_IN_SYSENTER(tracee); // current stage: syscall entry
    int status;

    assert(tracee->exe != NULL); // ensure the target executable is loaded

    /* Get the current register state */
    status = fetch_regs(tracee);
    if (status < 0)
        return; // failed -> return

    if (is_enter_stage) {
        /* ==== syscall entry stage handling ==== */

        /* Mark that the original registers need not be restored at the end of this stage */
        tracee->restore_original_regs = false;

        print_current_regs(tracee, 3, "sysenter start"); // debug output: register state at verbosity level 3

        /* Only handle real user-requested syscalls (not PRoot's internal chained calls) */
        if (tracee->chain.syscalls == NULL) {
            save_current_regs(tracee, ORIGINAL);      // save the original register snapshot
            status = translate_syscall_enter(tracee); // run the syscall entry translation
            save_current_regs(tracee, MODIFIED);      // save the modified register state
        }
        else {
            /* Chained call handling: notify extension modules */
            status = notify_extensions(tracee, SYSCALL_CHAINED_ENTER, 0, 0);
            tracee->restart_how = PTRACE_SYSCALL; // set ptrace to full tracing mode
        }

        /* Error handling */
        if (status < 0) {
            set_sysnum(tracee, PR_void); // set the syscall number to an invalid value
            poke_reg(tracee, SYSARG_RESULT, (word_t) status); // write the error code into the result register
            tracee->status = status;    // record the error status
        }
        else
            tracee->status = 1; // mark normal status

        /* Special case: when continuing directly via PTRACE_CONT, restore the stack pointer */
        if (tracee->restart_how == PTRACE_CONT) {
            tracee->status = 0;
            poke_reg(tracee, STACK_POINTER,
                peek_reg(tracee, ORIGINAL, STACK_POINTER)); // restore the original stack pointer
        }
    }
    else {
        /* ==== syscall exit stage handling ==== */

        /* By default restore the original registers at the end of the exit stage */
        tracee->restore_original_regs = true;

        print_current_regs(tracee, 5, "sysexit start"); // debug output: verbosity level 5

        /* Only handle the exit of real syscalls */
        if (tracee->chain.syscalls == NULL)
            translate_syscall_exit(tracee); // run the exit-stage translation
        else
            (void) notify_extensions(tracee, SYSCALL_CHAINED_EXIT, 0, 0);

        tracee->status = 0; // reset the status

        /* Chained call handling: run the next chained syscall */
        if (tracee->chain.syscalls != NULL)
            chain_next_syscall(tracee); // load the next syscall's arguments
    }

    /* Write the modified registers back to the tracee */
    (void) push_regs(tracee);

    /* Stage-end debug output */
    if (is_enter_stage)
        print_current_regs(tracee, 5, "sysenter end");
    else
        print_current_regs(tracee, 4, "sysexit end");
}

Inside it is the function that handles the syscall.

translate_syscall_enter()

/**
 * Translate the input arguments of the current @tracee's syscall in the
 * @tracee->pid process area. This function sets @tracee->status to
 * -errno if an error occured from the tracee's point-of-view (EFAULT
 * for instance), otherwise 0.
 */
int translate_syscall_enter(Tracee *tracee)
{
	int flags;
	int dirfd;
	int olddirfd;
	int newdirfd;

	int status;
	int status2;

	char path[PATH_MAX];
	char oldpath[PATH_MAX];
	char newpath[PATH_MAX];

	word_t syscall_number;
	bool special = false;

	status = notify_extensions(tracee, SYSCALL_ENTER_START, 0, 0);
	if (status < 0)
		goto end;
	if (status > 0)
		return 0;

	/* Translate input arguments. */
	syscall_number = get_sysnum(tracee, ORIGINAL);
	switch (syscall_number) {
	default:
		/* Nothing to do. */
		status = 0;
		break;

	case PR_execve:
		status = translate_execve_enter(tracee);
		break;

	case PR_ptrace:
		status = translate_ptrace_enter(tracee);
		break;

	case PR_wait4:
	case PR_waitpid:
		status = translate_wait_enter(tracee);
		break;

	case PR_brk:
		status = translate_brk_enter(tracee);
		break;

	case PR_getcwd:
		set_sysnum(tracee, PR_void);
		status = 0;
		break;

	case PR_fchdir:
	case PR_chdir: {
		struct stat statl;
		char *tmp;

		/* The ending "." ensures an error will be reported if
		 * path does not exist or if it is not a directory.  */
		if (syscall_number == PR_chdir) {
			status = get_sysarg_path(tracee, path, SYSARG_1);
			if (status < 0)
				break;

			status = join_paths(2, oldpath, path, ".");
			if (status < 0)
				break;

			dirfd = AT_FDCWD;
		}
		else {
			strcpy(oldpath, ".");
			dirfd = peek_reg(tracee, CURRENT, SYSARG_1);
		}

		status = translate_path(tracee, path, dirfd, oldpath, true);
		if (status < 0)
			break;

		status = lstat(path, &statl);
		if (status < 0)
			break;

		/* Check this directory is accessible.  */
		if ((statl.st_mode & S_IXUSR) == 0)
			return -EACCES;

		/* Sadly this method doesn't detranslate statefully,
		 * this means that there's an ambiguity when several
		 * bindings are from the same host path:
		 *
		 *    $ proot -m /tmp:/a -m /tmp:/b fchdir_getcwd /a
		 *    /b
		 *
		 *    $ proot -m /tmp:/b -m /tmp:/a fchdir_getcwd /a
		 *    /a
		 *
		 * A solution would be to follow each file descriptor
		 * just like it is done for cwd.
		 */

		status = detranslate_path(tracee, path, NULL);
		if (status < 0)
			break;

		/* Remove the trailing "/" or "/.".  */
		chop_finality(path);

		tmp = talloc_strdup(tracee->fs, path);
		if (tmp == NULL) {
			status = -ENOMEM;
			break;
		}
		TALLOC_FREE(tracee->fs->cwd);

		tracee->fs->cwd = tmp;
		talloc_set_name_const(tracee->fs->cwd, "$cwd");

		set_sysnum(tracee, PR_void);
		status = 0;
		break;
	}

	case PR_bind:
	case PR_connect: {
		word_t address;
		word_t size;

		address = peek_reg(tracee, CURRENT, SYSARG_2);
		size    = peek_reg(tracee, CURRENT, SYSARG_3);

		status = translate_socketcall_enter(tracee, &address, size);
		if (status <= 0)
			break;

		poke_reg(tracee, SYSARG_2, address);
		poke_reg(tracee, SYSARG_3, sizeof(struct sockaddr_un));

		status = 0;
		break;
	}

#define SYSARG_ADDR(n) (args_addr + ((n) - 1) * sizeof_word(tracee))

#define PEEK_WORD(addr, forced_errno)		\
	peek_word(tracee, addr);		\
	if (errno != 0) {			\
		status = forced_errno ?: -errno; \
		break;				\
	}

#define POKE_WORD(addr, value)			\
	poke_word(tracee, addr, value);		\
	if (errno != 0) {			\
		status = -errno;		\
		break;				\
	}

	case PR_accept:
	case PR_accept4:
		/* Nothing special to do if no sockaddr was specified.  */
		if (peek_reg(tracee, ORIGINAL, SYSARG_2) == 0) {
			status = 0;
			break;
		}
		special = true;
		/* Fall through.  */
	case PR_getsockname:
	case PR_getpeername:{
		int size;

		/* Remember: PEEK_WORD puts -errno in status and breaks if an
		 * error occured.  */
		size = (int) PEEK_WORD(peek_reg(tracee, ORIGINAL, SYSARG_3), special ? -EINVAL : 0);

		/* The "size" argument is both used as an input parameter
		 * (max. size) and as an output parameter (actual size).  The
		 * exit stage needs to know the max. size to not overwrite
		 * anything, that's why it is copied in the 6th argument
		 * (unused) before the kernel updates it.  */
		poke_reg(tracee, SYSARG_6, size);

		status = 0;
		break;
	}

	case PR_socketcall: {
		word_t args_addr;
		word_t sock_addr_saved;
		word_t sock_addr;
		word_t size_addr;
		word_t size;

		args_addr = peek_reg(tracee, CURRENT, SYSARG_2);

		switch (peek_reg(tracee, CURRENT, SYSARG_1)) {
		case SYS_BIND:
		case SYS_CONNECT:
			/* Handle these cases below.  */
			status = 1;
			break;

		case SYS_ACCEPT:
		case SYS_ACCEPT4:
			/* Nothing special to do if no sockaddr was specified.  */
			sock_addr = PEEK_WORD(SYSARG_ADDR(2), 0);
			if (sock_addr == 0) {
				status = 0;
				break;
			}
			special = true;
			/* Fall through.  */
		case SYS_GETSOCKNAME:
		case SYS_GETPEERNAME:
			/* Remember: PEEK_WORD puts -errno in status and breaks
			 * if an error occured.  */
			size_addr =  PEEK_WORD(SYSARG_ADDR(3), 0);
			size = (int) PEEK_WORD(size_addr, special ? -EINVAL : 0);

			/* See case PR_accept for explanation.  */
			poke_reg(tracee, SYSARG_6, size);
			status = 0;
			break;

		default:
			status = 0;
			break;
		}

		/* An error occured or there's nothing else to do.  */
		if (status <= 0)
			break;

		/* Remember: PEEK_WORD puts -errno in status and breaks if an
		 * error occured.  */
		sock_addr = PEEK_WORD(SYSARG_ADDR(2), 0);
		size      = PEEK_WORD(SYSARG_ADDR(3), 0);

		sock_addr_saved = sock_addr;
		status = translate_socketcall_enter(tracee, &sock_addr, size);
		if (status <= 0)
			break;

		/* These parameters are used/restored at the exit stage.  */
		poke_reg(tracee, SYSARG_5, sock_addr_saved);
		poke_reg(tracee, SYSARG_6, size);

		/* Remember: POKE_WORD puts -errno in status and breaks if an
		 * error occured.  */
		POKE_WORD(SYSARG_ADDR(2), sock_addr);
		POKE_WORD(SYSARG_ADDR(3), sizeof(struct sockaddr_un));

		status = 0;
		break;
	}

#undef SYSARG_ADDR
#undef PEEK_WORD
#undef POKE_WORD

	case PR_access:
	case PR_acct:
	case PR_chmod:
	case PR_chown:
	case PR_chown32:
	case PR_chroot:
	case PR_getxattr:
	case PR_listxattr:
	case PR_mknod:
	case PR_oldstat:
	case PR_creat:
	case PR_removexattr:
	case PR_setxattr:
	case PR_stat:
	case PR_stat64:
	case PR_statfs:
	case PR_statfs64:
	case PR_swapoff:
	case PR_swapon:
	case PR_truncate:
	case PR_truncate64:
	case PR_umount:
	case PR_umount2:
	case PR_uselib:
	case PR_utime:
	case PR_utimes:
		status = translate_sysarg(tracee, SYSARG_1, REGULAR);
		break;

	case PR_open:
		flags = peek_reg(tracee, CURRENT, SYSARG_2);

		if (   ((flags & O_NOFOLLOW) != 0)
		    || ((flags & O_EXCL) != 0 && (flags & O_CREAT) != 0))
			status = translate_sysarg(tracee, SYSARG_1, SYMLINK);
		else
			status = translate_sysarg(tracee, SYSARG_1, REGULAR);
		break;

	case PR_fchownat:
	case PR_fstatat64:
	case PR_newfstatat:
	case PR_utimensat:
	case PR_name_to_handle_at:
		dirfd = peek_reg(tracee, CURRENT, SYSARG_1);

		status = get_sysarg_path(tracee, path, SYSARG_2);
		if (status < 0)
			break;

		flags = (  syscall_number == PR_fchownat
			|| syscall_number == PR_name_to_handle_at)
			? peek_reg(tracee, CURRENT, SYSARG_5)
			: peek_reg(tracee, CURRENT, SYSARG_4);

		if ((flags & AT_SYMLINK_NOFOLLOW) != 0)
			status = translate_path2(tracee, dirfd, path, SYSARG_2, SYMLINK);
		else
			status = translate_path2(tracee, dirfd, path, SYSARG_2, REGULAR);
		break;

	case PR_fchmodat:
	case PR_faccessat:
	case PR_futimesat:
	case PR_mknodat:
		dirfd = peek_reg(tracee, CURRENT, SYSARG_1);

		status = get_sysarg_path(tracee, path, SYSARG_2);
		if (status < 0)
			break;

		status = translate_path2(tracee, dirfd, path, SYSARG_2, REGULAR);
		break;

	case PR_inotify_add_watch:
		flags = peek_reg(tracee, CURRENT, SYSARG_3);

		if ((flags & IN_DONT_FOLLOW) != 0)
			status = translate_sysarg(tracee, SYSARG_2, SYMLINK);
		else
			status = translate_sysarg(tracee, SYSARG_2, REGULAR);
		break;

	case PR_readlink:
	case PR_lchown:
	case PR_lchown32:
	case PR_lgetxattr:
	case PR_llistxattr:
	case PR_lremovexattr:
	case PR_lsetxattr:
	case PR_lstat:
	case PR_lstat64:
	case PR_oldlstat:
	case PR_unlink:
	case PR_rmdir:
	case PR_mkdir:
		status = translate_sysarg(tracee, SYSARG_1, SYMLINK);
		break;

	case PR_pivot_root:
		status = translate_sysarg(tracee, SYSARG_1, REGULAR);
		if (status < 0)
			break;

		status = translate_sysarg(tracee, SYSARG_2, REGULAR);
		break;

	case PR_linkat:
		olddirfd = peek_reg(tracee, CURRENT, SYSARG_1);
		newdirfd = peek_reg(tracee, CURRENT, SYSARG_3);
		flags    = peek_reg(tracee, CURRENT, SYSARG_5);

		status = get_sysarg_path(tracee, oldpath, SYSARG_2);
		if (status < 0)
			break;

		status = get_sysarg_path(tracee, newpath, SYSARG_4);
		if (status < 0)
			break;

		if ((flags & AT_SYMLINK_FOLLOW) != 0)
			status = translate_path2(tracee, olddirfd, oldpath, SYSARG_2, REGULAR);
		else
			status = translate_path2(tracee, olddirfd, oldpath, SYSARG_2, SYMLINK);
		if (status < 0)
			break;

		status = translate_path2(tracee, newdirfd, newpath, SYSARG_4, SYMLINK);
		break;

	case PR_mount:
		status = get_sysarg_path(tracee, path, SYSARG_1);
		if (status < 0)
			break;

		/* The following check covers only 90% of the cases. */
		if (path[0] == '/' || path[0] == '.') {
			status = translate_path2(tracee, AT_FDCWD, path, SYSARG_1, REGULAR);
			if (status < 0)
				break;
		}

		status = translate_sysarg(tracee, SYSARG_2, REGULAR);
		break;

	case PR_openat:
		dirfd = peek_reg(tracee, CURRENT, SYSARG_1);
		flags = peek_reg(tracee, CURRENT, SYSARG_3);

		status = get_sysarg_path(tracee, path, SYSARG_2);
		if (status < 0)
			break;

		if (   ((flags & O_NOFOLLOW) != 0)
		    || ((flags & O_EXCL) != 0 && (flags & O_CREAT) != 0))
			status = translate_path2(tracee, dirfd, path, SYSARG_2, SYMLINK);
		else
			status = translate_path2(tracee, dirfd, path, SYSARG_2, REGULAR);
		break;

	case PR_readlinkat:
	case PR_unlinkat:
	case PR_mkdirat:
		dirfd = peek_reg(tracee, CURRENT, SYSARG_1);

		status = get_sysarg_path(tracee, path, SYSARG_2);
		if (status < 0)
			break;

		status = translate_path2(tracee, dirfd, path, SYSARG_2, SYMLINK);
		break;

	case PR_link:
	case PR_rename:
		status = translate_sysarg(tracee, SYSARG_1, SYMLINK);
		if (status < 0)
			break;

		status = translate_sysarg(tracee, SYSARG_2, SYMLINK);
		break;

	case PR_renameat:
		olddirfd = peek_reg(tracee, CURRENT, SYSARG_1);
		newdirfd = peek_reg(tracee, CURRENT, SYSARG_3);

		status = get_sysarg_path(tracee, oldpath, SYSARG_2);
		if (status < 0)
			break;

		status = get_sysarg_path(tracee, newpath, SYSARG_4);
		if (status < 0)
			break;

		status = translate_path2(tracee, olddirfd, oldpath, SYSARG_2, SYMLINK);
		if (status < 0)
			break;

		status = translate_path2(tracee, newdirfd, newpath, SYSARG_4, SYMLINK);
		break;

	case PR_symlink:
		status = translate_sysarg(tracee, SYSARG_2, SYMLINK);
		break;

	case PR_symlinkat:
		newdirfd = peek_reg(tracee, CURRENT, SYSARG_2);

		status = get_sysarg_path(tracee, newpath, SYSARG_3);
		if (status < 0)
			break;

		status = translate_path2(tracee, newdirfd, newpath, SYSARG_3, SYMLINK);
		break;
	}

end:
	status2 = notify_extensions(tracee, SYSCALL_ENTER_END, status, 0);
	if (status2 < 0)
		status = status2;

	return status;
}

This is where the syscall functions get modified. At this point we basically understand PRoot’s implementation flow — and it’s essentially the same as our little demo (roughly).

Let’s do a quick recap:

  1. Split into a main process and a child process. PRoot attaches to the child process to handle signals and do emulation.
  2. Set up seccomp to speed up ptrace.

5. Porting PRoot to Android

Environment: Ubuntu 22, Android Studio, CLion.

I first implemented it in CLion following the tutorial, then ported it to Android by the same pattern.

CMakeLists:

# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html.
# For more examples on how to use CMake, see https://github.com/android/ndk-samples.

# Sets the minimum CMake version required for this project.
cmake_minimum_required(VERSION 3.22.1)

# Declares the project name. The project name can be accessed via ${ PROJECT_NAME},
# Since this is the top level CMakeLists.txt, the project name is also accessible
# with ${CMAKE_PROJECT_NAME} (both CMake variables are in-sync within the top level
# build script scope).
project("proot_demo")
enable_language(C ASM) # enable C and ASM (inline assembly)
#add_definitions(-DHAVE_SECCOMP_FILTER)
add_definitions(-D_GNU_SOURCE)
include_directories(
        proot/src/
        proot/src/cli
        proot/src/execve
        proot/src/extension
        #        proot/src/root/extension/care
        proot/src/extension/portmap
        proot/src/extension/python
        proot/src/loader
        proot/src/path
        proot/src/ptrace
        proot/src/syscall
        proot/src/tracee
        proot/src/lib/uthash/include/
        uthash/src

        # talloc include
        talloc/
        talloc/lib/replace/
        #        talloc/lib/replace/test/
        talloc/bin/default/
)

# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.
#
# In this top level CMakeLists.txt, ${CMAKE_PROJECT_NAME} is used to define
# the target library name; in the sub-module's CMakeLists.txt, ${PROJECT_NAME}
# is preferred for the same purpose.
#
# In order to load a library into your app from Java/Kotlin, you must call
# System.loadLibrary() and pass the name of the library defined here;
# for GameActivity/NativeActivity derived applications, the same library name must be
# used in the AndroidManifest.xml file.
add_library(${CMAKE_PROJECT_NAME} SHARED
        # List C/C++ source files with relative paths to this CMakeLists.txt.
        proot/src/cli/cli.c
        proot/src/cli/proot.c
        proot/src/cli/note.c
        proot/src/execve/enter.c
        proot/src/execve/exit.c
        proot/src/execve/shebang.c
        proot/src/execve/elf.c
        proot/src/execve/ldso.c
        proot/src/execve/auxv.c
        proot/src/execve/aoxp.c
        proot/src/path/binding.c
        proot/src/path/glue.c
        proot/src/path/canon.c
        proot/src/path/path.c
        proot/src/path/proc.c
        proot/src/path/temp.c
        proot/src/syscall/seccomp.c
        proot/src/syscall/syscall.c
        proot/src/syscall/chain.c
        proot/src/syscall/enter.c
        proot/src/syscall/exit.c
        proot/src/syscall/sysnum.c
        proot/src/syscall/socket.c
        proot/src/syscall/heap.c
        proot/src/syscall/rlimit.c
        proot/src/tracee/tracee.c
        proot/src/tracee/mem.c
        proot/src/tracee/reg.c
        proot/src/tracee/event.c
        proot/src/ptrace/ptrace.c
        proot/src/ptrace/user.c
        proot/src/ptrace/wait.c
        proot/src/extension/extension.c
        proot/src/extension/kompat/kompat.c
        proot/src/extension/fake_id0/fake_id0.c
        proot/src/extension/link2symlink/link2symlink.c
        proot/src/extension/portmap/portmap.c
        proot/src/extension/portmap/map.c
        proot/src/loader/loader.c

        talloc/lib/replace/cwrap.c
        talloc/lib/replace/replace.c
        talloc/talloc.c

        native-lib.cpp)

# Specifies libraries CMake should link to your target library. You
# can link libraries from various origins, such as libraries defined in this
# build script, prebuilt third-party libraries, or Android system libraries.
target_link_libraries(${CMAKE_PROJECT_NAME}
        # List libraries link to the target library
        archive
        android
        log)
add_subdirectory(libarchive)

Download the dependency sources from the official site. With the dependencies in place, the port is done (I compiled it successfully for arm64 on Linux).

6. Going deeper

What the child process does

  • Declares traceability: marks itself traceable via ptrace(PTRACE_TRACEME).
  • Synchronizes state: pauses itself with SIGSTOP so the tracer can capture the initial state.
  • Performance optimization: enables seccomp mode (if not disabled).
  • Runs the target: calls execvp to replace itself with the target program.

Our main focus should be what happens inside the event_loop() loop.

What event_loop() actually does:

  1. Initialize signal handling — kill the exiting processes.

  2. Configure signal handlers — reconfigure the signals PRoot needs every time, in case they get overwritten; ensure the program handles specific signals correctly and avoids losing control or leaking resources.

  3. Main event loop:

    • Release terminated processes via free_terminated_tracees().
    • Wait for the next process to stop with waitpid(-1, &tracee_status, __WALL); the __WALL flag ensures all child events are captured.
    • Get the stopped process’s info via get_tracee(NULL, pid, true).
    • Log detailed debug info.
    • Notify extension modules of the new state.
    • If the process is being ptrace-traced, call handle_ptracee_event to handle the specific ptrace event.
    • Call handle_tracee_event to handle the generic process event and decide how to restart it.
    • Restart the process via restart_tracee with the computed signal.

In the main event loop, that covers handling the process’s syscalls and emulating ptrace.

How PRoot emulates ptrace

Suppose a ptrace call fires. It gets caught here in event_loop():

if (tracee->as_ptracee.ptracer != NULL) {
			bool keep_stopped = handle_ptracee_event(tracee, tracee_status);
			if (keep_stopped)
				continue;
		}

		signal = handle_tracee_event(tracee, tracee_status);
		(void) restart_tracee(tracee, signal);

It checks whether tracee->as_ptracee.ptracer exists. I didn’t see it set earlier — my guess is it’s set during syscall handling in handle_tracee_event.

First translate_syscall_enter gets called, and sure enough there’s ptrace handling:

case PR_ptrace:
		status = translate_ptrace_enter(tracee);
		break;

The follow-up handling:

int translate_ptrace_enter(Tracee *tracee)
{
	/* The ptrace syscall have to be emulated since it can't be nested.  */
	set_sysnum(tracee, PR_void);
	return 0;
}

The handling turns the ptrace syscall number into 0 — basically setting it to invalid.

Of course that’s the “before” handling. What about after the call? That’s translate_syscall_exit():

case PR_ptrace:
		status = translate_ptrace_exit(tracee);
		break;

Follow translate_ptrace_exit:

int translate_ptrace_exit(Tracee *tracee)
{
	word_t request, pid, address, data, result;
	Tracee *ptracee, *ptracer;
	int forced_signal = -1;
	int signal;
	int status;

	/* Read ptrace parameters.  */
	request = peek_reg(tracee, ORIGINAL, SYSARG_1);
	pid     = peek_reg(tracee, ORIGINAL, SYSARG_2);
	address = peek_reg(tracee, ORIGINAL, SYSARG_3);
	data    = peek_reg(tracee, ORIGINAL, SYSARG_4);

	/* Propagate signedness for this special value.  */
	if (is_32on64_mode(tracee) && pid == 0xFFFFFFFF)
		pid = (word_t) -1;

	/* The TRACEME request is the only one used by a tracee.  */
	if (request == PTRACE_TRACEME) {
		ptracer = tracee->parent;
		ptracee = tracee;

		/* The emulated ptrace in PRoot has the same
		 * limitation as the real ptrace in the Linux kernel:
		 * only one tracer per process.  */
		if (PTRACEE.ptracer != NULL || ptracee == ptracer)
			return -EPERM;

		attach_to_ptracer(ptracee, ptracer);

		/* Detect when the ptracer has gone to wait before the
		 * ptracee did the ptrace(ATTACHME) request.  */
		if (PTRACER.waits_in == WAITS_IN_KERNEL) {
			status = kill(ptracer->pid, SIGSTOP);
			if (status < 0)
				note(tracee, WARNING, INTERNAL,
					"can't wake ptracer %d", ptracer->pid);
			else {
				ptracer->sigstop = SIGSTOP_IGNORED;
				PTRACER.waits_in = WAITS_IN_PROOT;
			}
		}

		/* Disable seccomp acceleration for this tracee and
		 * all its children since we can't assume what are the
		 * syscalls its tracer is interested with.  */
		if (tracee->seccomp == ENABLED)
			tracee->seccomp = DISABLING;

		return 0;
	}

	/* The ATTACH, SEIZE, and INTERRUPT requests are the only ones
	 * where the ptracee is in an unknown state.  */
	if (request == PTRACE_ATTACH) {
		ptracer = tracee;
		ptracee = get_tracee(ptracer, pid, false);
		if (ptracee == NULL)
			return -ESRCH;

		/* The emulated ptrace in PRoot has the same
		 * limitation as the real ptrace in the Linux kernel:
		 * only one tracer per process.  */
		if (PTRACEE.ptracer != NULL || ptracee == ptracer)
			return -EPERM;

		attach_to_ptracer(ptracee, ptracer);

		/* The tracee is sent a SIGSTOP, but will not
		 * necessarily have stopped by the completion of this
		 * call.
		 *
		 * -- man 2 ptrace.  */
		kill(pid, SIGSTOP);

		return 0;
	}

	/* Here, the tracee is a ptracer.  Also, the requested ptracee
	 * has to be in the "stopped for ptracer" state.  */
	ptracer = tracee;
	ptracee = get_stopped_ptracee(ptracer, pid, false, __WALL);
	if (ptracee == NULL) {
		static bool warned = false;

		/* Ensure we didn't get there only because inheritance
		 * mechanism has missed this one.  */
		ptracee = get_tracee(tracee, pid, false);
		if (ptracee != NULL && ptracee->exe == NULL && !warned) {
			warned = true;
			note(ptracer, WARNING, INTERNAL, "ptrace request to an unexpected ptracee");
		}

		return -ESRCH;
	}

	/* Sanity checks.  */
	if (   PTRACEE.is_zombie
	    || PTRACEE.ptracer != ptracer
	    || pid == (word_t) -1)
		return -ESRCH;

	switch (request) {
	case PTRACE_SYSCALL:
		PTRACEE.ignore_syscalls = false;
		forced_signal = (int) data;
		status = 0;
		break;  /* Restart the ptracee.  */

	case PTRACE_CONT:
		PTRACEE.ignore_syscalls = true;
		forced_signal = (int) data;
		status = 0;
		break;  /* Restart the ptracee.  */

	case PTRACE_SINGLESTEP:
		ptracee->restart_how = PTRACE_SINGLESTEP;
		forced_signal = (int) data;
		status = 0;
		break;  /* Restart the ptracee.  */

	case PTRACE_SINGLEBLOCK:
		ptracee->restart_how = PTRACE_SINGLEBLOCK;
		forced_signal = (int) data;
		status = 0;
		break;  /* Restart the ptracee.  */

	case PTRACE_DETACH:
		detach_from_ptracer(ptracee);
		status = 0;
		break;  /* Restart the ptracee.  */

	case PTRACE_KILL:
		status = ptrace(request, pid, NULL, NULL);
		break;  /* Restart the ptracee.  */

	case PTRACE_SETOPTIONS:
		if (data & PTRACE_O_TRACESECCOMP) {
			/* We don't really support forwarding seccomp traps */
			note(ptracer, WARNING, INTERNAL,
			     "ptrace option PTRACE_O_TRACESECCOMP "
			     "not supported yet");
			return -EINVAL;
		}
		PTRACEE.options = data;
		return 0;  /* Don't restart the ptracee.  */

	case PTRACE_GETEVENTMSG: {
		status = ptrace(request, pid, NULL, &result);
		if (status < 0)
			return -errno;

		poke_word(ptracer, data, result);
		if (errno != 0)
			return -errno;

		return 0;  /* Don't restart the ptracee.  */
	}

	case PTRACE_PEEKUSER:
		if (is_32on64_mode(ptracer)) {
			address = convert_user_offset(address);
			if (address == (word_t) -1)
				return -EIO;
		}
		/* Fall through.  */
	case PTRACE_PEEKTEXT:
	case PTRACE_PEEKDATA:
		errno = 0;
		result = (word_t) ptrace(request, pid, address, NULL);
		if (errno != 0)
			return -errno;

		poke_word(ptracer, data, result);
		if (errno != 0)
			return -errno;

		return 0;  /* Don't restart the ptracee.  */

	case PTRACE_POKEUSER:
		if (is_32on64_mode(ptracer)) {
			address = convert_user_offset(address);
			if (address == (word_t) -1)
				return -EIO;
		}

		status = ptrace(request, pid, address, data);
		if (status < 0)
			return -errno;

		return 0;  /* Don't restart the ptracee.  */

	case PTRACE_POKETEXT:
	case PTRACE_POKEDATA:
		if (is_32on64_mode(ptracer)) {
			word_t tmp;

			errno = 0;
			tmp = (word_t) ptrace(PTRACE_PEEKDATA, ptracee->pid, address, NULL);
			if (errno != 0)
				return -errno;

			data |= (tmp & 0xFFFFFFFF00000000ULL);
		}

		status = ptrace(request, pid, address, data);
		if (status < 0)
			return -errno;

		return 0;  /* Don't restart the ptracee.  */

	case PTRACE_GETSIGINFO: {
		siginfo_t siginfo;

		status = ptrace(request, pid, NULL, &siginfo);
		if (status < 0)
			return -errno;

		status = write_data(ptracer, data, &siginfo, sizeof(siginfo));
		if (status < 0)
			return status;

		return 0;  /* Don't restart the ptracee.  */
	}

	case PTRACE_SETSIGINFO: {
		siginfo_t siginfo;

		status = read_data(ptracer, &siginfo, data, sizeof(siginfo));
		if (status < 0)
			return status;

		status = ptrace(request, pid, NULL, &siginfo);
		if (status < 0)
			return -errno;

		return 0;  /* Don't restart the ptracee.  */
	}

	case PTRACE_GETREGS: {
		size_t size;
		union {
			struct user_regs_struct regs;
			uint32_t regs32[USER32_NB_REGS];
		} buffer;

		status = ptrace(request, pid, NULL, &buffer);
		if (status < 0)
			return -errno;

		if (is_32on64_mode(tracee)) {
			struct user_regs_struct regs64;

			memcpy(&regs64, &buffer.regs, sizeof(struct user_regs_struct));
			convert_user_regs_struct(false,	(uint64_t *) &regs64, buffer.regs32);

			size = sizeof(buffer.regs32);
		}
		else
			size = sizeof(buffer.regs);

		status = write_data(ptracer, data, &buffer, size);
		if (status < 0)
			return status;

		return 0;  /* Don't restart the ptracee.  */
	}

	case PTRACE_SETREGS: {
		size_t size;
		union {
			struct user_regs_struct regs;
			uint32_t regs32[USER32_NB_REGS];
		} buffer;

		size = (is_32on64_mode(ptracer)
			? sizeof(buffer.regs32)
			: sizeof(buffer.regs));

		status = read_data(ptracer, &buffer, data, size);
		if (status < 0)
			return status;

		if (is_32on64_mode(ptracer)) {
			uint32_t regs32[USER32_NB_REGS];

			memcpy(regs32, buffer.regs32, sizeof(regs32));
			convert_user_regs_struct(true, (uint64_t *) &buffer.regs, regs32);
		}

		status = ptrace(request, pid, NULL, &buffer);
		if (status < 0)
			return -errno;

		return 0;  /* Don't restart the ptracee.  */
	}

	case PTRACE_GETFPREGS: {
		size_t size;
		union {
			struct user_fpregs_struct fpregs;
			uint32_t fpregs32[USER32_NB_FPREGS];
		} buffer;

		status = ptrace(request, pid, NULL, &buffer);
		if (status < 0)
			return -errno;

		if (is_32on64_mode(tracee)) {
#if 0 /* TODO */
			struct user_fpregs_struct fpregs64;

			memcpy(&fpregs64, &buffer.fpregs, sizeof(struct user_fpregs_struct));
			convert_user_fpregs_struct(false, (uint64_t *) &fpregs64, buffer.fpregs32);
#else
			static bool warned = false;
			if (!warned)
				note(ptracer, WARNING, INTERNAL,
					"ptrace 32-bit request '%s' not supported on 64-bit yet",
					stringify_ptrace(request));
			warned = true;
			bzero(&buffer, sizeof(buffer));
#endif
			size = sizeof(buffer.fpregs32);
		}
		else
			size = sizeof(buffer.fpregs);

		status = write_data(ptracer, data, &buffer, size);
		if (status < 0)
			return status;

		return 0;  /* Don't restart the ptracee.  */
	}

	case PTRACE_SETFPREGS: {
		size_t size;
		union {
			struct user_fpregs_struct fpregs;
			uint32_t fpregs32[USER32_NB_FPREGS];
		} buffer;

		size = (is_32on64_mode(ptracer)
			? sizeof(buffer.fpregs32)
			: sizeof(buffer.fpregs));

		status = read_data(ptracer, &buffer, data, size);
		if (status < 0)
			return status;

		if (is_32on64_mode(ptracer)) {
#if 0 /* TODO */
			uint32_t fpregs32[USER32_NB_FPREGS];

			memcpy(fpregs32, buffer.fpregs32, sizeof(fpregs32));
			convert_user_fpregs_struct(true, (uint64_t *) &buffer.fpregs, fpregs32);
#else
			static bool warned = false;
			if (!warned)
				note(ptracer, WARNING, INTERNAL,
					"ptrace 32-bit request '%s' not supported on 64-bit yet",
					stringify_ptrace(request));
			warned = true;
			return -ENOTSUP;
#endif
		}

		status = ptrace(request, pid, NULL, &buffer);
		if (status < 0)
			return -errno;

		return 0;  /* Don't restart the ptracee.  */
	}

#if defined(ARCH_X86_64) || defined(ARCH_X86)
	case PTRACE_GET_THREAD_AREA: {
		struct user_desc user_desc;

		status = ptrace(request, pid, address, &user_desc);
		if (status < 0)
			return -errno;

		status = write_data(ptracer, data, &user_desc, sizeof(user_desc));
		if (status < 0)
			return status;

		return 0;  /* Don't restart the ptracee.  */
	}

	case PTRACE_SET_THREAD_AREA: {
		struct user_desc user_desc;

		status = read_data(ptracer, &user_desc, data, sizeof(user_desc));
		if (status < 0)
			return status;

		status = ptrace(request, pid, address, &user_desc);
		if (status < 0)
			return -errno;

		return 0;  /* Don't restart the ptracee.  */
	}
#endif

	case PTRACE_GETREGSET: {
		struct iovec local_iovec;
		word_t remote_iovec_base;
		word_t remote_iovec_len;

		remote_iovec_base = peek_word(ptracer, data);
		if (errno != 0)
			return -errno;

		remote_iovec_len = peek_word(ptracer, data + sizeof_word(ptracer));
		if (errno != 0)
			return -errno;

		/* Sanity check.  */
		assert(sizeof(local_iovec.iov_len) == sizeof(word_t));

		local_iovec.iov_len  = remote_iovec_len;
		local_iovec.iov_base = talloc_zero_size(ptracer->ctx, remote_iovec_len);
		if (local_iovec.iov_base == NULL)
			return -ENOMEM;

		status = ptrace(PTRACE_GETREGSET, pid, address, &local_iovec);
		if (status < 0)
			return status;

		remote_iovec_len = local_iovec.iov_len =
			MIN(remote_iovec_len, local_iovec.iov_len);

		/* Update remote vector content.  */
		status = writev_data(ptracer, remote_iovec_base, &local_iovec, 1);
		if (status < 0)
			return status;

		/* Update remote vector length.  */
		poke_word(ptracer, data + sizeof_word(ptracer), remote_iovec_len);
		if (errno != 0)
			return -errno;

		return 0;  /* Don't restart the ptracee.  */
	}

	case PTRACE_SETREGSET: {
		struct iovec local_iovec;
		word_t remote_iovec_base;
		word_t remote_iovec_len;

		remote_iovec_base = peek_word(ptracer, data);
		if (errno != 0)
			return -errno;

		remote_iovec_len = peek_word(ptracer, data + sizeof_word(ptracer));
		if (errno != 0)
			return -errno;

		/* Sanity check.  */
		assert(sizeof(local_iovec.iov_len) == sizeof(word_t));

		local_iovec.iov_len  = remote_iovec_len;
		local_iovec.iov_base = talloc_zero_size(ptracer->ctx, remote_iovec_len);
		if (local_iovec.iov_base == NULL)
			return -ENOMEM;

		/* Copy remote content into the local vector.  */
		status = read_data(ptracer, local_iovec.iov_base,
				remote_iovec_base, local_iovec.iov_len);
		if (status < 0)
			return status;

		status = ptrace(PTRACE_SETREGSET, pid, address, &local_iovec);
		if (status < 0)
			return status;

		return 0;  /* Don't restart the ptracee.  */
	}

	case PTRACE_GETVFPREGS:
	case PTRACE_GETFPXREGS: {
		static bool warned = false;
		if (!warned)
			note(ptracer, WARNING, INTERNAL, "ptrace request '%s' not supported yet",
				stringify_ptrace(request));
		warned = true;
		return -ENOTSUP;
	}

#if defined(ARCH_X86_64)
	case PTRACE_ARCH_PRCTL:
		switch (data) {
		case ARCH_GET_GS:
		case ARCH_GET_FS:
			status = ptrace(request, pid, &result, data);
			if (status < 0)
				return -errno;

			poke_word(ptracer, address, result);
			if (errno != 0)
				return -errno;
			break;

		case ARCH_SET_GS:
		case ARCH_SET_FS: {
			static bool warned = false;
			if (!warned)
				note(ptracer, WARNING, INTERNAL,
					"ptrace request '%s' ARCH_SET_{G,F}S not supported yet",
					stringify_ptrace(request));
			return -ENOTSUP;
		}

		default:
			return -ENOTSUP;
		}

		return 0;  /* Don't restart the ptracee.  */
#endif

	case PTRACE_SET_SYSCALL:
		status = ptrace(request, pid, address, data);
		if (status < 0)
			return -errno;

		return 0;  /* Don't restart the ptracee.  */

	default:
		note(ptracer, WARNING, INTERNAL, "ptrace request '%s' not supported yet",
			stringify_ptrace(request));
		return -ENOTSUP;
	}

	/* Now, the initial tracee's event can be handled.  */
	signal = PTRACEE.event4.proot.pending
		? handle_tracee_event(ptracee, PTRACEE.event4.proot.value)
		: PTRACEE.event4.proot.value;

	/* The restarting signal from the ptracer overrides the
	 * restarting signal from PRoot.  */
	if (forced_signal != -1)
		signal = forced_signal;

	(void) restart_tracee(ptracee, signal);

	return status;
}

That’s a long chunk of code — clearly emulating ptrace.

As suspected, this line:

attach_to_ptracer(ptracee, ptracer);

sets as_ptracee.ptracer, which ties back to event_loop:

if (tracee->as_ptracee.ptracer != NULL) {
    bool keep_stopped = handle_ptracee_event(tracee, tracee_status);
    if (keep_stopped)
       continue;
}

Now it can enter handle_ptracee_event. That function also handles ptrace events — but the PRoot-emulated ones, only effective for the functions we trace.

That’s the end of PRoot’s ptrace emulation. In plain terms: PRoot maintains its own fake ptrace for the child process.

7. Wrap-up

I planned to split off the usable parts of PRoot for my own project, but lacked the skill. Then, a turn: I realized the abyss project’s demo code — which I couldn’t understand before — is actually a modified PRoot. Lucky break.

https://github.com/iofomo/abyss

The loop part is similar: it handles the tracee and does the attach, and the rest is basically the same.

That concludes my study of PRoot’s theory. Next up: building my own svc-hook framework.

The code analysis above is just my personal view — there are bound to be gaps. Corrections are welcome.

Further reading


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