CoursesLinux hardeningMount options & filesystem

Mount options & filesystem

noexec, nosuid, nodev where they belong.

Advanced12 min · lesson 11 of 16

A large office building can hang the same lock on every door and still control what happens inside each room by posting a rule at the entrance: no open flames in the server room, no equipment leaves the workshop, no outside hardware in the lab. The rule covers the whole room, and it holds no matter what anyone carries in. Linux mount options work like those posted rules. A mount option is a restriction the kernel (the core part of the operating system that talks straight to the hardware) enforces across an entire mounted filesystem, whatever the individual files inside it claim they are allowed to do.

Three of those rules do most of the security work: noexec, nosuid, and nodev. Each strips a single power from a whole area at once. Because the rule lives on the mount and not on any one file, an attacker who can write files into that area has no way to switch it off. That is what makes them worth learning well: they shut down whole techniques instead of blocking one file at a time.

The three rules, in plain terms

noexec means no execute. The kernel refuses to launch any program stored on that filesystem. You can still read the file, copy it, open it in an editor, or checksum it. The moment you try to run it as a program, the kernel says no. It is the room where nothing can be switched on.

nosuid needs one piece of background. A special permission bit, the SUID (Set User ID) bit, and its sibling the SGID (Set Group ID) bit, let a program run with the powers of the file's owner instead of the powers of the person who started it. That is how the passwd command can rewrite the root-owned password file while you run it as an ordinary user. nosuid tells the kernel to ignore those bits on this filesystem. A root-owned program with the SUID bit set, dropped into a nosuid area, runs as whoever you already are. No free promotion to root.

nodev is about device files. A device file is a doorway drilled straight through to the hardware: /dev/sda opens onto the raw disk, /dev/mem onto physical memory. Reading or writing those doorways skips the normal file-by-file permission checks and talks to the device itself. nodev tells the kernel that any device file living on this filesystem is a dud, an ordinary lump of data with nothing wired to the hardware behind it. An attacker cannot smuggle a private /tmp/rawdisk doorway into a writable area and read the disk underneath everyone's permissions.

Where the mount rules belong
Scratch / world-writable
/tmp
nodev, nosuid, noexec
/var/tmp
nodev, nosuid, noexec
/dev/shm
nodev, nosuid, noexec
User & data
/home
nodev, nosuid
/var
nodev, nosuid
Logs
/var/log
nodev, nosuid, noexec
Static system
/usr
read-only, nodev
/boot
read-only, nodev, nosuid
Give each filesystem the smallest set of powers its job needs. World-writable scratch loses all three capabilities; user and data areas keep the ability to run programs; static system files go read-only.

Why /tmp is the one everyone hardens first

/tmp is the shared coat-check counter of a Linux system. Every user and every service can drop things on it (it is world-writable, held in check only by the sticky bit that stops you deleting someone else's files). That openness is exactly what an attacker wants. After a first foothold, often a low-privilege service account, the routine next step is to pull tooling down from somewhere and run it: a privilege-escalation helper, a network scanner, a crypto miner, a reverse shell. It has to land in a writable spot, and /tmp is the obvious one.

Mount /tmp with noexec and that routine step falls over. The payload lands fine. Running it does not.

~/secopslog — bash
$ # a normal user drops an executable in /tmp and tries to run it directly cp /usr/bin/id /tmp/id chmod +x /tmp/id /tmp/id
bash: /tmp/id: Permission denied

That is not a file-permission error. The copy is owned by you and marked executable. The 'Permission denied' is noexec on the mount refusing the launch. You can confirm which option is in force with findmnt, part of the util-linux tool set, which reads the live mount table instead of what fstab hoped for:

~/secopslog — bash
$ findmnt /tmp
TARGET SOURCE FSTYPE OPTIONS /tmp tmpfs tmpfs rw,nosuid,nodev,noexec,relatime,size=2097152k,mode=1777

Add nosuid so a planted SUID-root binary gives no promotion, and nodev so a fake device file is inert, and the three together turn /tmp from a launchpad into a dead end for a lot of off-the-shelf tooling.

noexec is a speed bump, not a wall

Here is the honest part, and the part that keeps you from a false sense of safety. noexec blocks one specific thing: the kernel executing a file that lives on the mount. It does not stop a program that is already allowed to run from reading that file as input. An interpreter is exactly such a program.

~/secopslog — bash
$ # noexec blocks running the script file directly... printf '#!/bin/sh\necho payload ran\n' > /tmp/x.sh chmod +x /tmp/x.sh /tmp/x.sh # ...but the interpreter reads it as plain text and runs it sh /tmp/x.sh
bash: /tmp/x.sh: Permission denied payload ran

sh lives in /usr/bin, a normal executable filesystem. It runs, opens /tmp/x.sh as text, and does what the text says. noexec never enters the picture. The same goes for python3 /tmp/payload.py, perl /tmp/x.pl, or node /tmp/x.js: the interpreter runs from a place that allows execution, and your file is only its input.

You may have read that the dynamic linker (the small program that loads shared libraries and starts a program running) hands you a way around noexec: call it by hand and give it the binary, /lib64/ld-linux-x86-64.so.2 /tmp/id. That trick is real, but it picks a different lock. It runs a binary that is missing its execute bit on an ordinary, execute-allowed filesystem. It does nothing against noexec. To start the program, the loader still has to map the code into memory as executable, and a noexec mount refuses that mapping just as flatly as it refuses a direct launch. So the loader trips over the same wire:

~/secopslog — bash
$ /lib64/ld-linux-x86-64.so.2 /tmp/id
/tmp/id: error while loading shared libraries: /tmp/id: failed to map segment from shared object: Operation not permitted

The move that does run a compiled binary out of a noexec /tmp goes around the mount instead of through it. Reading a file is always allowed, so the bytes get read, copied into an anonymous file that lives only in memory (memfd_create, a kernel call that gives you a file backed by RAM rather than by any mounted disk), and run straight from there with execveat (the system call that launches a program named by an open file descriptor). Nothing ever executes from /tmp, so noexec has nothing to refuse. That is the technique 'fileless' payloads lean on.

So why keep noexec at all? It shuts the laziest door, and, more useful to you as a defender, it turns an ordinary-looking action into a loud one. A process hitting 'permission denied' trying to run something out of /tmp, or a program reaching for ld-linux by its full path, is a strong anomaly you can log and alert on. Treat noexec as one layer that raises the attacker's cost and throws off signal, sitting under execution logging (auditd, the Linux audit daemon), application allow-listing, and least-privilege service accounts.

noexec is not a sandbox
Do not treat a noexec mount as a wall that holds an attacker inside it. An interpreter you invoke by hand (sh, python3, perl) will read a script off a noexec filesystem and run it, because the interpreter itself lives somewhere executable and your file is only its input. A native binary can be run without ever touching the mount too: read its bytes, drop them into an anonymous in-memory file (memfd_create), and launch that. Note that invoking the dynamic loader (/lib64/ld-linux-*.so) by hand does NOT beat noexec; it only runs a binary that lacks its execute bit on an execute-allowed filesystem. What noexec buys you is the end of the lazy ./payload launch and, better, loud and alertable events when someone tries. Layer it over auditd (the Linux audit daemon) execution logging and tight, least-privilege service accounts.

Putting the rules in place

Rules go in /etc/fstab, the filesystem table the system reads at boot to decide what to mount and with which options. Each line lists the device, the mount point, the filesystem type, the options, and two trailing numbers that control dump backups and the order of the boot-time filesystem check (fsck). For an area to carry its own options, it has to be its own mount. That means one of three things: a separate partition, a tmpfs (a filesystem that lives in memory), or a bind mount (a second doorway onto an existing directory). On a modern systemd machine, /tmp and /dev/shm as tmpfs is a clean, common choice:

/etc/fstab
# device / UUID mount point type options dump pass
tmpfs /tmp tmpfs defaults,nodev,nosuid,noexec,size=2G,mode=1777 0 0
tmpfs /dev/shm tmpfs defaults,nodev,nosuid,noexec 0 0
UUID=a1c9d4e2-... /home ext4 defaults,nodev,nosuid 0 2
UUID=7b3f9c10-... /var ext4 defaults,nodev,nosuid 0 2
UUID=e5a2f8b4-... /var/log ext4 defaults,nodev,nosuid,noexec 0 2

A couple of details in that file matter. mode=1777 keeps /tmp world-writable with the sticky bit, which real software depends on; leave it out and legitimate programs break. size=2G caps the memory the tmpfs can eat, so an attacker or a runaway job cannot swallow all the RAM you gave it by flooding /tmp. Note that /home and /var keep nodev and nosuid but not noexec: users and services legitimately run programs from those trees, and noexec there breaks normal work. /var/log gets all three, because nothing should ever execute out of your logs.

You do not have to reboot to try this. If an area is already its own mount, you can remount it in place and watch the options change, which is the safe way to test before you commit anything to fstab:

~/secopslog — bash
$ mount -o remount,nodev,nosuid,noexec /tmp findmnt /tmp
TARGET SOURCE FSTYPE OPTIONS /tmp tmpfs tmpfs rw,nosuid,nodev,noexec,relatime,size=2097152k,mode=1777

One catch: remount only works if /tmp is already a separate mount. If your /tmp is a plain folder on the root filesystem, mount -o remount /tmp fails, because there is no distinct mount to change. That is when you reach for a bind mount or a real partition, then make it stick in fstab.

Separation and read-only where the work allows

Two structural habits back all of this up. The first is separation: give /tmp, /var, /var/log, and /home their own filesystems. Think of the watertight compartments in a ship's hull. If someone floods /var/log with junk to force an outage, or a runaway process fills /tmp, the damage stays inside that compartment instead of spilling into the root filesystem and wedging the whole host. As a bonus, each compartment can carry its own mount rules, which is the whole point of this lesson.

The second is read-only. Areas that hold static system files, /usr and /boot especially, rarely change outside of an update. Mount them read-only and an attacker who lands on the box cannot quietly overwrite a system binary to plant a backdoor. When you do need to patch, you remount read-write for the update and read-only again after. The goal across all of it is that every part of the filesystem carries exactly the powers its job needs and nothing spare, which is least privilege applied to disk instead of to users.

Auditing what you really have

Wanting the options and having them are different things, and a typo in fstab can be silently ignored or, worse, strand a host at boot. Check the live system, not your intentions. findmnt with no filter draws the whole mount tree with the options actually in effect:

~/secopslog — bash
$ findmnt -o TARGET,FSTYPE,OPTIONS
TARGET FSTYPE OPTIONS / ext4 rw,relatime ├─/boot ext4 ro,nosuid,nodev,relatime ├─/home ext4 rw,nosuid,nodev,relatime ├─/var ext4 rw,nosuid,nodev,relatime │ └─/var/log ext4 rw,nosuid,nodev,noexec,relatime ├─/tmp tmpfs rw,nosuid,nodev,noexec,relatime,size=2097152k,mode=1777 └─/dev/shm tmpfs rw,nosuid,nodev,noexec,relatime

Before you ever reboot on a changed fstab, validate it. findmnt --verify parses the file the way the boot process will and flags missing mount points, unknown options, and duplicate entries; add --verbose and it walks each entry, ticking off target, options, and source one line at a time. This matters more than it sounds. A wrong device name, a misspelled option, or a stale UUID can drop the machine into emergency mode on the next boot, sometimes with no network and no easy console. Run it after every edit, test options live with mount -o remount first, and keep a console or recovery path open before you reboot a remote host on a fresh table.

~/secopslog — bash
$ findmnt --verify
Success, no errors or warnings detected
Quick check
01You mount /tmp with noexec,nosuid,nodev. An attacker with a shell on the box drops payload.py into /tmp and runs python3 /tmp/payload.py. It works. Why didn't noexec stop it?
Correct — noexec only stops the kernel from executing files that live on the mount, not an interpreter you invoke from an executable path that reads your file as input.
Incorrect — noexec blocks running a script file directly too (/tmp/x.sh fails with Permission denied); the bypass is invoking the interpreter yourself.
Incorrect — The options are independent; nosuid only affects the SUID/SGID bits and has nothing to do with executing scripts.
Incorrect — size= only caps the tmpfs capacity; it has no effect on execute permission.
02You mount /tmp with nosuid. An attacker drops a root-owned program that has its SUID (Set User ID) bit set into /tmp and runs it. What happens?
Incorrect — nosuid is a mount rule that overrides the file's SUID bit, so the mount wins.
Correct — nosuid strips the free promotion the SUID bit would normally grant, so a planted root-owned binary gives no escalation.
Incorrect — that is noexec's job; nosuid still lets the program execute, just without elevated privileges.
Incorrect — nosuid changes nothing on disk; it only makes the kernel ignore the bit while the file lives on that mount.
03Before touching /etc/fstab, you try to test options live with mount -o remount,nodev,nosuid,noexec /tmp, but it fails. What's the most likely reason?
Incorrect — remount adds these options fine when the target is its own mount; the failure is that /tmp isn't one.
Incorrect — tmpfs remounts fine; the real question is whether /tmp is a separate mount at all.
Correct — remount only works on an existing mount, so a /tmp that is just a folder on / has nothing to remount.
Incorrect — the option order shown is valid; the blocker is that /tmp is not its own mount.

One more sweep is worth building into your checks. Find every world-writable directory an attacker could stage in, then list the filesystems that still allow execution so you can eyeball what remains:

~/secopslog — bash
$ # 1. every world-writable directory an attacker could stage in find / -type d -perm -0002 -not -path '/proc/*' 2>/dev/null # 2. real filesystems that still allow execution, for review findmnt --real -l -o TARGET,OPTIONS | grep -v noexec
/dev/mqueue /dev/shm /run/lock /tmp /var/tmp TARGET OPTIONS / rw,relatime /boot ro,nosuid,nodev,relatime /home rw,nosuid,nodev,relatime /var rw,nosuid,nodev,relatime

The first list is your staging map. The three that matter are /tmp, /var/tmp, and /dev/shm: writable scratch where an attacker can drop a file and try to run it, and all three deserve the same nodev, nosuid, noexec treatment. (/dev/mqueue and /run/lock show up because they are world-writable too, but they are small special-purpose mounts the system manages, not general scratch space.) /var/tmp is the one people forget, because unlike /tmp it survives a reboot, which makes it a favorite quiet parking spot for anything an attacker wants to keep around. Give it the rules you gave /tmp. The second list is your review pile: every real filesystem that can still run code (root and /usr have to, /boot is read-only), so nothing executable is hiding where you did not expect it.

Try this

Work through “Auditing what you really have” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: noexec is not a sandbox. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related