Safe Penetration Testing Labs · beginner · ~20 min
**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.
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 fopen → fgets → fclose 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.
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.
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.
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. Incmdlineandenviron,/procuses it as a separator between items, so a naiveprintf("%s")stops at the first argument.
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.
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
/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.ps/netstat output on a possibly-compromised host. Reading /proc directly removes that assumption.environ can expose other users' secrets; do it only on systems you own or are explicitly authorized to inspect.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.
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.
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.
/* 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.
#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
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.
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.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.
| 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 |
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.sscanf field widths are off. Print the raw laddr/lport hex and compare against ss -ltn.%s on cmdline; switch to fread + NUL replacement.ss. Re-check the state constant (0x0A = LISTEN) and remember ss also shows tcp6; add /proc/net/tcp6 for parity.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?
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.
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):
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 |
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.
VmRSS) of your own process.fopen("/proc/self/status"), fgets, strncmp(line, "VmRSS:", 6).VmRSS: 1234 kB.fopen; buffer ≤ 4096 bytes.Beginner 2 — cmdline printer.
/proc/self/cmdline with arguments separated by spaces.fread the block; replace \0 with ' '; NUL-terminate../a.out one two ⇒ ./a.out one two.%s/fgets. Hint: count bytes returned by fread. Concepts: NUL-separated data.Intermediate 1 — process lister.
/proc, print PID<TAB>cmdline.opendir/readdir on /proc; skip non-numeric names; reuse Beginner 2.fopen failure). Hint: strtol to test numeric names. Concepts: enumeration, graceful permission handling.Intermediate 2 — public-listener audit with logging.
LISTEN sockets bound to 0.0.0.0 from /proc/net/tcp, and log each finding with a UTC timestamp and inode.local_address + state 0x0A; emit an audit line to stderr; never log secrets.PUBLIC LISTEN port=N inode=M.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.
/proc/PID/fd/ symlinks for socket:[inode], print port → PID → cmdline.readlink on each fd; match the inode; reuse earlier recipes.EACCES on other users' fds; bounded buffers throughout./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./proc/PID/ (status, cmdline, maps, fd/, environ); system-wide sockets in /proc/net/tcp*. /proc/self points at you.fopen → fgets/fread → fclose, read sequentially, bound the buffer, and always check fopen.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.%s on NUL-separated files, ignoring little-endian byte order, seeking on cursor streams, and unchecked fopen.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."