OS Security & Hardening — Least Privilege in Practice
What you'll learn
- How the kernel decides what a process may do: kernel mode and user mode, real and effective UIDs, and the principle of least privilege
- How Linux capabilities split root's power into about forty pieces, and how to read a process's capability sets
- How to sandbox a process with seccomp, no_new_privs, namespaces and systemd's sandboxing options
- What NX, ASLR, stack canaries and RELRO protect against, and what they do not
- A practical hardening routine: patching, minimal services, SSH, firewalls, file permissions and auditing
This lesson is about defending systems you are responsible for. You will write a capability decoder in C, a seccomp policy evaluator in Python and a permission audit in Bash, all working on made-up data.
Rules of the road ⚖️
Security knowledge cuts both ways, so the ground rules come first:
- Practice on machines you own or have written permission to test: your laptop, a VM, a lab container. Probing or "testing" anyone else's system is a crime in most countries, even if you only look around
- Hardening a production system at work is a change like any other: get approval, test it and have a way back. A well-meant firewall rule can take a service down
- Everything in this lesson is defensive and uses synthetic data; no exercise touches a real system or network
- This is general education, not legal advice. If you are unsure, ask the system's owner first
- If you find a vulnerability, report it privately to the vendor or owner and give them time to fix it (coordinated disclosure)
Users, privileges and least privilege 👤
The CPU runs the kernel in a privileged kernel mode and your programs in user mode, where instructions that touch hardware or page tables are forbidden. The only door between the two is the system call: every open, kill or mount asks the kernel, and the kernel checks the caller's credentials first.
Each process carries a real UID (who started it), an effective UID (whose rights it uses now) and a saved UID, and the same three for groups. They differ in setuid programs: /usr/bin/passwd is owned by root and has the setuid bit, so while you run it its effective UID is 0 and it can update /etc/shadow. That power is exactly why every setuid program is a target, and why distributions keep the list short.
The defence is the principle of least privilege: every program and every person gets the minimum rights needed, for the minimum time.
- Services run as dedicated system accounts with a
nologinshell, never as root or as you - Privilege separation: OpenSSH's sshd keeps a small privileged monitor and does the risky network parsing in an unprivileged child process
sudorules name specific commands for specific people, notALL- A program that starts as root to grab something (a low port, a log file) drops privileges right after, in the right order: supplementary groups, then the GID, then the UID, and then it checks that it cannot get root back. In the wrong order it keeps root's group:
Once root calls setuid(1000), the process has no privileges left, so it may no longer change its group: the later setgid fails and the process quietly stays in group root. Always drop groups and GID first, then the UID, and check the return values.
# A tiny model of the kernel's rules for setuid() and setgid() (see setuid(2), setgid(2)).
class Process:
def __init__(self):
self.uid = self.euid = self.suid = 0 # real, effective and saved user IDs: root
self.gid = self.egid = self.sgid = 0
self.groups = [0]
def privileged(self):
return self.euid == 0 # simplification: euid 0 = every capability
def setuid(self, uid):
if self.privileged():
self.uid = self.euid = self.suid = uid # root changes all three: no way back
elif uid in (self.uid, self.suid):
self.euid = uid
else:
return "EPERM"
return "ok"
def setgid(self, gid):
if self.privileged():
self.gid = self.egid = self.sgid = gid
elif gid in (self.gid, self.sgid):
self.egid = gid
else:
return "EPERM"
return "ok"
def setgroups(self, groups):
if not self.privileged():
return "EPERM"
self.groups = groups
return "ok"
def __str__(self):
return f"uid={self.euid} gid={self.egid} groups={self.groups}"
wrong = Process()
print("wrong order: setuid(1000) ->", wrong.setuid(1000), "| setgid(1000) ->", wrong.setgid(1000))
print(" result:", wrong, " <- still in group 0 (root)!")
right = Process()
print("right order: setgroups([]) ->", right.setgroups([]), "| setgid(1000) ->", right.setgid(1000),
"| setuid(1000) ->", right.setuid(1000))
print(" result:", right)
print(" try to get root back: setuid(0) ->", right.setuid(0))
Capabilities: root in pieces 🧩
Since Linux 2.2 the kernel splits root's power into capabilities (capabilities(7)); recent kernels define 41 of them, numbered 0 to 40. A few examples:
CAP_NET_BIND_SERVICE(10): bind to ports below 1024CAP_CHOWN(0): change any file's ownerCAP_DAC_OVERRIDE(1): bypass file read, write and execute permission checksCAP_SYS_PTRACE(19): trace other processes and read their memoryCAP_SYS_ADMIN(21): a huge grab bag (mounting, many device controls and more); the manual page itself calls it "the new root"
Each process has several capability sets: permitted (what it may use), effective (what is switched on right now), inheritable, bounding (an upper limit for it and its children) and ambient (kept across execve of unprivileged programs). The kernel shows them as 64-bit hex masks in /proc/<pid>/status, where bit n set means capability n:
$ grep Cap /proc/self/status # as root inside a container with Docker's defaults
CapInh: 0000000000000000
CapPrm: 00000000a80425fb
CapEff: 00000000a80425fb
CapBnd: 00000000a80425fb
CapAmb: 0000000000000000
capsh --decode=00000000a80425fb turns a mask into names. Least privilege with capabilities means giving a program only the one it needs: setcap cap_net_bind_service=+ep ./server lets a web server bind port 443 without running as root, and docker run --cap-drop ALL --cap-add NET_BIND_SERVICE does the same for a container. The Code Lab runner that executes your lab code drops all capabilities and sets no-new-privileges.
Some capabilities are, in practice, as good as root, because each one opens a path to full control of the machine: CAP_SYS_ADMIN, CAP_SYS_MODULE (load code into the kernel), CAP_SYS_RAWIO (raw access to devices and I/O ports), CAP_SYS_PTRACE and CAP_DAC_READ_SEARCH (read any file). Seeing one of them on a service is worth a question.
Lab exercise 1
CGraded in Code LabDecode capability masks the way capsh --decode does, and flag the dangerous capabilities.
Input: zero or more lines <label> <hex mask>, like the Cap* lines of /proc/<pid>/status (for example CapEff:, a tab and 16 hex digits). Only bits 0 to 40 can be set; hex digits may be lower or upper case.
Output: two lines per input line, with a trailing : removed from the label:
<label> (<count>): <names>: the names of the set bits in bit order, separated by commas, or<label> (0): none<label> dangerous: <names>: the set bits that are among the dangerous five, in bit order, separated by commas, ornone
The starter contains the 41 names in bit order (NAMES[n] is capability n) and the dangerous five: cap_dac_read_search (2), cap_sys_module (16), cap_sys_rawio (17), cap_sys_ptrace (19) and cap_sys_admin (21).
Example: the line CapEff: 00000000a80425fb prints
CapEff (14): cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap
CapEff dangerous: none
- Example: a container with Docker's default set
- Full root on a recent kernel
- A container given ptrace and admin rights
- An empty set and an ambient capability
- Upper-case hex and the highest bit
Example run
- Input
CapEff: 00000000a80425fb
- Expected output
CapEff (14): cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap CapEff dangerous: none
#include <stdio.h>
#include <string.h>
/* Bit numbers from <linux/capability.h>: NAMES[n] is capability number n. */
const char *const NAMES[] = {
"cap_chown", "cap_dac_override", "cap_dac_read_search", "cap_fowner",
"cap_fsetid", "cap_kill", "cap_setgid", "cap_setuid",
"cap_setpcap", "cap_linux_immutable", "cap_net_bind_service", "cap_net_broadcast",
"cap_net_admin", "cap_net_raw", "cap_ipc_lock", "cap_ipc_owner",
"cap_sys_module", "cap_sys_rawio", "cap_sys_chroot", "cap_sys_ptrace",
"cap_sys_pacct", "cap_sys_admin", "cap_sys_boot", "cap_sys_nice",
"cap_sys_resource", "cap_sys_time", "cap_sys_tty_config", "cap_mknod",More lines in Code Lab: 26
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Sandboxing: seccomp, no_new_privs and friends 🏖️
Even an unprivileged process can call hundreds of different system calls, and each one is kernel code that a compromised program can poke at. seccomp shrinks that attack surface:
- Strict mode (the original, 2005): after entering it a process may only call
read,write,_exitandsigreturn - Filter mode (seccomp-bpf, Linux 3.5): the process installs a small BPF program that looks at each system call's number and arguments and returns an action:
SECCOMP_RET_ALLOW,SECCOMP_RET_LOG(allow, but log it),SECCOMP_RET_ERRNO(fail with an error such asEPERM),SECCOMP_RET_KILL_PROCESSand a few more - Filters stack: every installed filter runs, and the action with the highest precedence wins (KILL beats ERRNO, which beats LOG, which beats ALLOW). A filter can never be removed, and it is inherited across
forkand kept acrossexecve - To install a filter without
CAP_SYS_ADMIN, a process must first set no_new_privs (prctl(PR_SET_NO_NEW_PRIVS)). It guarantees thatexecvecan never grant more privileges: setuid bits and file capabilities are ignored from then on
Docker, Podman, Chrome, Firefox, OpenSSH and systemd all use seccomp filters. systemd turns the whole toolbox into a few lines of a unit file (systemd.exec(5)), and systemd-analyze security <unit> rates how exposed a service is:
[Service]
User=web
NoNewPrivileges=yes
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
SystemCallFilter=@system-service
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
The namespaces from the previous lesson are sandboxing tools too (a private mount table, no network), and mandatory access control systems (SELinux, AppArmor) add rules that even root processes must obey.
Lab exercise 2
PythonGraded in Code LabEvaluate stacked seccomp filters for a trace of system calls.
Input:
- Zero or more filters. Each one starts with a line
filter, followed bydefault <ACTION>and any number of rules<syscall> <ACTION> - Then a line
tracefollowed by the system calls the process makes, separated by spaces or newlines
ACTION is one of ALLOW, LOG, ERRNO and KILL (short for SECCOMP_RET_ALLOW, _LOG, _ERRNO and _KILL_PROCESS). Blank lines are ignored.
Rules:
- Each filter answers with the action of its rule for that system call, or with its default
- Like the kernel, use the answer with the highest precedence:
KILL>ERRNO>LOG>ALLOW. With no filters at all everything is allowed - After a
KILLthe process is dead: printprocess killedand ignore the rest of the trace
Output: <syscall> -> <ACTION> for every system call that runs, then summary: ALLOW=<n> LOG=<n> ERRNO=<n> KILL=<n>.
Example:
filter
default ALLOW
ptrace ERRNO
reboot KILL
filter
default ERRNO
read ALLOW
write ALLOW
ptrace ALLOW
trace
read write openat
ptrace
reboot
write
prints
read -> ALLOW
write -> ALLOW
openat -> ERRNO
ptrace -> ERRNO
reboot -> KILL
process killed
summary: ALLOW=2 LOG=0 ERRNO=2 KILL=1
- Example from the prompt
- No filter installed
- LOG beats ALLOW, ERRNO beats LOG
- An allow-list filter kills on the first forbidden call
- A filter but an empty trace
Example run
- Input
filter default ALLOW ptrace ERRNO reboot KILL filter default ERRNO read ALLOW write ALLOW ptrace ALLOW trace read write openat ptrace reboot write
- Expected output
read -> ALLOW write -> ALLOW openat -> ERRNO ptrace -> ERRNO reboot -> KILL process killed summary: ALLOW=2 LOG=0 ERRNO=2 KILL=1
import sys
# Highest precedence first (the order seccomp(2) uses for these actions).
PRECEDENCE = ["KILL", "ERRNO", "LOG", "ALLOW"]
filters = [] # each filter: {"default": action, "rules": {syscall: action}}
trace = []
section = None
for line in sys.stdin.read().splitlines():
words = line.split()
if not words:
continueMore lines in Code Lab: 24
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Memory protections 🧱
C and C++ code can contain memory-safety bugs: a buffer overflow writes past the end of an array, a use-after-free reuses memory that was already released. If input controls what gets overwritten, such a bug can become code execution. The OS and the toolchain add layers that make that much harder:
- NX / W^X: page-table entries have a no-execute bit (the PTE flags from the memory lesson), so the stack and the heap can hold data the CPU refuses to run; the linker marks a non-executable stack with the
PT_GNU_STACKheader. W^X is the policy NX makes possible: a page is writable or executable, never both. It is a policy, not a law: Linux still lets a process map memory both writable and executable withmmapormprotectunless something forbids it, such as SELinux'sexecmempermission or systemd'sMemoryDenyWriteExecute= - ASLR (address space layout randomization): the stack, the heap, shared libraries,
mmapregions and, for PIE (position-independent executables), the program itself start at random addresses on every run, so an attacker cannot rely on fixed addresses./proc/sys/kernel/randomize_va_spaceis 2 (full randomization) on mainstream distributions - Stack canaries (
-fstack-protector-strong): the compiler puts a random value next to the return address and checks it before the function returns; glibc aborts with*** stack smashing detected ***if it changed - RELRO (
-Wl,-z,relro,-z,now): the dynamic linker's tables (the GOT) become read-only after start-up _FORTIFY_SOURCE: adds size checks to calls such asmemcpyandstrcpywhen the compiler knows the buffer size- Newer CPUs add shadow stacks and indirect-branch tracking (Intel CET) or pointer authentication (ARM PAC)
None of these fixes the bug; they raise the cost of exploiting it, and attackers chain bugs together (an information leak can defeat ASLR, for example). The real fix is still correct code: bounds checks, fuzzing, sanitizers and memory-safe languages. See what ASLR changes from run to run:
Every run moves the whole program by a random number of pages, so main() lands somewhere new, while its offset inside the page (149) stays put. That is why one leaked address is enough to undo ASLR for the whole binary. Run it again, or lower RANDOM_BITS to 8.
// A simplified model of ASLR for a position-independent executable (PIE) on x86-64 Linux:
// base = fixed start + a random number of pages, with RANDOM_BITS bits of randomness.
const PAGE = 4096n;
const START = 0x555555554000n; // where a PIE is loaded when ASLR is turned off
const RANDOM_BITS = 28; // check /proc/sys/vm/mmap_rnd_bits on your own machine
function randomBase() {
const pages = BigInt(Math.floor(Math.random() * 2 ** RANDOM_BITS));
return START + pages * PAGE;
}
const mainOffset = 0x1149n; // where main() sits inside the binary (fixed at link time)
for (let run = 1; run <= 4; run++) {
const base = randomBase();
console.log("run " + run + ": base 0x" + base.toString(16) + " main() at 0x" + (base + mainOffset).toString(16));
}
console.log("chance of guessing the base in one try: 1 in " + (2 ** RANDOM_BITS).toLocaleString("en-US"));
console.log("the low 12 bits (the offset inside a page) never change");
A practical hardening routine 🧰
Most break-ins use known holes and sloppy configuration, not movie-style genius. A routine that covers the basics:
- Patch: install security updates automatically (
unattended-upgrades,dnf-automatic), and remember that a kernel update needs a reboot or live patching to take effect - Minimal services: every listening program is attack surface. List them with
ss -tulpnandsystemctl list-units --type=service --state=running, then disable or uninstall what you do not need - Accounts and SSH: key-based login,
PasswordAuthentication noandPermitRootLogin noinsshd_config, named admins who use sudo, unused accounts locked - Firewall: deny inbound traffic by default and open only what you serve (nftables, ufw or firewalld)
- File permissions: hunt for unexpected setuid programs (
find / -xdev -perm -4000 -type f) and world-writable directories without the sticky bit (find / -xdev -type d -perm -0002 ! -perm -1000) - Logging and auditing: read
journalctland the authentication log, watch sensitive files with auditd (-w /etc/passwd -p wa -k identity), and send logs to another machine so an intruder cannot erase them - Measure: compare against a baseline such as the CIS Benchmarks, run
lynis audit system, and keep backups you have actually restored once
Automate step 5 on the kind of listing find and stat produce:
Lab exercise 3
BashGraded in Code LabAudit a listing of file modes for two classic mistakes: unexpected setuid/setgid programs and world-writable files and directories.
Input (read from stdin):
- Line 1:
allow:followed by the setuid/setgid programs you expect (possibly none) - Then one entry per line:
<mode> <owner> <path>, where the mode is four octal digits (special bits, owner, group, other), and a path that ends in/is a directory. Paths contain no spaces
Rules, checked in this order for every entry:
- A file with the setuid bit (4000) that is not in the allow list:
REVIEW setuid <path> (owner <owner>) - A file with the setgid bit (2000) that is not in the allow list:
REVIEW setgid <path> (owner <owner>)(setgid on a directory is normal: new files inherit its group) - A file writable by others (0002):
FIX world-writable file <path> - A directory writable by others without the sticky bit (1000):
FIX world-writable directory without sticky bit <path>
Output: every finding in input order, then findings: <count>.
Example:
allow: /usr/bin/passwd /usr/bin/sudo
4755 root /usr/bin/passwd
4755 root /opt/backup/run
0666 www /var/www/config.php
1777 root /tmp/
0777 root /srv/upload/
prints
REVIEW setuid /opt/backup/run (owner root)
FIX world-writable file /var/www/config.php
FIX world-writable directory without sticky bit /srv/upload/
findings: 3
- Example from the prompt
- setgid files versus setgid directories
- Several findings on one line; allowed is not the same as safe
- An empty allow list
- Allowed paths match exactly: su is not sudo
- A clean system
Example run
- Input
allow: /usr/bin/passwd /usr/bin/sudo 4755 root /usr/bin/passwd 4755 root /opt/backup/run 0666 www /var/www/config.php 1777 root /tmp/ 0777 root /srv/upload/
- Expected output
REVIEW setuid /opt/backup/run (owner root) FIX world-writable file /var/www/config.php FIX world-writable directory without sticky bit /srv/upload/ findings: 3
#!/usr/bin/env bash # Line 1: "allow: <path> <path> ..." lists the setuid/setgid programs we expect. read -r _ allowed findings=0 while read -r mode owner path; do [ -z "$mode" ] && continue m=$((8#$mode)) # the mode is octal: 8# makes bash read it that way # TODO 1: expected=yes if " $allowed " contains " $path ", otherwise no (a case statement works) # TODO 2: isdir=yes if the path ends with / # TODO 3: the four checks, in order, with (( m & 04000 )), (( m & 02000 )), (( m & 0002 ))
More lines in Code Lab: 4
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Question 1 of 5
A web server must listen on port 443 but should not run as root. What is the least-privilege fix?
Question 2 of 5
Why must a program running as root call setgid() before setuid() when it drops privileges?
Question 3 of 5
Which statement about seccomp filters is true?
Question 4 of 5
What does ASLR do?
Question 5 of 5
A shared directory has mode 0777 (drwxrwxrwx) and no sticky bit. What can any local user do there?
Summary
- Practice only on systems you own or are authorized to test, and treat production hardening as a reviewed change
- The kernel checks credentials at every system call. setuid programs run with their owner's effective UID; least privilege means dedicated service accounts, privilege separation, narrow sudo rules and dropping privileges in the right order (groups, GID, UID)
- Capabilities split root into 41 pieces shown as hex masks in
/proc/<pid>/status; grant only what is needed (setcap,--cap-drop ALL) and questionCAP_SYS_ADMIN,CAP_SYS_MODULE,CAP_SYS_RAWIO,CAP_SYS_PTRACEandCAP_DAC_READ_SEARCH - seccomp filters stack, the highest-precedence action wins, they can never be removed, and no_new_privs makes them safe for unprivileged processes; systemd, namespaces and SELinux/AppArmor complete the sandbox
- NX, ASLR, canaries, RELRO and FORTIFY make memory bugs harder to exploit, but only correct code removes them
- Hardening is a routine: patch, minimise services, lock down SSH, firewall by default, audit permissions, log off-host and measure against a baseline
- You wrote a capability decoder, a seccomp evaluator and a permission audit
Practice: in a VM of your own, run grep Cap /proc/self/status as a user and as root, try systemd-analyze security on a few services, and run the two find commands from the routine. Next: I/O and device drivers, with the resources listed for that topic on the course map.
Practice
Reading is not learning until you run something. Pick one.
Graded lab exercises
- Lab exercise 1CNot passed yet Try in Code Lab
- Lab exercise 2PythonNot passed yet Try in Code Lab
- Lab exercise 3BashNot passed yet Try in Code Lab