I’ll use a web application that accepts profile photos as an example. When a user uploads an image, the application saves the original and starts a separate process to generate a thumbnail. The application can read and write every file in the photo directory, but the thumbnail process only needs to read that original and write its thumbnail.
Suppose someone uploads an image that exploits a bug in the decoder and gives them control of the process. If the process runs under the application’s account, they can read or overwrite other users’ photos. Running it under a separate account helps, but the process can still use any open files the application passes to it.
In Hardening a Binary, I covered building the main program as a position-independent executable (PIE). That lets Linux vary the executable’s base address when address space layout randomization (ASLR) is enabled. Write xor execute (W^X) keeps memory from being both writable and executable. On Linux, a process can opt into this restriction for new mappings with PR_SET_MDWE. These protections make certain bugs harder to exploit. Here I’ll assume the attacker can already run code inside the process and see what that code can still do.
I’m using a Debian 13 (Trixie) VM for these examples. Run the commands as root in one Bash session, keeping $work and the helper functions between steps. Some probes deliberately fail, so leave set -e off.
The account, capability, descriptor, and ulimit examples in sections 1–4 also work in a slim Debian container. Sections 5–8 need the VM with systemd running; the AppArmor example also needs AppArmor enabled and its tools installed.
$ docker run --rm -it debian:trixie-slim bash
1. Give the process its own account
I’ll start by giving the thumbnail process a different account from the application. When a process opens a file by path, Linux checks its user and group IDs against the file’s permissions and those of the directories along the way. That’s discretionary access control (DAC). If the application runs as webapp, chown can make it the owner of /srv/photos, and chmod can set the directory’s mode to 0700. A process under a different, unprivileged account can’t traverse it. Check any ACLs as well when reviewing access to the files.
For this example, I’ll create a private file as root and set its mode to 0600. The read as root succeeds; the read as UID and GID 65534 (nobody and nogroup on Debian) fails. setpriv switches the account for that second read. I’d give the process a dedicated account in a real service.
$ work=$(mktemp -d /var/tmp/process-hardening.XXXXXX)
$ chmod 0755 "$work"
$ mkdir -m 0755 "$work/private"
$ printf 'one original\n' > "$work/private/photo.txt"
$ chown root:root "$work/private/photo.txt"
$ chmod 0600 "$work/private/photo.txt"
$ cat "$work/private/photo.txt"
one original
$ setpriv --reuid=65534 --regid=65534 --clear-groups -- cat "$work/private/photo.txt"
File permissions enforce that boundary as long as the process lacks privileges that bypass it. If the process owns a file, it can normally change its mode.
2. Drop capabilities the process doesn’t need
Linux capabilities split privileges traditionally held by root into separate permissions. For example, CAP_DAC_OVERRIDE can let a process bypass ordinary file permission checks even when it isn’t running as root. CAP_SETUID lets the launcher change user IDs, and CAP_SETGID lets it change group IDs and clear supplementary groups. The thumbnail process needs none of these permissions, so I’ll drop its capabilities before it handles the image.
I’ll also set no_new_privs so that later calls to execve() can’t add privileges through setuid executables or file capabilities. It doesn’t take away privileges the process already has, and the launcher can still use its existing capabilities to switch accounts.
The run_unprivileged function applies those restrictions. To check them, I’ll read /proc/self/status from inside the process. CapEff is what the process can use now; CapBnd limits what it can acquire through file capabilities on execve(). Both should be zero, and NoNewPrivs should be 1.
$ run_unprivileged() {
setpriv --reuid=65534 --regid=65534 --clear-groups \
--bounding-set=-all --inh-caps=-all --ambient-caps=-all \
--no-new-privs -- "$@"
}
$ run_unprivileged grep -E '^(CapEff|CapBnd|NoNewPrivs):' /proc/self/status
CapEff: 0000000000000000
CapBnd: 0000000000000000
NoNewPrivs: 1
3. Pass only the descriptors the process needs
The application can open the original for reading and create the thumbnail file for writing, then hand the process those two file descriptors. If it accidentally hands over a descriptor for someone else’s photo, the process can read that one too. A read through an already open descriptor doesn’t repeat the DAC check for opening the file by path, so changing the file’s mode or switching accounts doesn’t revoke that access.
In the launcher I’d open descriptors with close-on-exec, pass only the ones the process needs, and check what it gets in its environment and standard streams. Here’s the difference between asking the process to open the file and giving it the already-open file on standard input:
$ run_unprivileged cat "$work/private/photo.txt" # Permission denied.
$ run_unprivileged cat < "$work/private/photo.txt"
one original
In the last command, the root shell opens the file for input redirection before setpriv changes identity. The process reads from standard input without opening the path. This is the same handoff the application can use for the original photo.
4. Limit resource use with ulimit
Bash’s ulimit sets resource limits the shell passes to programs it starts. I’ll start a fresh shell for each example so the limits don’t carry over. With neither -S nor -H, Bash sets both the soft and hard limits, so an unprivileged process can’t raise the soft limit above the cap.
Open descriptors: -n
-n limits how many descriptors a process can open. With a limit of 16, the loop fails with Too many open files before it finishes because some descriptors are already in use.
$ run_unprivileged bash -c 'ulimit -n 16 || exit
for ((i=0; i<16; i++)); do exec {fd}</dev/null || exit; done'
Output file size: -f
-f caps how large a process can make an individual file, in KiB in normal Bash mode. Here the process tries to write 128 KiB but hits the limit at 64 KiB. It could still fill the disk by creating many files.
$ run_unprivileged bash -c 'ulimit -c 0 && ulimit -f 64 &&
exec head -c 131072 /dev/zero' > "$work/output.bin"
$ wc -c < "$work/output.bin"
65536
CPU time: -t
-t counts CPU seconds. This loop exhausts its allowance and gets killed. A process waiting on I/O can sit there indefinitely without hitting the CPU limit.
$ run_unprivileged bash -c 'ulimit -c 0 && ulimit -t 1 || exit
while :; do :; done'
Each process has its own CPU-time allowance, counting CPU use across its threads. A child inherits the limit but starts its own CPU-time counter. A service cgroup can enforce a shared CPU quota, as I’ll show in section 7; neither limit replaces a deadline for elapsed time.
Core dump size: -c
The two commands above use -c 0 to suppress ordinary core dumps, which can contain sensitive process memory. A piped core dump handler can ignore this limit. Check how the host handles dumps before relying on it.
$ run_unprivileged bash -c 'ulimit -c 0 && ulimit -c'
0
Tasks per user: -u
On Linux, -u compares the limit against the total number of threads under the real UID. A process using a shared account may hit the limit because another service uses that same account. Use a cgroup to cap one service separately.
$ run_unprivileged bash -c 'ulimit -u 64 && ulimit -u'
64
5. Change what the process can see
For sections 5–8 I’ll start temporary services with systemd-run. PrivateNetwork=yes gives the process a network namespace with only loopback. TemporaryFileSystem= uses a mount namespace to cover the private directory with an empty filesystem.
$ run_service() {
systemd-run --quiet --collect --wait --pipe \
-p User=nobody -p Group=nogroup \
-p NoNewPrivileges=yes -p CapabilityBoundingSet= "$@"
}
$ isolated() {
run_service -p PrivateNetwork=yes \
-p "TemporaryFileSystem=$work/private:ro" "$@"
}
$ run_service -- /usr/bin/cat /proc/net/dev
$ isolated -- /usr/bin/cat /proc/net/dev
$ isolated -- /usr/bin/cat "$work/private/photo.txt"
The second run shows only lo. The last fails with No such file or directory: the path is absent in that view. Compare that with the permission error in step 1. chroot() also changes path lookup, but it leaves the process’s network and open descriptors in place.
6. Block a system call with seccomp
The process can still create a socket in its private network namespace. I’ll use a small Go program to call socket() before and after applying a seccomp filter. If Go isn’t installed, run:
$ apt-get update
$ apt-get install --no-install-recommends golang-go
Find the temporary directory, then save the following as socket.go inside it:
$ echo "$work"
package main
import (
"fmt"
"syscall"
)
func main() {
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0)
fmt.Println("socket:", err)
if err == nil {
syscall.Close(fd)
}
}
$ CGO_ENABLED=0 go build -o "$work/socket-probe" "$work/socket.go"
$ filtered() {
isolated -p SystemCallArchitectures=native \
-p 'SystemCallFilter=~socket' -p SystemCallErrorNumber=EPERM "$@"
}
$ isolated -- "$work/socket-probe"
socket: <nil>
$ filtered -- "$work/socket-probe"
socket: operation not permitted
This example assumes amd64 or arm64. Systemd applies SystemCallFilter= with seccomp. The filter can deny the socket call, but it can’t read a pathname passed by pointer to openat(). That needs a filesystem policy.
7. Cap the service with a cgroup
So far I’ve limited what the process can see and do. Now I’ll limit what the whole service can consume. CPUQuota=, MemoryMax=, and TasksMax= apply to all processes in the unit, including any children the process starts.
$ limited() {
filtered -p CPUQuota=50% -p MemoryMax=128M -p TasksMax=64 "$@"
}
$ limited -- /bin/bash -c '
group=$(cut -d: -f3 /proc/self/cgroup)
for key in cpu.max memory.max pids.max; do
printf "%s: " "$key"
cat "/sys/fs/cgroup$group/$key"
done'
Expect 50000 100000, 134217728, and 64 respectively with the default CPU period. That’s half of one CPU, 128 MiB of memory, and 64 tasks including threads. The quota throttles CPU use across the group. A wall-clock deadline is still useful for a thumbnail that never finishes.
8. Apply filesystem policy with AppArmor
AppArmor can deny a read that Unix permissions allow. I’m using it here because Debian enables AppArmor by default. With AppArmor enabled and its tools installed in the VM, I’ll load a profile with apparmor_parser and read the same file twice: once without the profile and once with it. The profile is deliberately broad apart from that one denial.
$ printf 'readable by mode bits\n' > "$work/policy.txt"
$ chmod 0644 "$work/policy.txt"
$ profile=${work##*/}
$ cat > "$work/profile" <<EOF_PROFILE
profile $profile {
file,
audit deny "$work/policy.txt" r,
}
EOF_PROFILE
$ apparmor_parser -a "$work/profile"
$ limited -- /usr/bin/cat "$work/policy.txt" # Succeeds.
$ limited -p "AppArmorProfile=$profile" -- /usr/bin/cat "$work/policy.txt"
$ apparmor_parser -R "$work/profile"
This is mandatory access control (MAC): the file’s mode permits the read, but the policy denies it. SELinux can enforce the same kind of restriction using labels on processes and files. Landlock lets a process restrict its own future file opens before decoding. Its file-open rules don’t revoke access through descriptors the process already has.
9. Run the process in a container
If I deploy the thumbnail process in a container, the same Linux controls still apply. Docker creates namespaces and cgroups around the process. In Kubernetes, the kubelet asks a container runtime to start it with the requested restrictions. Ordinary Linux containers share the node’s kernel, which enforces those restrictions.
Docker’s launch options and Kubernetes security contexts expose several of the settings used above:
| Control | Docker | Kubernetes |
|---|---|---|
| User and group IDs | --user=10001:10001 | runAsUser, runAsGroup, runAsNonRoot |
| Capabilities | --cap-drop=ALL | capabilities.drop: ["ALL"] |
no_new_privs | --security-opt=no-new-privileges | allowPrivilegeEscalation: false |
| Inherited descriptors | The launcher must pass only what its children need | The same applies to a launcher inside the container |
| Process resource limits | --ulimit | Set through the launcher or runtime configuration |
| Namespaces and mounts | Separate namespaces; choose mounts and network mode | Pod isolation and selected volume mounts; avoid host namespace sharing |
| Seccomp | Default profile or --security-opt=seccomp=... | seccompProfile |
| Cgroup resource limits | --cpus, --memory, --pids-limit | CPU and memory resources.limits; Pod PID limits configured on the node |
| AppArmor or SELinux | --security-opt=apparmor=... or --security-opt=label=... | appArmorProfile or seLinuxOptions |
Docker normally applies a default seccomp profile, but CPU and memory limits need to be configured. In Kubernetes, I’ll request the runtime’s default seccomp profile explicitly. Reproducing the socket denial from section 6 would need a custom profile tested with the thumbnail program.
For filesystem policy, an AppArmor profile must be available on the nodes that can run the Pod; SELinux also depends on node support. A profile written for the VM needs to account for the paths the process sees inside its container.
Launch it with Docker
For this example, I’ll assume an image named thumbnailer:example whose entrypoint reads /input/original.jpg and writes /output/thumbnail.jpg. The application stages one job under /srv/thumbnail-jobs/example: an original.jpg readable by UID 10001 and an output directory writable by that UID. These paths and the image are prerequisites for the command below.
job_dir=/srv/thumbnail-jobs/example
docker run --rm \
--user=10001:10001 \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--read-only --network=none \
--cpus=0.5 --memory=128m --pids-limit=64 \
--ulimit nofile=64:64 --ulimit core=0:0 \
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777 \
--mount "type=bind,src=$job_dir/original.jpg,dst=/input/original.jpg,readonly" \
--mount "type=bind,src=$job_dir/output,dst=/output" \
thumbnailer:example
The process gets the original through a read-only mount, its own output directory, and a small temporary filesystem. --read-only makes the container’s root filesystem read-only; writable mounts remain writable. Mounting the whole photo directory would give the process access to more data than this job needs. I’d also keep the Docker socket and unrelated host directories out of the container.
The CPU quota limits the container to half of one CPU, while --pids-limit caps its tasks. --ulimit nofile=64:64 sets a process limit on descriptors. These are example budgets; I’d measure the decoder’s needs before choosing production values. The command still needs a supervisor to stop a job that exceeds its elapsed-time allowance.
Launch it as a Kubernetes Job
A Job fits a program that converts one image and exits. I’ll assume the image has been pushed to a registry the cluster can reach. Replace the image reference below with that location. The two PersistentVolumeClaims already exist in the Job’s namespace: thumbnail-input contains this job’s original.jpg, and thumbnail-output receives its result. Their permissions must allow the process to read the original and write the output.
apiVersion: batch/v1
kind: Job
metadata:
name: thumbnailer
spec:
backoffLimit: 0
activeDeadlineSeconds: 30
template:
metadata:
labels:
app: thumbnailer
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: thumbnailer
image: registry.example.com/thumbnailer:example
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: 500m
memory: 128Mi
volumeMounts:
- name: input
mountPath: /input
readOnly: true
- name: output
mountPath: /output
- name: scratch
mountPath: /tmp
volumes:
- name: input
persistentVolumeClaim:
claimName: thumbnail-input
readOnly: true
- name: output
persistentVolumeClaim:
claimName: thumbnail-output
- name: scratch
emptyDir:
medium: Memory
sizeLimit: 16Mi
The resource requests guide scheduling. The CPU limit throttles usage; exceeding the memory limit can lead to an OOM kill. The memory-backed scratch volume also counts toward memory use. fsGroup can arrange group access to supported volumes, but I’d check the volume driver’s ownership behavior and the file permissions. Kubernetes configures Pod PID limits on the node, so this manifest doesn’t reproduce Docker’s 64-task cap.
activeDeadlineSeconds: 30 gives the Job an elapsed-time limit. Once it expires, Kubernetes starts terminating its Pods; the termination grace period still applies. backoffLimit: 0 avoids retrying a failed conversion in this example. I’d handle retries deliberately so a failed attempt can’t leave a partial thumbnail that looks complete.
Restrict its network access and credentials
The Job still has a network interface. For a process using staged files, I’ll add a NetworkPolicy in the same namespace that selects its label and allows no ingress or egress connections:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: thumbnailer
spec:
podSelector:
matchLabels:
app: thumbnailer
policyTypes: [Ingress, Egress]
ingress: []
egress: []
This needs a network plugin that enforces NetworkPolicy. Policies are additive: another policy selecting this Pod can allow traffic. This policy also doesn’t block traffic within the Pod or all communication with its node, so it isn’t equivalent to Docker’s --network=none. If the process needs object storage, I’d allow only the required connections and DNS traffic.
The process also needs only the credentials required for its job. It doesn’t call the Kubernetes API, so I’ve set automountServiceAccountToken: false. I’d review mounted secrets and any cloud permissions available through the workload’s identity with the same question: what could an attacker use after taking over this process?
Finally, I’d enforce the Restricted Pod Security Standard for the workload’s namespace. It can reject settings such as privileged containers, added capabilities outside the permitted set, and missing required seccomp settings. Resource budgets, network access, and mounted data still need their own policies.
Conclusion
Process hardening limits what an attacker can read, change, execute, or consume after taking control of the process. In this post, I’ve looked at several techniques for restricting what a compromised process can do:
- Choose the process’s identity and file permissions. Run it under its own account. Use file ownership, mode bits, and ACLs to control access to files.
- Remove unnecessary privileges. Clear supplementary groups, drop capabilities the process doesn’t need, and set
no_new_privsto prevent privilege gains throughexecve(). - Control inherited descriptors. Pass only the open files and standard streams the process needs. Changing its UID or a file’s mode doesn’t revoke access through an already-open descriptor.
- Set process resource limits. Use
ulimitto cap open descriptors, individual file size, CPU time, core dump size, and tasks per user. - Narrow the process’s view of the system. Use mount and network namespaces to limit which files and network interfaces it can see.
- Filter system calls. Use seccomp to restrict the operations the process can ask the kernel to perform. Pathname-based restrictions still need filesystem policy.
- Cap the service as a whole. Use cgroups to budget CPU, memory, and tasks across the process and its children. Add a wall-clock deadline for jobs that don’t finish.
- Apply filesystem policy. Use AppArmor or SELinux for restrictions beyond ordinary file permissions. Landlock lets the process restrict its own future file opens.
- Configure the container deployment. Use a read-only root filesystem, mounts limited to the job’s files, and bounded scratch space. Restrict network access, mounted secrets, service-account tokens, and cloud permissions. Enforce the Restricted Pod Security Standard in Kubernetes to reject configurations that don’t meet its requirements.
The thumbnail process needs to read one original, write one thumbnail, and finish within a budget. I’d carry those requirements into its Docker or Kubernetes configuration. Any extra access is also available to an attacker who takes it over.