Safe Penetration Testing Labs · beginner · ~20 min

/proc forensics — a cookbook

**What you will learn** - Open and read Linux `/proc` pseudo-files safely with `fopen` + `fgets`, checking every return value. - Answer three core live-forensics questions on a host you are authorized to inspect: **what is running**, **what is listening**, and **who is a process talking to**. - Parse labelled fields from `/proc/PID/status` and NUL-separated data from `/proc/PID/cmdline`. - Decode a `/proc/net/tcp` line to spot a service listening on a public interface (`0.0.0.0`). - Apply defensive habits: bounded reads, permission-aware error handling, and forensic logging of what you inspected. - Explain why direct `/proc` reads are the foundation under `ps`, `ss`, `lsof`, `top`, and agents like osquery and falco.

Overview

Security objective. The asset you are protecting is the integrity and visibility of a live Linux host — a server you own or are explicitly authorized to inspect during incident response. The threat is a hidden or unauthorized process: malware, a backdoor listener, a crypto-miner, or a service someone left exposed to the internet. What you will detect is a suspicious process, an unexpected public listener, or an open file/socket that should not be there. You do this defensively — read-only observation, no exploitation.

/proc is the Linux kernel's introspection API, exposed as a virtual filesystem. Nothing under /proc lives on disk; the kernel synthesizes each file's contents the instant you read it. Every running process has a directory /proc/PID/ holding its memory map, open file descriptors, environment, command line, and status. There are also system-wide files such as /proc/net/tcp listing every socket.

This builds directly on your prerequisites. From File descriptors you know that open files, sockets, and pipes are all small integers a process holds — /proc/PID/fd/ is exactly that table made visible. From fopen and the stdio model you know the fopenfgetsfclose pattern and why you always check the returned FILE *. Every recipe here is that same pattern pointed at a /proc path. Because these are plain text files, the tool you already have — buffered line reading — is all you need to build your own defender tooling.

Why it matters

In authorized professional work — incident response, threat hunting, blue-team monitoring, container security — /proc is the single most concentrated source of live-system truth. When a host is suspected compromised, you cannot trust its installed binaries: an attacker may have replaced ps or netstat with tampered versions that hide their own process. Reading /proc directly with a small trusted program you brought with you sidesteps that whole class of deception.

A defender who can read /proc does not need ten separate tools and does not depend on the target's toolchain. The same skill scales up: production observability and security agents (auditd consumers, osquery, falco, container runtimes) are, at their core, structured readers of /proc. Understanding the raw interface makes you far better at configuring, trusting, and debugging those agents — and at knowing their blind spots.

Core concepts

1. /proc is a virtual (pseudo) filesystem

Definition. /proc is a kernel interface that looks like files and directories but is generated on demand. Reading a file runs kernel code; the bytes did not exist a moment before.

How it works. When you fopen("/proc/self/status") and read, the kernel formats the current state of that process into text right then. There is no on-disk block to seek within.

When / when not. Use it for live introspection of a running system. It is not a source for a process that has already exited (its directory disappears), and it is not for random-access parsing — read sequentially.

Pitfall. Many /proc files are cursor-driven single-shot streams. Using lseek/pread or reopening mid-read can give truncated or inconsistent data. Open, read start-to-finish, close.

2. The per-process directory /proc/PID

Each live process has /proc/PID/. Key files:

Path Contents Forensic use
/proc/PID/cmdline argv, joined by NUL bytes What program + arguments launched it
/proc/PID/status Labelled text: Name, PPid, Uid, VmRSS, Threads, CapEff Identity, parent, memory, privileges
/proc/PID/maps Virtual memory regions + permissions Injected/executable-writable regions
/proc/PID/fd/ Symlinks to open files/sockets/pipes What the process has open
/proc/PID/environ Inherited env vars, NUL-separated Leaked secrets, unusual config
/proc/self/… Shortcut to the calling process Test your parser on yourself

A NUL byte (\0) is the zero character C uses to end strings. In cmdline and environ, /proc uses it as a separator between items, so a naive printf("%s") stops at the first argument.

3. System-wide socket tables: /proc/net/tcp

Definition. /proc/net/tcp (plus tcp6, udp, udp6) lists every socket the kernel knows about, one per line, in hex. The local_address column is IP:PORT in little-endian hex, and the st column is the TCP state (0A = LISTEN).

Plain explanation. A row with state 0A and a local address of 00000000:XXXX is a service listening on 0.0.0.0 — every network interface, i.e. reachable from outside the host. That is the pentester/defender's first question: what did we accidentally expose?

Pitfall. The IP is little-endian hex, so 0100007F is 127.0.0.1, not 1.0.0.127. Getting byte order wrong makes a loopback-only service look public.

The pentester (defensive) mindset

Scan /proc/net/tcp for LISTEN sockets bound to 0.0.0.0, then pair each with the owning /proc/PID/cmdline to name the process behind it. That single loop reproduces the useful core of ss -ltnp — and you can trust it because you wrote it.

THREAT MODEL — live-host /proc inspection (authorized)

  ASSET: truth about what is running / listening on the host
  ATTACKER GOAL: hide a process or a public backdoor listener

  +---------------------------- Linux host (in scope) ----------------------------+
  |                                                                               |
  |   [ Kernel ]  --generates-->  /proc  (pseudo-files, read-only view)           |
  |       ^                          |                                           |
  |       | authoritative            | fopen + fgets                             |
  |   =====|==========TRUST BOUNDARY==|===========================                |
  |       |                          v                                           |
  |   tampered  ps/netstat  <-x-  YOUR trusted C reader ---> findings + log       |
  |   (untrusted)                                                                 |
  +------------------------------------|------------------------------------------+
                                       | (entry point: network sockets in 0.0.0.0)
                                       v
                             outside / attacker network

  Trust boundary: kernel-generated /proc is trusted; on-disk userland binaries are NOT.
  Entry point under scrutiny: public LISTEN sockets (state 0A, addr 00000000).

Knowledge check

  1. What asset is protected? The defender's accurate view of what is running and listening on a host — so hidden processes and exposed services cannot slip by.
  2. Where is the trust boundary? Between the kernel (which generates /proc and is authoritative) and the host's installed userland binaries (which an attacker may have replaced). Your own reader stays on the trusted side.
  3. What insecure assumption lets a backdoor hide? Trusting ps/netstat output on a possibly-compromised host. Reading /proc directly removes that assumption.
  4. Why only in an authorized lab or engagement? Enumerating processes, sockets, and especially environ can expose other users' secrets; do it only on systems you own or are explicitly authorized to inspect.

Syntax notes

Every recipe is the stdio model you already know, pointed at a /proc path. Read sequentially, bound the buffer, check fopen.

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

/* Open a /proc file, read it line by line, never overflow the buffer. */
FILE *fp = fopen("/proc/self/status", "r");  /* read-only text */
if (!fp) { perror("fopen"); return 1; }       /* ALWAYS check: perms/EACCES */

char line[4096];                              /* fixed cap = bounded read   */
while (fgets(line, sizeof line, fp)) {        /* fgets stops at buffer-1     */
    /* strncmp against a label, then parse the value after the colon */
    if (strncmp(line, "VmRSS:", 6) == 0)
        fputs(line, stdout);
}
fclose(fp);                                   /* release the FILE*           */

For NUL-separated files (cmdline, environ) do not use fgets/%s; read a raw block with fread and walk the bytes, replacing each \0 with a space or newline for display.

Lesson

On Linux, /proc is a virtual filesystem that the kernel exposes for introspection.

Every running process has a directory at /proc/PID/. Inside it you find the process's memory map, open file descriptors, environment, command line, and more.

Forensic tools are built on top of this interface. Learning to read it directly is the foundation of writing your own defender tooling.

Code examples

The task: list every public TCP listener and name the process behind it — a read-only exposure audit. We show an insecure first cut, the hardened version, and a verification step.

(1) WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy.

/* INSECURE SKETCH — do not deploy. Illustrates two real bugs. */
#include <stdio.h>
int main(void) {
    FILE *fp = fopen("/proc/net/tcp", "r");
    char line[64];                    /* BUG 1: too small; lines are ~150+ bytes */
    while (fgets(line, sizeof line, fp)) {   /* BUG 2: fp never checked for NULL  */
        unsigned st;
        /* naive: assumes fixed columns, ignores byte order and 0.0.0.0 check */
        printf("%s", line);
    }
    return 0;                          /* BUG 3: no fclose, no findings, no log  */
}

Why it is dangerous: if fopen fails (restricted mount, sandbox) fp is NULL and fgets dereferences it — a crash mid-investigation. The 64-byte buffer splits every row, so parsing silently corrupts. It also just dumps raw hex, proving nothing.

(2) SECURE version

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <time.h>

/* Read /proc/<pid>/cmdline (NUL-separated) into buf as a space-joined string. */
static void read_cmdline(long pid, char *buf, size_t n) {
    char path[64];
    snprintf(path, sizeof path, "/proc/%ld/cmdline", pid);
    FILE *fp = fopen(path, "r");
    if (!fp) { snprintf(buf, n, "<unknown>"); return; }
    size_t got = fread(buf, 1, n - 1, fp);
    fclose(fp);
    if (got == 0) { snprintf(buf, n, "<none>"); return; }
    for (size_t i = 0; i < got; i++)      /* turn NUL separators into spaces */
        if (buf[i] == '\0') buf[i] = ' ';
    buf[got] = '\0';
}

/* Forensic log: timestamp + what we decided. Never logs payloads/secrets. */
static void audit_log(const char *decision, unsigned port, long inode) {
    time_t now = time(NULL);
    char ts[32];
    strftime(ts, sizeof ts, "%Y-%m-%dT%H:%M:%S", gmtime(&now));
    fprintf(stderr, "[audit] %sZ decision=%s port=%u inode=%ld\n",
            ts, decision, port, inode);
}

int main(void) {
    FILE *fp = fopen("/proc/net/tcp", "r");
    if (!fp) { perror("fopen /proc/net/tcp"); return 1; }  /* checked */

    char line[512];                       /* generously sized, bounded */
    if (!fgets(line, sizeof line, fp)) {  /* discard the header row */
        fclose(fp);
        fprintf(stderr, "empty /proc/net/tcp\n");
        return 1;
    }

    int public_listeners = 0;
    while (fgets(line, sizeof line, fp)) {
        unsigned laddr, lport, st;
        long inode = 0;
        /* local_addr is HEX ip:port; st is the TCP state; inode is col 10 */
        int fields = sscanf(line,
            "%*d: %8x:%4x %*8x:%*4x %2x %*x:%*x %*x:%*x %*x %*d %*d %ld",
            &laddr, &lport, &st, &inode);
        if (fields < 4) continue;         /* skip malformed rows safely */

        /* st 0x0A == LISTEN; laddr 0x00000000 == bound to 0.0.0.0 (public) */
        if (st == 0x0A && laddr == 0x00000000) {
            printf("PUBLIC LISTEN  port=%u  inode=%ld\n", lport, inode);
            audit_log("public_listen", lport, inode);
            public_listeners++;
        }
    }
    fclose(fp);

    printf("Found %d public TCP listener(s).\n", public_listeners);
    /* (Mapping inode -> pid means scanning /proc/PID/fd symlinks; see tasks.) */
    return 0;
}

Expected output (exact ports depend on your lab host):

PUBLIC LISTEN  port=22  inode=27310
PUBLIC LISTEN  port=8080  inode=51992
Found 2 public TCP listener(s).

and on stderr:

[audit] 2026-07-08T14:03:11Z decision=public_listen port=22 inode=27310
[audit] 2026-07-08T14:03:11Z decision=public_listen port=8080 inode=51992

(3) VERIFY the audit is correct

Prove it accepts real public listeners and rejects loopback-only ones:

# Ground truth from the system tool (blue-team cross-check):
ss -ltn
# Every 0.0.0.0:PORT row there must appear in your program's output,
# and no 127.0.0.1:PORT row should.

# Positive test: start a lab listener on all interfaces, re-run — it appears.
nc -l -k -p 9999 &          # binds 0.0.0.0:9999 in the lab
./audit                     # expect: PUBLIC LISTEN port=9999

# Negative test: a loopback-only listener must NOT be flagged public.
nc -l -k -s 127.0.0.1 -p 9998 &
./audit                     # expect: port 9998 absent from output

# Cleanup:
kill %1 %2 2>/dev/null

If a 127.0.0.1 listener shows up as public, your byte-order or laddr check is wrong — fix it before trusting the tool.

Line by line

Walkthrough of the secure main loop over /proc/net/tcp.

Step Code What happens
Open fopen("/proc/net/tcp", "r") Kernel prepares a fresh text snapshot of all TCP sockets
Guard if (!fp) On a restricted/sandboxed mount this is NULL; we bail with perror instead of crashing
Header first fgets discarded Row 0 is sl local_address …; skipping it avoids parsing labels as data
Read while (fgets(line, sizeof line, fp)) One socket per iteration; fgets never writes past line[511]
Parse sscanf(... "%8x:%4x ... %2x ... %ld") Pulls local IP (hex), local port (hex), state (hex), inode
Validate if (fields < 4) continue A short/garbled line is skipped, never trusted
Decide st == 0x0A && laddr == 0x00000000 LISTEN and bound to every interface ⇒ publicly exposed
Report + log printf + audit_log Human output plus a timestamped forensic record

How the values change on a sample loopback line ... 0100007F:1F90 ... 0A ...:

  • laddr = 0x0100007F (that is 127.0.0.1 little-endian), lport = 0x1F90 = 8080, st = 0x0A.
  • The condition laddr == 0x00000000 is false ⇒ not flagged. Correct: loopback is not public.

On a public line ... 00000000:0016 ... 0A ...: laddr == 0 and st == 0x0A ⇒ flagged, lport = 0x16 = 22 (SSH). That is why byte order and the exact state value matter.

Common mistakes

Wrong approach Why it is wrong Corrected How to recognise / prevent
Read cmdline/environ with printf("%s") Fields are NUL-separated; %s stops at the first \0, so you see only argv[0] fread the block, replace each \0 with a space, then print You only ever see one argument even for multi-arg commands
Interpreting local_address as normal-order IP It is little-endian hex; 0100007F misreads as 1.0.0.127 Treat bytes least-significant first, or compare the raw hex (00000000 = 0.0.0.0) A 127.0.0.1 service shows up as "public"
lseek/pread on /proc files Many are single-shot cursor streams; random access truncates Open, fgets/fread start-to-finish, close Output randomly missing rows or fields
Not checking fopen Restricted files (environ of another user needs CAP_SYS_PTRACE) return NULL Check the return, perror, skip gracefully Segfault when reading privileged targets
Trusting host ps/ss on a compromised box Attacker may have replaced them to hide themselves Read /proc with your own static binary Tool output disagrees with raw /proc
Assuming a passed scanner means "secure" A clean scan only means those checks passed, not that the host is safe Treat scans as one signal; keep reading /proc and correlating Overconfidence after green results

Debugging tips

  • fopen returns NULL. Check errno/perror. EACCES on another process's environ/fd is expected without CAP_SYS_PTRACE or matching UID — run in your lab as the right user, or accept the file is out of scope.
  • Port/IP look absurd (e.g. port 34816). You forgot the hex is byte-swapped, or sscanf field widths are off. Print the raw laddr/lport hex and compare against ss -ltn.
  • Only the first argument of a command appears. You used %s on cmdline; switch to fread + NUL replacement.
  • Your listener list disagrees with ss. Re-check the state constant (0x0A = LISTEN) and remember ss also shows tcp6; add /proc/net/tcp6 for parity.
  • Random missing rows. You seeked or reopened mid-stream — read once, sequentially.

Questions to ask when it fails: Did I check fopen? Is my buffer large enough for the longest real line? Am I honouring little-endian byte order? Am I reading NUL-separated data as a C string by mistake? Does my output match the trusted cross-check tool?

Memory safety

Security & safety — detection and logging.

Memory-wise: bound every read (fgets with sizeof, fread with n-1), NUL-terminate before printing, and never build a /proc/%d/... path without snprintf (bounded) — an unbounded sprintf with a huge PID is a classic overflow.

What to log for a /proc inspection so findings are defensible: an ISO-8601 UTC timestamp; the operator/source identity; the resource inspected (path, PID, port, socket inode); the security decision (e.g. public_listen, benign); and a correlation id tying rows from one scan together. That is enough to reconstruct "who looked at what, when, and what they concluded."

What to NEVER log: the raw contents of /proc/PID/environ (it routinely holds API_KEY, DB_PASSWORD, tokens), session cookies, private keys, full credentials, or unnecessary PII. Log that you read environ and your verdict — never the values. If you must record a secret's presence, record a redacted marker like env_has_token=true, not the token.

Events that signal abuse: a new public LISTEN on an unexpected high port; a process whose /proc/PID/maps has writable+executable regions; an fd symlink pointing to a deleted binary ((deleted) suffix); a child of an unusual parent (PPid chain). False positives are common — legitimate services bind 0.0.0.0 (SSH, web servers), and JITs create RWX pages. Correlate with an approved baseline before escalating, and never flag on a single signal alone.

Real-world uses

Authorized real-world use. During incident response on a server you own or are contracted to inspect, you copy a small statically-linked /proc reader onto the host, enumerate public listeners and their owning processes, and diff against a known-good baseline — all without trusting the box's own ps/netstat. This is exactly how responders find a backdoor listener the tampered tools were hiding.

Authorization checklist (before any lab or engagement):

  • Written authorization / scope covering this host and these actions.
  • Target is one you own, a container, an intentionally-vulnerable VM, or a CTF.
  • Read-only intent confirmed; no modification of the target.
  • A place to store findings and logs securely (no secrets copied out).
  • Lab cleanup/reset plan ready (kill test listeners, revert the VM snapshot).

Best-practice habits. Validate every parsed field before trusting it; run with least privilege (only elevate for the specific files that need CAP_SYS_PTRACE); prefer secure defaults (bounded buffers, checked fopen); log decisions, not secrets; and handle permission errors gracefully instead of crashing.

Beginner Advanced
Scope /proc/self and your own processes System-wide across all PIDs, containers, namespaces
Data status, cmdline, net/tcp maps (RWX hunting), fd (deleted-binary detection), tcp6/udp
Output print findings correlate to a baseline, emit structured logs, feed a SIEM
Trust assume tools are fine assume host binaries may be tampered; bring your own reader

Practice tasks

All tasks are lab-only — run on a VM, container, or host you own. Each ends by remediating and verifying, not exploiting.

Beginner 1 — VmRSS of self.

  • Objective: print the resident memory (VmRSS) of your own process.
  • Requirements: fopen("/proc/self/status"), fgets, strncmp(line, "VmRSS:", 6).
  • Output: one line like VmRSS: 1234 kB.
  • Constraints: check fopen; buffer ≤ 4096 bytes.
  • Hints: the value follows the label after whitespace. Concepts: labelled-field parsing.

Beginner 2 — cmdline printer.

  • Objective: print /proc/self/cmdline with arguments separated by spaces.
  • Requirements: fread the block; replace \0 with ' '; NUL-terminate.
  • Input/Output: run as ./a.out one two./a.out one two.
  • Constraints: do not use %s/fgets. Hint: count bytes returned by fread. Concepts: NUL-separated data.

Intermediate 1 — process lister.

  • Objective: for every numeric directory in /proc, print PID<TAB>cmdline.
  • Requirements: opendir/readdir on /proc; skip non-numeric names; reuse Beginner 2.
  • Constraints: skip PIDs that vanish mid-scan (handle fopen failure). Hint: strtol to test numeric names. Concepts: enumeration, graceful permission handling.

Intermediate 2 — public-listener audit with logging.

  • Objective: list LISTEN sockets bound to 0.0.0.0 from /proc/net/tcp, and log each finding with a UTC timestamp and inode.
  • Requirements: parse hex local_address + state 0x0A; emit an audit line to stderr; never log secrets.
  • I/O: prints PUBLIC LISTEN port=N inode=M.
  • Constraints: cross-check against ss -ltn. Defensive conclusion: for any unexpected listener, note the remediation (bind to 127.0.0.1 or firewall it) and verify by re-running the audit until it is gone.

Challenge — inode → process, then remediate.

  • Objective: map a public listener's socket inode to the owning PID by scanning /proc/PID/fd/ symlinks for socket:[inode], print port → PID → cmdline.
  • Requirements: readlink on each fd; match the inode; reuse earlier recipes.
  • Constraints: lab host only; handle EACCES on other users' fds; bounded buffers throughout.
  • Defensive conclusion: pick one unexpected exposed service, remediate it (rebind to loopback or add a firewall rule), then re-run your tool to prove the public listener is gone and record the before/after in your log. Cleanup: kill any test listeners and revert the VM snapshot. Concepts: fd symlinks, correlation, mitigation verification.

Summary

  • /proc is the kernel's introspection filesystem — pseudo-files generated on demand, the most concentrated live-forensics resource on Linux and the foundation under ps, ss, lsof, top, osquery, and falco.
  • Per-process data lives in /proc/PID/ (status, cmdline, maps, fd/, environ); system-wide sockets in /proc/net/tcp*. /proc/self points at you.
  • Access pattern: fopenfgets/freadfclose, read sequentially, bound the buffer, and always check fopen.
  • Key syntax: strncmp(line, "Label:", n) for status fields; hex sscanf for net/tcp (0x0A = LISTEN, 00000000 = 0.0.0.0); fread + NUL-replacement for cmdline/environ.
  • Common mistakes: %s on NUL-separated files, ignoring little-endian byte order, seeking on cursor streams, and unchecked fopen.
  • Defensive standard: trust the kernel over possibly-tampered host binaries; log the decision (timestamp, resource, verdict, correlation id) but never log secrets from environ; correlate before escalating; and always finish by remediating and verifying the fix. Only ever inspect systems you own or are authorized to test — nothing is ever "completely secure."

Practice with these exercises