Skip to main content

Command Palette

Search for a command to run...

Linux File System Hunting: Dispatches from Inside the Machine

Updated
10 min read
Linux File System Hunting: Dispatches from Inside the Machine

Most people treat the Linux filesystem as a backdrop — a place to store files. But if you know where to look, it is a living dashboard of the entire operating system. This blog is the result of hunting through that landscape on a real Ubuntu 24.04 LTS system, looking not for commands to memorise, but for stories the system is quietly telling.


Finding 1 — /proc/self: The Mirror the Kernel Holds Up to Every Process

Every running process on Linux has its own private window into the kernel at /proc/<PID>/. Reading /proc/self is a shortcut — it always refers to the process doing the reading.

When I opened /proc/self/fd, I found this:

lrwxrwxrwx  0 -> pipe:[15]   (stdin)
lrwxrwxrwx  1 -> pipe:[16]   (stdout)
lrwxrwxrwx  2 -> pipe:[17]   (stderr)

These are not placeholders. Each is a live symlink to the actual kernel object behind stdin, stdout, and stderr. The numbers 15, 16, 17 are inode identifiers in the kernel's pipefs. This is how shell piping actually works — the shell sets up pipe inodes and wires your process's FD 0/1/2 to them before execve() is called.

/proc/self/maps was even more revealing — it shows the full virtual memory layout:

55bbb50c5000-55bbb50c7000  r--p   /usr/bin/head   (ELF header, read-only)
55bbb50c7000-55bbb50cd000  r-xp   /usr/bin/head   (text segment, executable)
55bbb50d1000-55bbb50f2000  rw-p   [heap]
7ecb459b0000-7ecb461b0000  rw-p   [stack]
7edab0800000-7edab0828000  r--p   /usr/lib/x86_64-linux-gnu/libc.so.6

Why it matters: The kernel enforces W^X (Write XOR Execute) at this level. The text segment is r-xp but never rw-. Shared libraries are mapped once into the kernel's page cache and Copy-on-Write'd into each process — which is how 50 bash processes can share one libc.so.6 in RAM without 50 copies of it.

Security insight: A process that maps a region as rw- and then re-maps it as r-x is the classic shellcode injection footprint. Forensic tools detect this at the /proc level.


Finding 2 — The DNS Stack Is Three Files Deep

Name resolution in Linux is not a single mechanism. It is an ordered chain controlled by three cooperating files:

/etc/nsswitch.conf — The Dispatch Table

hosts:   files dns

The word files means check /etc/hosts first. The word dns means fall through to a resolver if /etc/hosts has no match. This one line is why you can block any domain by pointing it to 127.0.0.1 in /etc/hosts — without touching DNS servers at all.

/etc/hosts — The Local Authority

On this system:

# BEGIN CONTAINER MANAGED HOSTS
127.0.0.1   localhost
127.0.0.1   runsc
# END CONTAINER MANAGED HOSTS

The BEGIN/END CONTAINER MANAGED HOSTS comment markers reveal something important: this file was not user-managed. The container runtime injected these entries on startup. The hostname runsc is the kernel's identity inside the sandbox.

/etc/resolv.conf — The Upstream Resolver

nameserver 8.8.8.8

Why it matters: The full chain is: nsswitch.conf decides the order → /etc/hosts is checked first → /etc/resolv.conf points upstream if hosts fails. On systemd systems, /etc/resolv.conf is often a symlink to /run/systemd/resolve/stub-resolv.conf, routing all queries through a local stub resolver at 127.0.0.53 that handles caching and DNSSEC — invisible from resolv.conf alone.


Finding 3 — The Routing Table Hidden in ASCII

Network routing is usually checked with ip route. But the actual data lives in /proc/net/route as raw hex:

Iface        Destination  Gateway   Flags  Mask
3bcbb706c0-v 4A010415     00000000  0001   7FFFFFFF
3bcbb706c0-v 00000000     4B010415  0003   00000000

Decoding the little-endian hex with Python:

import socket, struct

def decode(h):
    return socket.inet_ntoa(struct.pack('<I', int(h, 16)))

decode('4A010415')  # → 21.4.1.74  (network)
decode('4B010415')  # → 21.4.1.75  (default gateway)

The Flags column tells the full story: 0001 = route is Up. 0003 = Up + Gateway. The second entry with destination 00000000 and Mask 00000000 is the default route — it catches all unmatched traffic and hands it to the gateway.

The interface name 3bcbb706c0-v (not a typical eth0 or ens3) immediately signals something: the -v suffix is gVisor's virtual network interface naming convention.

Why it matters: Reading /proc/net/route directly is how network monitoring tools like netstat and ss actually work. They don't shell out to ip route — they parse this file. It's faster and requires no external binary.


Finding 4 — /proc/net/tcp: Active Sockets as a Hex Table

Every TCP socket on the system is listed in /proc/net/tcp:

sl  local_address  rem_address  st
 3: 00000000:07E8  00000000:0000  0A   ← LISTEN
41: 4A010415:07E8  963C040A:DF70  01   ← ESTABLISHED
  • Port 0x07E8 → decimal 2024

  • State 0ALISTEN

  • State 01ESTABLISHED

  • Remote IP 963C040A10.4.60.150 (decoded little-endian)

  • Remote port 0xDF7057200 (ephemeral port)

The ESTABLISHED entry is the very TCP session this process is running inside.

Security implication: /proc/net/tcp is world-readable on most Linux systems. Any local user can enumerate every listening port and active connection, including the UID that owns each socket. Attackers with local access routinely parse this file to map the network attack surface before escalating privileges.


Finding 5 — /proc/self/cgroup: You Are Where the Container Put You

cgroups (control groups) enforce resource limits on groups of processes. Every process belongs to a cgroup hierarchy, visible at /proc/self/cgroup:

7:pids:/container_012bqMcAkkVq7TwVzQUrxe5g--wiggle--9bc569
6:memory:/container_012bqMcAkkVq7TwVzQUrxe5g--wiggle--9bc569/process_api/41a14caf...
5:job:/container_012bqMcAkkVq7TwVzQUrxe5g--wiggle--9bc569
1:cpu:/container_012bqMcAkkVq7TwVzQUrxe5g--wiggle--9bc569

Each line: hierarchy_id : subsystem : cgroup_path. The process is in separate cgroups for PIDs, memory, CPU, cpuacct, and cpuset — each managed independently. The memory subsystem has a nested path, meaning this process is in a sub-cgroup within the container's cgroup — allowing per-process memory limits inside the container boundary.

The memory limit at /sys/fs/cgroup/memory/memory.limit_in_bytes was 9223372036854771807 — the maximum int64 value, meaning "no hard limit" at this cgroup level. Limits are enforced at the container runtime layer above.

Why it matters: Docker, Kubernetes, and all container runtimes are fundamentally cgroup managers. The container ID in the cgroup path is the container. docker stats works by reading /sys/fs/cgroup/memory/<container_id>/memory.usage_in_bytes — not some internal Docker API.


Finding 6 — /proc/self/ns: Linux Namespaces Are Just Inodes

Linux containers are built on namespaces — kernel mechanisms that give a process an isolated view of the system. Every namespace a process belongs to is visible in /proc/self/ns/:

lrwxrwxrwx  ipc  -> ipc:[2]
lrwxrwxrwx  mnt  -> mnt:[5]
lrwxrwxrwx  net  -> net:[1]
lrwxrwxrwx  pid  -> pid:[4]
lrwxrwxrwx  user -> user:[1323]
lrwxrwxrwx  uts  -> uts:[3]

Each namespace is a symlink pointing to a kernel object identified by an inode number. Two processes sharing the same inode number share the same namespace — they see the same network stack, filesystem mounts, and process tree. Different inodes = isolated.

The net namespace has inode 1 — a suspiciously low number suggesting proximity to the host's network namespace. This kind of detail matters in container escape analysis.

The insight: Namespaces have no names — only inodes. "Entering a container" means having your process re-associated with that container's set of namespace inodes. This is exactly what docker exec does: it calls setns() with file descriptors pointing to the target container's /proc/<pid>/ns/* files.


Finding 7 — The passwd/shadow Split: A Security Decision Made Visible

/etc/passwd is world-readable. On this system:

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin

The x in the password field is not the password — it is a pointer. It means: look in /etc/shadow. /etc/shadow holds the actual hashed password and is readable only by root and the shadow group:

-rw-r----- 1 root shadow 609 Apr 11 /etc/shadow

This split exists because programs like ls and id need to read /etc/passwd to display usernames, but must never read password hashes. Before shadow passwords (early Unix), /etc/passwd was readable by everyone — and so were the hashes.

Notice system accounts like daemon and bin have /usr/sbin/nologin as their shell. This is not just documentation — PAM checks the shell field. If it points to nologin, login is rejected before any password prompt appears.

/etc/login.defs insight:

UID_MIN    1000
UID_MAX   60000

Human users start at UID 1000. UIDs 1–999 are reserved for system accounts. A process running as UID 33 (www-data) cannot write files owned by UID 1000. This boundary is the first line of privilege isolation.


Finding 8 — setuid Binaries: Privilege Pinned to the Filesystem

Nine binaries on this system carry the setuid-root bit:

/usr/bin/chfn    /usr/bin/chsh    /usr/bin/fusermount3
/usr/bin/gpasswd /usr/bin/mount   /usr/bin/newgrp
/usr/bin/passwd  /usr/bin/su      /usr/bin/umount

When you run /usr/bin/passwd as an ordinary user, it temporarily runs as root (UID 0) — long enough to write your new password hash to /etc/shadow, which is root-owned. The setuid bit makes this possible without giving you a root shell.

mount and umount being setuid-root is equally interesting. A user can mount a filesystem, but only within the constraints the kernel imposes on non-root mount calls. The setuid bit runs the syscall as root, but the kernel still validates mount options and filesystem type.

Security implication: Every setuid binary is a potential privilege escalation vector if it has an exploitable bug. Security hardening often involves removing setuid bits that aren't needed (e.g. chfn/chsh on servers), replacing them with Linux capabilities, or confining them with AppArmor profiles.


Finding 9 — /etc/skel: The Blueprint for Every New User

When you create a user with useradd, Linux doesn't just write a line to /etc/passwd. It copies the entire contents of /etc/skel into the new user's home directory:

/etc/skel/.bash_logout   ← runs when the user logs out
/etc/skel/.bashrc        ← interactive shell configuration
/etc/skel/.profile       ← login shell configuration

These three files are the skeleton of every new user's environment. .bashrc sets up aliases and shell options for interactive sessions. .profile sets PATH and invokes .bashrc for login shells. .bash_logout can wipe terminal history or run cleanup on exit.

An administrator who adds a company-wide alias or security banner places it in /etc/skel/.bashrc before creating accounts — every new user inherits it. Existing users are not affected.

Persistence insight: An attacker with write access to /etc/skel can plant malicious .bashrc code that executes as any new user who logs in. This is a real post-exploitation persistence technique — add a reverse shell callback to /etc/skel and wait for an admin to provision a new account.


Finding 10 — Container Runtime Fingerprinting Through the Filesystem Alone

By the end of the investigation, the filesystem had revealed something never explicitly documented anywhere: this is not a bare-metal Linux system. It's a gVisor-sandboxed container. The evidence:

Artifact What It Reveals
Linux version 4.4.0 #1 SMP Sun Jan 10 15:06:54 PST 2016 Not a real 2016 kernel — gVisor's synthetic kernel version string
Interface 3bcbb706c0-v in /proc/net/route gVisor's virtual network interface naming convention
Mounts using 9p protocol in /proc/mounts gVisor uses Plan 9 filesystem protocol to expose host directories
Hostname runsc in /proc/sys/kernel/hostname runsc is gVisor's sandbox runner binary
Container ID in /proc/self/cgroup Full Anthropic process orchestration path visible

None of this required any special tools. No docker inspect, no cloud metadata APIs, no external queries. Just reading files.


Closing Thoughts

Linux exposes almost everything through the filesystem. Processes publish their memory maps, open files, cgroup memberships, and namespace associations. The network stack publishes routing tables and TCP socket state in ASCII. The kernel's own tunable variables live in /proc/sys and /sys, readable without any special tools.

This design — everything is a file — is both Linux's greatest transparency feature and its most interesting security frontier.

The system is always talking. You just have to know which files to read.


Explored on Ubuntu 24.04 LTS (Noble Numbat) — April 2026

Tags: linux devops security systems-programming networking