Skip to main content

Command Palette

Search for a command to run...

Linux File System Hunting

Updated
17 min readView as Markdown

I went into thinking "how interesting can file system even be?" — and then I spent way more time than I planned just going deeper and deeper. Linux is weird in cool way. Almost everything is file.

Think about it

Your RAM? File

Your CPU? File

Running processes? Files

Your network routing table? Also a file

Once that clicked for me, everything started making more sense. It is not where you store your code. it's how entire operating system exposes itself to you. Here's what I actually found when I started digging.

/etc/resolv.conf : Your System's Phone Book for the Internet

When I opened this file, it had exactly one line:

nameserver 8.8.8.8

That single line is Google's DNS server address, and it is literally reason you can type google.com in your browser instead of memorizing 142.250.195.46 Without it, every program that tries to reach internet would fail at name resolution not because network is broken, but because your system would have no idea who to ask for the IP address.

What got me curious was — who actually reads this file? Is it kernel? Some background daemon? Turns out there's a library called the resolver library part of glibc that every single program on your system silently calls when it needs to resolve a hostname. When you run curl https://api.github.com, curl doesn't know how to talk to DNS itself — it calls a function in glibc, glibc reads /etc/resolv.conf, finds the nameserver address, sends a DNS query, gets back an IP, and only then does your actual request leave the machine. This chain happens for every ping, every browser request, every background system update — all depending on this one tiny file.

The more interesting part I found was about systemd-resolved. On modern Linux systems, this file is not actually a real file — it's a symlink pointing to /run/systemd/resolve/stub-resolv.conf, which is a dynamically managed file that systemd rewrites based on your network configuration. So if you're on a laptop and you connect to a new WiFi network, your DNS server changes automatically without you doing anything. But if you manually edit /etc/resolv.conf without knowing this, your changes will just disappear on the next reboot or network event, and you'll have no idea why. That gotcha has confused a lot of people.

/etc/nsswitch.conf : File Decides Where Linux Looks

Right next to resolv.conf sits this file that most people never hear about, but it controls something really fundamental — the order in which Linux looks up information. Not just DNS, but users, groups, hostnames, network protocols, everything.

When I read it, the most important line was this:

hosts: files dns

This one line is doing a lot of work. It's telling the system that whenever any program asks "what's the IP address for this hostname?", Linux should first check the local /etc/hosts file, and only if no match is found there, go out to DNS. The word files literally means /etc/hosts. The word dns means go talk to the nameserver in resolv.conf.

This is why editing /etc/hosts and adding a fake entry like 127.0.0.1 myapp.local works instantly without touching any DNS server. You're not configuring DNS — you're just cutting in front of it. The system checks /etc/hosts first, finds a match, and never even sends a DNS query. This is also how the classic /etc/hosts-based ad blocking works you map known ad domains to 0.0.0.0 and the system never contacts them.

The file also controls lookups for passwd user accounts, group groups, shadow passwords, and more all following the same pattern of "try these sources in this order." This design is what makes Linux so flexible. You can swap in LDAP, NIS, or any other directory service just by editing this one file, without rewriting any application code.

/etc/passwd : Not Actually Passwords, But Still Fascinating

The name is completely misleading now, but it made sense historically. Today this file stores basic user account information one entry per user, seven colon-separated fields per line. When I read it:

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash
claude:x:999:1001::/home/claude:/bin/bash

The fields go: username, password placeholder, UID, GID, description, home directory, shell. The x in the password field means "actual password hash is in /etc/shadow, go look there." The number after that is the UID — Linux doesn't actually care about usernames internally. When you run a process as ubuntu, the kernel is tracking UID 1000, not the string "ubuntu". The username is just a human-readable label that the system maps to a number. This is why UID 0 is special — anything running as UID 0 is root, regardless of what the username is.

Look at the nologin entries — daemon, www-data, nobody. These are service accounts that exist so services like Apache or cron can run with limited permissions without being a real user. The shell field set to /usr/sbin/nologin means even if someone somehow gets credentials to that account, they can't get a shell. The OS will just print "This account is not available" and terminate the session. That's a security boundary built right into a plain text file.

The nobody account at UID 65534 is particularly interesting — it's the most restricted user on the system, used as a fallback when a service needs to run as some user but should have as close to zero permissions as possible.

/etc/shadow : Where Real Security Lives

When I tried reading /etc/shadow without root, I got permission denied immediately. That's expected — and that's the whole reason this file exists separately from passwd.

In old Unix systems, password hashes were stored inside /etc/passwd, which was world-readable because programs needed to look up user info. Any user on the system could read it and take the hashes offline to crack them at their own pace. The solution was simple but effective — move the sensitive part to a separate file and lock it down. /etc/shadow is readable only by root.

When I read it as root, entries looked like:

root:*:20536:0:99999:7:::
daemon:*:20536:0:99999:7:::

Each field here tells a story. The * in the second field means this account has no password set and cannot be used for direct login at all — it's locked. If it had a real password hash, it would look something like \(6\)rounds=... (SHA-512 hashed). The number 20536 is the number of days since the Unix epoch (January 1, 1970) when the password was last changed. The 0 after that is the minimum number of days before the password can be changed again. The 99999 is the maximum age in days before the password must be changed 99999 days is effectively "never expires." The 7 is the warning period start warning the user 7 days before expiry.

So the entire password aging policy for every user on the system lives right here in a plain text file. No database, no GUI needed. You can read it, you can understand it, and if you have root, you can change it with a text editor.

/proc : A Fake File System That Tells You Everything Real

This one genuinely surprised me. /proc looks completely normal — it has folders, it has files, you can cat them. But none of it exists on disk. Nothing in /proc is stored on your hard drive. It's a virtual filesystem that the kernel creates fresh on every boot and keeps entirely in memory.

When I ran cat /proc/version, I got:

Linux version 4.4.0 #1 SMP Sun Jan 10 15:06:54 PST 2016

That response was generated by the kernel in real time, formatted to look like a file. The kernel saw that I opened a path under /proc, recognized the request, assembled the answer from its internal data structures, and handed it back to me as if I had just read a text file. There is no file. The kernel is just playing along.

The most mind-bending thing I found was /proc/self. It's a symlink that dynamically resolves to the current process's directory. So if your shell runs ls /proc/self/, the symlink points to your shell's own process folder under /proc. If a Python script reads /proc/self/, it points to the Python process. Every process gets its own folder under /proc named after its PID — containing files like status, cmdline, maps, environ, fd/ (file descriptors) — and /proc/self is just a shortcut to whichever one belongs to you right now. That's elegant design.

/proc/meminfo : Every Memory Tool You've Used Reads This File

MemTotal:        9437184 kB
MemFree:         9427452 kB
MemAvailable:    9427452 kB
Cached:             5608 kB
SwapTotal:             0 kB
SwapFree:              0 kB
Active(anon):       4112 kB

This is the raw memory state of the entire system, straight from the kernel. The first thing I noticed — SwapTotal: 0 kB. There's no swap space at all on this machine. Swap is what Linux uses when physical RAM is full it pushes less-used memory pages to disk so active processes can keep running. With zero swap, if this system runs out of RAM, the kernel has nowhere to offload anything. It would invoke the OOM Killer Out of Memory Killer, which scans running processes, scores them based on how much memory they're using and how "important" they seem, and then terminates the highest-scoring one to free up space. Processes can die suddenly with no warning, no error message — just gone.

The other thing worth understanding here is the difference between MemFree and MemAvailable. MemFree is technically unused RAM, but Linux intentionally keeps barely any RAM "free" — it aggressively caches disk reads into memory to speed things up. So MemFree is usually a small number and doesn't mean you're low on memory. MemAvailable is the real number — it's the kernel's estimate of how much RAM could actually be made available for a new process if needed, after reclaiming cache. When htop or free -h shows you memory usage, they're reading exactly this file and reformatting the numbers. All memory monitoring on Linux traces back to /proc/meminfo.

/proc/net/route : Your Routing Table Is a File Too

I already knew about ip route for checking routing, but I didn't realize the data comes from here. When I read /proc/net/route:

Iface        Destination  Gateway   Flags  Mask
6cda128933-v 00000000     25000415  0003   00000000
6cda128933-v 24000415     00000000  0001   7FFFFFFF

The addresses are in hexadecimal, little-endian format — meaning the bytes are reversed. The destination 00000000 with gateway 25000415 and flags 0003 is the default route "send everything here if no better match exists" rule. Flags 0003 means the route is Up and it's a Gateway. To decode 25000415 into a real IP, you reverse the byte pairs: 15 04 00 25 -> 21.4.0.37. That's your gateway IP.

The ip route command is just a wrapper that reads this file and translates the hex into human-readable form for you. The actual routing decisions the kernel makes for every single network packet going in or out of your system are driven by what's in this file. What's powerful about this is that because it's a file, you can watch it change in real time with tools like watch cat /proc/net/route. If a VPN connects, new routes appear. If your network interface goes down, routes disappear. The kernel is updating this file dynamically and you can observe it just by reading.

/proc/self/environ : Your Secrets Might Be in Here

cat /proc/self/environ | tr '\0' '\n'

Output from my session:

PIP_ROOT_USER_ACTION=ignore
NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
HOME=/root
PATH=/home/claude/.npm-global/bin:/home/claude/.local/bin:...
JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
DEBIAN_FRONTEND=noninteractive
IS_SANDBOX=yes

Every environment variable of current process, dumped out. reason tr '\0' '\n' is needed is because Linux stores environment variables as a flat blob of memory where each variable is separated by a null byte (\0) instead of a newline. null byte is invisible, so without converting it you'd see everything mashed together on one line.

Interesting thing here is how environment variables actually work. When a parent process spawns a child, the kernel copies the parent's entire environment block and gives the child its own copy. So when you open a terminal and type export API_KEY=mysecret, every command you run after that inherits that variable — because they're all child processes of your shell. It's not global or system-wide. It's just memory inheritance.

Security implication is real: if you set secrets as environment variables (like DATABASE_URL with a password in it, or an API key), those values are visible in /proc/<PID>/environ to anyone who has permission to read that process's files. On a shared server, if file permissions aren't tight, another user could read your process's environment and extract credentials. This is why production systems are increasingly using secret managers and mounted secret files instead of environment variables for sensitive values.

/dev/null, /dev/zero, /dev/random : Devices That Aren't Devices

The /dev directory is supposed to hold device files — things that represent hardware like disks, USB ports, terminals. But three entries in there represent something completely virtual, and they're some of the most useful things in the entire filesystem.

When I did ls -la /dev/, I could see them with their types:

crw-rw-rw- 1 root root  1,  3  null
crw-rw-rw- 1 root root  1,  5  zero
crw-rw-rw- 1 root root  1,  8  random
crw-rw-rw- 1 root root  1,  9  urandom

The c at the start means character device — data flows through them one character at a time, not in blocks. The numbers 1, 3 are the major and minor device numbers — 1 identifies the "memory device" driver in the kernel, and 3 means null, 5 means zero, 8 means random.

/dev/null is a black hole. Write to it — data disappears. Read from it — you get nothing. It exists because programs always need somewhere to send output, and sometimes you just want output suppressed. command > /dev/null 2>&1 is the standard way to run something completely silently.

/dev/zero streams infinite zero bytes. It's used to create empty files of exact sizes (dd if=/dev/zero of=file bs=1M count=100 creates a 100MB file of zeros), wipe disk partitions, or pre-allocate space. The kernel generates the zeros on demand — there's no 100MB of zeros sitting in RAM.

/dev/random and /dev/urandom both produce random bytes, but they work differently. /dev/random collects entropy from unpredictable physical events — tiny timing variations between hardware interrupts, keyboard press timings, network packet arrival intervals. It's genuinely hard to predict because it's based on physical world chaos. But it blocks if the entropy pool runs low, meaning reads will pause until more entropy is collected. /dev/urandom uses a cryptographically secure algorithm seeded by that entropy and never blocks — it keeps generating output even if the entropy pool is technically "empty." Every SSH key, TLS certificate, and encryption operation on your system is ultimately seeded from one of these files.

/proc/mounts : Linux Doesn't Care What a Filesystem Is

cat /proc/mounts

I expected to see maybe three or four entries — the root filesystem, /home, /boot. Instead I saw over twenty mounts, and many of them weren't "real" filesystems at all:

none /proc          proc    rw
none /dev/shm       tmpfs   rw,noexec,nosuid
none /sys/fs/cgroup tmpfs   rw,noexec,nosuid
none /sys/fs/cgroup/memory  cgroup  rw,memory

tmpfs is a RAM-based filesystem. /dev/shm is shared memory — programs that need to pass data between each other quickly can write to files here, and everything lives in RAM with no disk involved. When you reboot, it's gone.

cgroup mounts are how Linux enforces resource limits. There are separate cgroup mounts for CPU, memory, PIDs, and devices. When Docker tells a container "you get 512MB of RAM max," it's writing limits into the memory cgroup. The kernel enforces it from there. This isn't a Docker-specific feature — it's a core kernel mechanism exposed through the filesystem.

Then there were 9p mounts — that's the Plan 9 filesystem protocol, used here to mount remote directories over a file descriptor. The ro (read-only) and rw (read-write) flags on each mount define exactly what you can do.

The deeper insight is that Linux treats everything as a mountable filesystem. It doesn't care if the data comes from a spinning disk, solid state memory, RAM, a remote server over a network, or a kernel driver pretending to be a filesystem. As long as something implements the filesystem interface, Linux will mount it, integrate it into the directory tree at whatever path you choose, and let you interact with it using the exact same tools — ls, cat, cp, grep. Docker volumes, NFS shares, /proc, /sys, tmpfs — all fundamentally the same from the OS's perspective.

/proc/sys : You Can Change Kernel Behavior by Writing to a File

I saved this one for last because it genuinely shocked me. /proc/sys isn't just readable — parts of it are writable. You can change how the kernel behaves by just writing a value to a file.

When I checked:

cat /proc/sys/net/ipv4/ip_forward

Output: 0

That 0 means this machine won't forward network packets between interfaces — it's not acting as a router. If you write 1 to that file, the kernel immediately starts routing packets. No restart, no config file reload. Just:

echo 1 > /proc/sys/net/ipv4/ip_forward

And your machine becomes a router. This is how tools like iptables, VPN software, and Docker's networking all enable packet forwarding when they set up.

I also found /proc/sys/net/ipv4/tcp_keepalive_time which had the value 7200 — meaning idle TCP connections send a keepalive probe after 7200 seconds (2 hours) of silence. This is relevant for backend developers — if you've ever had a long-lived database connection randomly die, it could be because the connection sat idle long enough that some router in the middle closed it, while your app thought it was still open. Tuning this value can fix those mysterious disconnects.

And /proc/sys/net/ipv4/ip_local_port_range showed 16000 65535 — that's the range of ephemeral ports Linux will use when your program opens an outbound connection. Every curl request, every database call you make uses a temporary port from this range. If you're running a very high-throughput service making tons of outbound connections, this range can get exhausted and connections will start failing. Knowing where this setting lives — and being able to change it by writing a number to a file — is genuinely useful.

What All of This Actually Means

The pattern I kept seeing throughout all of this is that Linux exposes almost everything through files. Not because it's lazy design — because it's intentionally consistent. If everything is a file, you only need to know how to read and write files to inspect or control almost any part of the operating system. You don't need a separate API for memory stats, a different tool for routing tables, a GUI for kernel parameters. You just use the same read/write primitives you already know, pointed at the right path.

That's also why shell scripting is so powerful on Linux — you're not running special system commands, you're mostly just reading and writing files that happen to represent the kernel's state.

Once you start seeing it this way, error messages make more sense, debugging gets easier, and the OS stops feeling like a black box. It's just files. All the way down.