System Calls & the Kernel Interface Questions
The boundary between user space and the kernel: how programs request privileged services through system calls, the user/kernel mode transition, and the semantics of core POSIX calls such as fork, exec, wait, open, read, write, and stat. Covers syscall numbers, arguments, return values and errno, and how libc wrappers relate to the underlying trap. This is the foundational interface for all systems programming on Unix/Linux.
Describe TOCTOU (time-of-check to time-of-use) race conditions when performing file system operations. Provide concrete examples where checking a path then opening it leads to vulnerabilities. Explain how openat(2), O_NOFOLLOW, O_DIRECTORY, and fstatat(2) can be used to avoid races and perform secure atomic checks and opens.
Sample Answer
A concrete worked example first
Consider a security scanner tasked with hashing files it's told to inspect: it does stat(path) to confirm the target is a regular file under some size limit, decides it's safe, and then open(path)s the SAME path string to read and hash its contents. Between the stat() call and the open() call, an attacker with write access to that directory (a shared upload/scan-queue directory, or any directory a lower-privileged or scanned user controls) deletes the original file and creates a symlink at the exact same path, pointing at /etc/shadow, a device file, or any other file entirely. The scanner's open() transparently follows the new symlink, and the tool hashes and reports on a completely different file than the one it validated, either exfiltrating or misreporting on privileged content it should never have touched, or, if the operation had been a write instead of a read, corrupting a file the scanner never intended to write to at all. The vulnerability exists purely because "check" and "use" are two independent path resolutions with an exploitable gap between them; this is TOCTOU (time-of-check to time-of-use), the general name for this race condition class.
Generalizing: any check-path-then-open-path sequence is exposed
The same shape recurs anywhere code does: resolve a path to decide something (is it a regular file, does it belong to the expected owner, is it under a size limit), then LATER performs a privileged action by resolving that path string again (open, unlink, chmod, exec). Anything with write access to a directory in that path, at any point between the two resolutions, can change what the path means. This applies just as much to a config loader that validates a file before parsing it, a backup tool that stats before archiving, or a privilege-dropping helper that checks a target binary before exec'ing it.
How openat, O_NOFOLLOW, O_DIRECTORY, and fstatat close the race
openat(dirfd, name, ...): resolvesnamerelative to an already-open directory file descriptor rather than re-walking a path string from the root each time. On its own it doesn't remove TOCTOU, but it is the building block the rest of the mitigation is built on, because it anchors resolution to a specific, already-validated directory object rather than a name that has to be looked up fresh.O_NOFOLLOW: passed toopen()/openat(), makes the call FAIL withELOOPif the final path component is a symlink, instead of silently following it. This converts "attacker swapped the file for a symlink" from a silent, invisible redirection into a loud, immediately checkable error.O_DIRECTORY: fails the open unless the resolved target is genuinely a directory, which matters when walking a tree, so a symlink planted where a subdirectory was expected can't trick the walker into descending into somewhere else entirely.fstatat(dirfd, name, &st, AT_SYMLINK_NOFOLLOW): the*at-family equivalent of stat, checking metadata using the same directory-fd-anchored resolution the subsequentopenat()will use, rather than re-resolving a brand-new path string from the filesystem root as a second, independent operation (itself a second opportunity for the meaning of the path to differ, especially across mount namespaces or symlinked intermediate directories).
The secure atomic pattern
Reorder the operations so the check happens AFTER acquisition, not before: openat(dirfd, name, O_RDONLY | O_NOFOLLOW) first (a single resolution, with symlinks rejected outright), and only then fstat() the resulting fd to verify size, type, and ownership. There is no remaining check-then-use gap, because by the time any check runs, the fd already IS the object being checked; there is no second resolution left for an attacker to hijack. Applied to the worked example above, the scanner would open the candidate file with O_NOFOLLOW first (failing loudly if it's a symlink) and confirm S_ISREG via fstat() on that descriptor before ever reading a byte, closing the exact window the naive stat-then-open version left wide open.
Explain the differences between stat(2), fstat(2), and lstat(2), including examples of when each should be used and how they behave with symbolic links. How would you robustly detect whether a path refers to a regular file, directory, or symlink, and what TOCTOU considerations apply when relying on stat information during a security review?
Sample Answer
The three calls and how they differ
stat(path, &st) resolves the path following ALL symlinks, including a final one, and returns metadata about whatever the path ultimately resolves to. Use stat() when you want metadata about the resolved target itself and have no reason to distinguish a symlink from what it points to: checking a config file's size or last-modified time before parsing it, for instance, where if that path happens to be a symlink to a versioned target, you want the real target's properties, not the symlink object's, so stat() (not lstat()) is the right call.
lstat(path, &st) resolves every symlink EXCEPT the final path component: if path itself names a symlink, lstat returns metadata about the symlink object itself (its own inode, st_size equal to the length of the link target string, st_mode showing S_IFLNK), not about whatever it points at. Use lstat when the question you're actually asking is "is this specific path a symlink," such as while enumerating a directory and needing to distinguish real entries from symlinks without following them.
fstat(fd, &st) takes an already-open file descriptor rather than a path, and returns metadata for whatever that descriptor's open file description points at. No path resolution happens at fstat() time at all, because the resolution already happened once, when the fd was created; whatever the fd refers to is fixed as of that open() call.
Robust type detection
Given a struct stat, use the S_ISREG, S_ISDIR, and S_ISLNK macros on st.st_mode (via stat/lstat/fstat as appropriate) rather than inspecting raw bits; S_ISLNK only ever returns true from an lstat()-populated struct, since a plain stat() has already followed the link by the time it returns, so there is no symlink left to detect in its result.
TOCTOU considerations during a security review, and why fstat on an open descriptor is the safer default
TOCTOU (time-of-check to time-of-use, a race condition class where a decision is made based on a CHECK against a path, but the actual USE re-resolves that same path string later, and the underlying object can change identity in between) is the central risk with path-based stat()/lstat() calls used for security decisions. If code does stat(path) to confirm a file is safe to trust, then later open(path)s the same string, an attacker with write access to a shared or attacker-influenced directory can swap what that path resolves to between the two calls (deleting a regular file and replacing it with a symlink to something sensitive, for example), and the open() call transparently follows the new target.
The mitigation this question's answer should lead with: prefer fstat() on an already-open descriptor over repeated path-based stat()/lstat() calls. Concretely, open the file first (with O_NOFOLLOW where a symlink should never legitimately appear), and only THEN call fstat() on the resulting fd to check its type/size/permissions. Because fstat() operates on a descriptor that is already bound to a specific, resolved object, there is no remaining resolution step for an attacker to hijack between the check and the use; the "thing you checked" and "the thing you use" are, by construction, the identical inode.
Detecting a swapped file via device and inode
When you must compare two points in time (for example, confirming a long-held fd still refers to the file you originally validated, or checking whether a path you stat'd earlier still means the same thing now), compare the (st_dev, st_ino) pair (device ID and inode number) from both observations rather than comparing st_mtime alone. A modification-time comparison can be defeated trivially by an attacker who touches the replacement file to match the original timestamp, or who simply doesn't care about matching it if the check never looks at it; st_dev/st_ino together uniquely identify the filesystem object within that filesystem, so any mismatch is unambiguous proof the underlying object changed identity, regardless of what the path string or timestamps claim.
A malware analyst tool walks a directory tree, filters for regular files, and then hashes them. How would you make the file-type checks and traversal resistant to symlink tricks, renamed paths, and concurrent filesystem changes?
Sample Answer
Why this is a live attack surface, not a theoretical edge case
The vulnerable pattern is: enumerate a directory tree with readdir(), filter entries down to "regular files" using d_type and/or stat(), then separately open() and hash the same path string. Every gap between "decided this is safe to hash" and "actually opened it" is a TOCTOU (time-of-check to time-of-use) window, and in malware analysis the directory tree being walked is explicitly adversarial input: the sample itself, or anything an attacker controls in a shared analysis environment, can plant content specifically designed to defeat or redirect the scanner. This isn't a hardening nicety here, it's the expected threat model.
The specific tricks to defend against
- Symlink tricks: an entry named to look like an innocuous sample (
report.pdf,invoice.exe) that is actually a symlink to/etc/shadow, a device file, or a named pipe designed to block the scanner forever (a denial-of-service against the tool itself), or a symlink to an enormous sparse file designed to exhaust disk or memory when "hashed." - Renamed-path tricks: the entry
readdir()reported gets deleted and a DIFFERENT object created under the same name before the tool acts on it, possible whenever the directory being scanned is live and writable, whether by the sample's own partial execution, by another process sharing the analysis filesystem, or by an operator re-triaging concurrently. - Concurrent filesystem changes: directories scanned on a live system, as opposed to a static forensic image, can have entries added, removed, or replaced mid-walk; POSIX gives no ordering or consistency guarantee for
readdir()under concurrent modification, so entries can be seen twice, missed entirely, or observed in an inconsistent state relative to each other.
A resistant design
- Walk using directory FILE DESCRIPTORS, not path strings. Open the top directory once with
open(path, O_DIRECTORY); for every subdirectory encountered, descend viaopenat(parent_dirfd, name, O_DIRECTORY | O_NOFOLLOW)rather than concatenating a path string, so a symlink planted where a subdirectory was expected is rejected instead of followed. - For each entry, don't trust
d_typeas authoritative (it can beDT_UNKNOWN, and even when populated it reflects a moment in time that has already passed by the time you act). Instead of a check-then-open sequence,openat(dirfd, name, O_RDONLY | O_NOFOLLOW)directly: if the entry is a symlink, this call fails withELOOP(too many levels of symbolic links encountered, the same errnoO_NOFOLLOWproduces when it hits a symlink directly) and the entry can be flagged and skipped rather than silently redirected. fstat()the resulting fd, neverfstatat/staton the name a second time, and verifyS_ISREGbefore reading a single byte, so the object being hashed is guaranteed to be the exact fd already held open, with zero remaining path re-resolution for anything to hijack.- Compare
(st_dev, st_ino)(device and inode, from the same fd'sfstat()) against a set of already-visited identifiers to detect hardlink loops or the same file linked into the tree more than once, avoiding both infinite loops and double-counted results. - Don't trust
st_sizeblindly before reading: a pathological entry (a sparse file reporting a huge logical size, or a FIFO with a stale or meaningless size field) can turn a naive "read st_size bytes" into a resource-exhaustion or hang condition. Read in bounded chunks with an overall size and time budget, and use non-blocking I/O or a read timeout so a FIFO planted specifically to block forever can't stall the whole walk. - Run the entire scan under explicit resource limits (a wall-clock timeout per file,
RLIMIT_FSIZE(a per-process resource limit capping the largest file the process may write, to stop the scanner itself from being tricked into writing an unbounded amount of data), memory caps), treating the sample directory as hostile input for the whole duration of the walk, not just at the point of opening each file, since the scanner's own resource consumption is as much a part of the attack surface as the data it reads.
During an incident, you notice a suspicious process tree spawning rapidly and executing different binaries. How would you use your understanding of fork(), exec(), and process parent-child relationships to distinguish normal automation from a fork bomb or malware loader?
Sample Answer
The investigation comes down to reading the SHAPE of the process tree and the IDENTITY of what's actually being executed at each hop, then comparing both against what's normal for this host.
What a fork bomb looks like
A fork bomb is defined by rate and self-similarity, not by what any individual process does. Symptoms: an extremely high, near-instantaneous rate of new PIDs, potentially thousands per second; the SAME short-lived binary re-forking itself recursively (the parent and the child are typically running the identical program, sometimes literally the same shell one-liner re-invoking itself); and system-wide symptoms that show up FAST, fork() starting to fail everywhere on the host with EAGAIN/ENOMEM because the global PID space or resource limits are being exhausted, CPU pegged near 100% from pure scheduling/context-switch overhead even though no individual process is doing meaningful work (no real file I/O, no network activity, nothing but forking). Confirming this: ps -eo pid,ppid,etimes,cmd --sort=start_time | tail -50 (or pstree -p) shows a large cluster of processes all born within the same second or two, almost all running the same command, with a shallow but extremely WIDE tree (one ancestor with an enormous number of direct or near-direct descendants), rather than a deep chain.
What a malware loader / process-chaining attack looks like
The opposite shape: a NARROW, sequential chain, parent -> child -> grandchild, where each hop exec()s into a DIFFERENT binary than the one before it ("process chaining," commonly discussed as part of "living off the land" tradecraft). The telltale signal is fork() immediately followed by execve() into an unrelated program, especially when: the target binary lives in a WRITABLE, non-standard location (/tmp, /dev/shm, a user's Downloads directory) rather than a normal system binary path; or it's a legitimate, trusted system binary being invoked with unusual, scripted-looking arguments that a human wouldn't type interactively (a base64-encoded inline script, a curl/wget piped directly into a shell interpreter, a download-then-exec two-step); or a process that has no legitimate reason to spawn a shell at all suddenly does, a web server worker process forking sh -c "...", or a document viewer (PDF reader, Office app) spawning a script interpreter, is a strong standalone signal on its own, since normal automation for those specific parent processes essentially never legitimately does that.
What normal automation looks like, as the baseline to compare against
A stable, REPEATING, predictable tree shape: a cron job or systemd timer forking the same handful of children on a fixed schedule; a CI runner spawning build-tool subprocesses in a consistent pattern run after run. Children exec well-known binaries from standard system paths (/usr/bin, /usr/local/bin, not a temp directory). The fork rate is moderate and clearly correlated with an actual trigger (a cron entry firing, a webhook arriving), not continuous, unbounded self-replication. And critically, the specific argv patterns and binary paths involved are ones you'd expect to have SEEN BEFORE on this host if you have any historical baseline (fleet-wide EDR telemetry, prior audit logs), whereas malicious activity commonly shows a first-time-ever binary path, argv combination, or parent/child pairing for that specific host.
Concrete investigative steps
pstree -p <suspect_root_pid>first, to see the actual tree SHAPE at a glance: wide-and-shallow (fork bomb candidate) versus narrow-and-deep with binary changes at each hop (loader candidate) versus a familiar, repeating pattern (probably fine).ps -eo pid,ppid,lstart,etime,cmdfor exact start times and full command lines, WHEN things started (clustered instantaneously, or spread out on a schedule) and WHAT was actually typed as arguments (a base64 blob, a download URL, a-enc/-EncodedCommand-style obfuscation flag are all strong indicators).- Don't trust the process name alone:
argv[0](what shows up as the "command" in a casualps) can be rewritten by the process itself to look innocuous. Cross-check/proc/<pid>/exe(a symlink that resolves to the ACTUAL binary inode being executed, harder to spoof after the fact than an in-process argv rewrite) and/proc/<pid>/cmdline(the real argv the kernel recorded at exec time) against whatpsdisplayed; a mismatch between a friendly-looking process name and a suspicious/proc/<pid>/exetarget is itself a finding. - Check
/proc/<pid>/statusforPPID(confirm the actual parentage rather than trusting a displayed process name) and cross-reference againstwho/last/authentication logs: does this chain trace back to an interactive login session, a legitimate cron entry, or a network-facing service process, and if it's the latter, does that service have any legitimate reason to be spawning a shell or a script interpreter AT ALL? A network-facing daemon forking a shell is close to always worth escalating on its own. - Recognize the limits of reasoning from the CURRENT process tree alone:
fork()/execve()calls aren't logged anywhere by default, so if the malicious chain has already partially exited by the time you're looking, the tree you're inspecting is incomplete. This is exactly why Linux audit (auditctl -a exit,always -F arch=b64 -S execve) or an eBPF (a Linux kernel facility that lets small sandboxed monitoring programs run in-kernel, the basis for many modern tracing/EDR tools)-based EDR agent recording exec events needs to be enabled BEFORE an incident, not reached for during one; reconstructing a process-chaining attack cleanly from an execve audit trail is straightforward, reconstructing it purely frompssnapshots and whatever remnants are still running when you happen to look is much harder and can miss steps that already completed and exited.
Implement a minimal ptrace-based tracer in C that attaches to a running process, intercepts execve syscalls, and logs the command-line arguments of executed programs. Provide code for attaching, using PTRACE_SYSCALL to intercept entry/exit, and reading strings from traced process memory. Discuss performance and security limitations of ptrace in production.
Sample Answer
A ptrace-based tracer works by intercepting a target process at every syscall boundary and inspecting its registers and memory while it's stopped. The code below is a complete, minimal execve()-logging tracer that compiles and runs unchanged on Linux/aarch64 (verified in a clean Linux/aarch64 container, gcc 13): it traces the process it launches, and any time that process calls execve(), it prints the path and argv it's about to exec.
/* Minimal ptrace-based execve() tracer. Built and verified on Linux/aarch64.
* On Linux/x86_64: regs.regs[8] (syscall number) becomes orig_rax, regs.regs[0]
* (1st arg) becomes rdi, regs.regs[1] (2nd arg) becomes rsi; struct user_pt_regs +
* PTRACE_GETREGSET becomes struct user_regs_struct + PTRACE_GETREGS. See
* "AArch64 register layout and the x86_64 equivalent" below the code for why
* each register holds what it holds; unlike the aarch64 path below, this
* x86_64 mapping has not itself been compiled and run. */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/uio.h>
#include <asm/ptrace.h> /* struct user_pt_regs */
#include <elf.h>
#define MAX_ARGS 16
#define MAX_ARGLEN 256
#define SYS_execve_arm64 221
#define NT_PRSTATUS_ 1
/* Read a NUL-terminated string out of the traced process's address space,
* one machine word at a time via PTRACE_PEEKDATA. */
static char *read_traced_string(pid_t pid, unsigned long addr) {
static char buf[MAX_ARGLEN];
size_t i = 0;
while (i < MAX_ARGLEN - sizeof(long)) {
errno = 0;
long word = ptrace(PTRACE_PEEKDATA, pid, addr + i, NULL);
if (word == -1 && errno != 0) break;
memcpy(buf + i, &word, sizeof(long));
if (memchr(&word, 0, sizeof(long)) != NULL) break;
i += sizeof(long);
}
buf[MAX_ARGLEN - 1] = '\0';
return buf;
}
int main(int argc, char **argv) {
if (argc < 2) {
fprintf(stderr, "usage: %s <program> [args...]\n", argv[0]);
return 1;
}
pid_t child = fork();
if (child == -1) { perror("fork"); return 1; }
if (child == 0) {
/* Child asks to be traced, then execs the target. That exec
* delivers the first SIGTRAP the parent's initial waitpid catches. */
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execvp(argv[1], &argv[1]);
perror("execvp");
_exit(1);
}
int status;
waitpid(child, &status, 0); /* catch the exec-triggered SIGTRAP */
ptrace(PTRACE_SETOPTIONS, child, 0, PTRACE_O_TRACESYSGOOD);
int at_syscall_entry = 1;
while (1) {
if (ptrace(PTRACE_SYSCALL, child, NULL, NULL) == -1) break;
waitpid(child, &status, 0);
if (WIFEXITED(status)) {
printf("child exited with status %d\n", WEXITSTATUS(status));
break;
}
/* PTRACE_O_TRACESYSGOOD (set above) ORs 0x80 into SIGTRAP specifically on
* a syscall-stop, so this is how the tracer tells "stopped because of a
* syscall" apart from "stopped because of an unrelated real SIGTRAP." */
if (!WIFSTOPPED(status) || WSTOPSIG(status) != (SIGTRAP | 0x80)) {
continue; /* a real signal, not a syscall-stop: skip in this minimal demo */
}
if (at_syscall_entry) {
struct user_pt_regs regs;
struct iovec iov = { .iov_base = ®s, .iov_len = sizeof(regs) };
ptrace(PTRACE_GETREGSET, child, (void *)(long)NT_PRSTATUS_, &iov);
if (regs.regs[8] == SYS_execve_arm64) {
unsigned long path_addr = regs.regs[0];
unsigned long argv_addr = regs.regs[1];
printf("execve(\"%s\") args:", read_traced_string(child, path_addr));
for (int i = 0; i < MAX_ARGS; i++) {
errno = 0;
long argp = ptrace(PTRACE_PEEKDATA, child,
argv_addr + (unsigned long)i * sizeof(long), NULL);
if (argp == 0 || (argp == -1 && errno != 0)) break;
printf(" \"%s\"", read_traced_string(child, (unsigned long)argp));
}
printf("\n");
fflush(stdout); /* flush before the traced child can write its own output */
}
}
at_syscall_entry = !at_syscall_entry;
}
return 0;
}
Compiled with gcc -Wall -Wextra -o tracer tracer.c and run as ./tracer /bin/sh -c 'exec /bin/echo hello world', this prints, deterministically:
execve("/bin/echo") args: "/bin/echo" "hello" "world"
hello world
child exited with status 0
(The target here is a shell that itself execs /bin/echo, using the same process, so the tracer catches a genuine execve() made by the traced program. Plain /bin/echo would never trigger the output line, since echo itself never calls execve().)
AArch64 register layout, syscall-stop detection, and the x86_64 equivalent
The code above relies on two pieces of ABI (application binary interface) knowledge worth stating explicitly rather than leaving implicit in bare register indices.
Syscall-stop detection. ptrace(PTRACE_SETOPTIONS, child, 0, PTRACE_O_TRACESYSGOOD) tells the kernel to OR an extra bit (0x80) into the stop signal specifically when the tracee stops because it entered or exited a syscall, as opposed to stopping for an unrelated real signal (including a genuine SIGTRAP the tracee raised itself, e.g. from a breakpoint instruction). WSTOPSIG(status) != (SIGTRAP | 0x80) is exactly that check: without PTRACE_O_TRACESYSGOOD there is no reliable way to tell "this stop is a syscall boundary" apart from "this stop is something else," and the at_syscall_entry toggle would have nothing trustworthy to key off.
AArch64 syscall calling convention. On AArch64 Linux, a syscall's arguments are passed in registers x0 through x5, and the syscall number goes in x8. struct user_pt_regs (populated via PTRACE_GETREGSET with NT_PRSTATUS) exposes those as the regs[] array: regs.regs[8] is x8 (the syscall number, compared against SYS_execve_arm64), regs.regs[0] is x0 (the first argument, execve()'s path_addr), and regs.regs[1] is x1 (the second argument, execve()'s argv_addr). Nothing more exotic is happening: the code is reading the ABI-defined argument registers straight out of the register-set snapshot ptrace hands back.
The x86_64 equivalent, by name, not just position. On x86_64 Linux, the first two integer/pointer arguments to a syscall are rdi and rsi (the System V AMD64 calling convention's first two argument registers), so path_addr becomes regs.rdi and argv_addr becomes regs.rsi. The syscall number is not in rax: it's saved separately in orig_rax specifically because rax itself gets overwritten with the syscall's return value once the syscall actually runs, so by the time you observe the syscall-exit stop, rax no longer tells you which syscall just ran, only orig_rax still does. So the substitution the code comment calls for is precisely: regs.regs[8] becomes regs.orig_rax, regs.regs[0] becomes regs.rdi, regs.regs[1] becomes regs.rsi, read via PTRACE_GETREGS into a struct user_regs_struct (from <sys/user.h>) instead of PTRACE_GETREGSET/struct user_pt_regs, and the syscall-number constant becomes __NR_execve (59 on x86_64, from <sys/syscall.h>) instead of SYS_execve_arm64 (221). The SIGTRAP | 0x80 syscall-stop check and the rest of the event loop are architecture-independent and need no change.
This x86_64 mapping is standard, documented Linux/x86_64 ABI and ptrace behavior, not a guess, but unlike the aarch64 code above it has not itself been compiled and run for this answer: only the aarch64 path carries that verification. Treat it as a correct substitution to make and verify yourself, not as machine-checked output.
Attaching to an already-running process
The demo above uses fork() + PTRACE_TRACEME + execvp() to trace a freshly-launched program, which is convenient when the tracer gets to start the target. But the question specifically asks for attaching to a process that's already running, which is a different pair of primitives, and it is just as reproducible to demonstrate as the fork+TRACEME case: launch a target in the background first (exactly the situation an "attach to an existing PID" scenario is always in), note its PID, then attach to it. Below is a second, complete program that does exactly that, compiled and run the same way as the first, and also verified in a clean Linux/aarch64 container, gcc 13:
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/uio.h>
#include <asm/ptrace.h>
#define MAX_ARGS 16
#define MAX_ARGLEN 256
#define SYS_execve_arm64 221
static char *read_traced_string(pid_t pid, unsigned long addr) {
static char buf[MAX_ARGLEN];
size_t i = 0;
while (i < MAX_ARGLEN - sizeof(long)) {
errno = 0;
long word = ptrace(PTRACE_PEEKDATA, pid, addr + i, NULL);
if (word == -1 && errno != 0) break;
memcpy(buf + i, &word, sizeof(long));
if (memchr(&word, 0, sizeof(long)) != NULL) break;
i += sizeof(long);
}
buf[MAX_ARGLEN - 1] = '\0';
return buf;
}
int main(int argc, char **argv) {
if (argc < 2) { fprintf(stderr, "usage: %s <pid>\n", argv[0]); return 1; }
pid_t target = (pid_t)atoi(argv[1]);
/* PTRACE_ATTACH (or the gentler PTRACE_SEIZE, which doesn't stop the
* target immediately) requires either matching real/effective UID
* between tracer and tracee, or CAP_SYS_PTRACE, and is also subject to
* the Yama LSM's ptrace_scope sysctl, which on many distributions
* restricts attaching to only a process's own children by default. */
if (ptrace(PTRACE_ATTACH, target, NULL, NULL) == -1) {
perror("PTRACE_ATTACH");
return 1;
}
int status;
waitpid(target, &status, 0); /* the ATTACH-induced stop: there is no
exec-trap to synchronize on here,
since the target was already running
arbitrary code when we attached. */
ptrace(PTRACE_SETOPTIONS, target, 0, PTRACE_O_TRACESYSGOOD);
int at_syscall_entry = 1;
while (1) {
if (ptrace(PTRACE_SYSCALL, target, NULL, NULL) == -1) break;
waitpid(target, &status, 0);
if (WIFEXITED(status)) {
printf("target exited with status %d\n", WEXITSTATUS(status));
break;
}
if (!WIFSTOPPED(status) || WSTOPSIG(status) != (SIGTRAP | 0x80)) {
continue;
}
if (at_syscall_entry) {
struct user_pt_regs regs;
struct iovec iov = { .iov_base = ®s, .iov_len = sizeof(regs) };
ptrace(PTRACE_GETREGSET, target, (void *)(long)1 /* NT_PRSTATUS */, &iov);
if (regs.regs[8] == SYS_execve_arm64) {
unsigned long path_addr = regs.regs[0];
unsigned long argv_addr = regs.regs[1];
printf("execve(\"%s\") args:", read_traced_string(target, path_addr));
for (int i = 0; i < MAX_ARGS; i++) {
errno = 0;
long argp = ptrace(PTRACE_PEEKDATA, target,
argv_addr + (unsigned long)i * sizeof(long), NULL);
if (argp == 0 || (argp == -1 && errno != 0)) break;
printf(" \"%s\"", read_traced_string(target, (unsigned long)argp));
}
printf("\n");
fflush(stdout);
}
}
at_syscall_entry = !at_syscall_entry;
}
return 0;
}
Compiled with gcc -Wall -Wextra -o attach_tracer attach_tracer.c and run against an already-running background process (started with bash -c 'sleep 1; exec /bin/echo already-running-attach-worked' &, its PID passed as the argument), this prints, deterministically:
execve("/bin/echo") args: "/bin/echo" "already-running-attach-worked"
already-running-attach-worked
target exited with status 0
The mechanics: after PTRACE_ATTACH, the tracer waitpid()s for the attach-induced stop, sets PTRACE_O_TRACESYSGOOD exactly as before, then reuses an identical syscall-entry/exit loop; PTRACE_SYSCALL/PTRACE_GETREGSET/PTRACE_PEEKDATA don't care whether the tracee arrived via PTRACE_TRACEME or PTRACE_ATTACH, only the setup differs. In a real incident-response or security-tooling scenario the target PID is normally already known (from ps, a monitoring alert, or a specific process under investigation), so this attach-based path, not the fork+TRACEME one, is the code you'd actually reach for; fork+TRACEME is the right choice only when the tracer also controls how the target gets launched, the way strace prog does by default.
Performance and security limitations of ptrace in production
- Overhead. Every traced syscall costs (at minimum) two extra context switches between tracer and tracee (one stop at syscall-entry, one at syscall-exit) plus a
waitpid()round trip in the tracer, on top of whateverPTRACE_PEEKDATA/PTRACE_GETREGSETcalls the tracer makes to actually inspect the state. For a syscall-heavy workload, this is not a small tax, tracing every syscall of a busy process can slow it down by an order of magnitude or more, which is whyptrace-based tracing (unlike eBPF-based tracing) is a diagnostic/debugging tool, not something you'd leave attached to a production service continuously. - Single tracer per process. Only one tracer can be attached to a given process at a time (via the standard ptrace API); a debugger and a tracing tool can't both attach simultaneously.
- Setuid/setgid exemption. The kernel refuses to let an unprivileged tracer
ptrace()-attach to a process running a setuid or setgid binary: allowing that attach would let a tracer with only ordinary-user credentials read and rewrite the memory of a process running with privilege the tracer itself doesn't have, defeating the whole point of the setuid/setgid mechanism. This is a real limitation if the thing you need to trace legitimately runs with elevated privilege; you either need to be privileged yourself (typically root, orCAP_SYS_PTRACE) or you're locked out of tracing it this way. - PTRACE_PEEKDATA's word-at-a-time cost. Reading a string out of the target's memory one machine word per syscall, as the demo above does, is simple to reason about but slow for large reads;
process_vm_readv(2)(a newer, purpose-built syscall for cross-process memory reads) does the same job in far fewer syscalls and is the better choice for a tracer that needs to read substantial amounts of target memory, not just short argv strings. - It's a debugging primitive, not a hardened sandbox boundary. A ptrace-based interception layer (e.g. denying/rewriting syscalls via
PTRACE_SYSCALLplusSECCOMP_RET_TRACE) is significantly more fragile and slower than a seccomp-bpf filter for actually restricting what a process can do; ptrace is the right tool for observing and debugging, seccomp is the right tool for enforcing a policy, and conflating the two (using ptrace as your actual security boundary rather than as an inspection tool) is a common mistake.
Unlock Full Question Bank
Get access to all 7 System Calls & the Kernel Interface interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.