Mount options for /tmp and friends
nosuid, nodev and noexec, and their limits.
A mount option is a rule the kernel enforces across a whole filesystem, no matter what the individual files on it claim. Three of them do most of the security work: nosuid ignores the SUID and SGID bits, nodev treats device files as inert, and noexec refuses to execute anything on the mount. Because the rule lives on the mount and not on any file, someone who can write files there cannot switch it off. In this lesson you see exactly what each option blocks and what it does not, on the filesystems Ubuntu 26.04 and RHEL 10 actually ship, then add noexec to /tmp the systemd way and change /etc/fstab safely, with the emergency-mode risk in mind.
What each option blocks
nosuid tells the kernel to ignore the SUID and SGID bits when running a program from this filesystem. Those bits let a program run with the powers of the file's owner instead of the user who started it, which is how passwd edits the root-owned password file. On a nosuid mount the program still runs, but only with your own privileges. nodev makes device files inert. A device node such as /dev/sda is a doorway to hardware, guarded only by its own mode, so a node created elsewhere with loose permissions gives the raw access that /dev would refuse; nodev turns any device node on the mount into an ordinary lump of data. noexec refuses to execute a file that lives on the mount at all. Give a filesystem the smallest set of powers its job needs, and these three are how you take the others away.
What Ubuntu and RHEL ship
Start by reading the live mount table with findmnt, part of util-linux, which shows the options actually in effect rather than what fstab hoped for. On Ubuntu 26.04 /tmp is already a tmpfs, a filesystem that lives in RAM, and it is mounted nosuid,nodev but not noexec. /dev/shm, the POSIX shared-memory area, is nosuid,nodev too. /var/tmp is not its own mount at all: it sits on the root filesystem.
Two things follow. Ubuntu already gives you two of the three options on /tmp and /dev/shm for free, so the work here is adding noexec and giving /var/tmp the same treatment. And /var/tmp shares the root filesystem's options, so you cannot harden it without either making it a separate mount or a bind mount. On RHEL 10 the picture is different again: /tmp is a plain directory on the root XFS filesystem, and the tmp.mount unit that would make it a tmpfs ships disabled. There is nothing to remount until you enable it.
The Ubuntu /tmp tmpfs is set up by a systemd unit, not by fstab. systemctl cat tmp.mount shows where its options come from, which matters because you will change them through the same mechanism.
Prove it on a test filesystem
Rather than experiment on a live mount, put a small ext4 filesystem in a file under /var/tmp and mount it through a loop device (a kernel feature that presents a file as a block device). Nothing outside the file is touched, and a mount -o remount changes its options in place.
Linux essentials showed nosuid on a SUID copy; here all three options are tested side by side. Plant four things on the test filesystem: a copy of id with the SUID bit set and owned by root, a block device node pointing at the real root disk, an executable shell script, and a shared library. On Ubuntu 26.04 /usr/bin/id is a symlink to the uutils multicall binary; cp copies the real target, which still works because it dispatches on its own name. The device node uses the root disk's major and minor numbers, which lsblk prints in the MAJ:MIN column. This lab disk is a virtio disk, vda at 253:0; on your machine read your own numbers (a SATA or SCSI sda is 8:0, an nvme0n1 is usually 259:0).
With no options set, every one of them works, which is the baseline to compare against. The SUID copy runs with euid=0; the device node reads real bytes off the disk (the partition table's EFI PART signature); the script runs; and Python loads the shared library and calls a function in it.
Now add the options one at a time and watch each power disappear. nosuid first: the same SUID copy of id now runs as deploy, with no euid=0.
The SUID bit is still on the file; nosuid does not clear it, it makes the kernel ignore it while the file lives on this mount. If root copies it, mode and owner kept (cp -p), to a filesystem without nosuid, the promotion is back; a plain cp by an ordinary user drops the SUID bit.
Add nodev, and the device node stops reaching the disk: reading it fails outright.
Add noexec, and the kernel refuses to run the script directly. The exit status is 126, the shell's code for "found but could not execute".
noexec is a speed bump, not a wall
noexec blocks one 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. sh runs from /usr, which allows execution, opens the script as text, and does what it says. The same is true of python3 /tmp/x.py, perl, and node.
You may read that invoking the dynamic loader by hand, /lib/ld-linux-aarch64.so.1 ./prog, gets around noexec. It does not. That trick runs a binary that is only missing its execute bit, on a filesystem that allows execution. Against noexec it fails, because to start the program the loader still has to map its code into memory as executable, and the mount refuses that mapping.
The same refusal catches a real use of the mount: loading a shared library from it. noexec blocks the executable memory mapping a library needs, so Python cannot dlopen the copy on the mount even though it read the file happily a moment ago. (x86_64 servers show /lib64/ld-linux-x86-64.so.2 in place of the aarch64 loader; the behaviour is identical.)
noexec does not contain an attacker. An interpreter you already allow will read a script off the mount and run it, and a native binary can be copied into anonymous memory and run from there without ever touching the mount. What noexec buys you is the end of the lazy ./payload launch, and, more useful, it turns an ordinary action into a loud one: a "Permission denied" trying to run something out of /tmp is a strong signal to log and alert on. Treat it as one layer under auditd execution logging and least-privilege service accounts, not as a sandbox.Adding noexec to /tmp on Ubuntu
Because Ubuntu's /tmp comes from tmp.mount, change it with a systemd drop-in rather than fstab. systemctl edit creates the override (--drop-in names the file; without --stdin it opens an editor) and reloads systemd's configuration itself, so no separate daemon-reload is needed. Options= replaces the packaged value entirely, so the whole list is repeated with noexec added. Comments go on their own line: systemd ignores a line that starts with #, but not a # after a value, where the comment text would become part of the option. systemctl cat shows the drop-in after the packaged unit:
The new options take effect at the next mount of /tmp; to apply them now without a reboot, remount. findmnt confirms noexec is in force, and a script in /tmp is refused.
This is where you weigh the operational impact before committing it. noexec on /tmp breaks anything that runs code from there: some package maintainer scripts, Java's extraction of bundled native libraries, pip builds, a few installers. Test the workloads that matter on a staging host first. The rollback is to remove the drop-in, reload, and remount exec; keep a console or recovery path open, because a mistake in a mount that the boot needs can strand the machine.
nosuid,nodev,noexec on /tmp, /var/tmp and /dev/shm is a CIS Benchmark recommendation on both distributions and sound SecOpsLog advice. It is not an upstream default: Ubuntu ships nosuid,nodev on /tmp, and RHEL leaves /tmp on the root filesystem.
Changing /etc/fstab without stranding the host
Filesystems other than /tmp are configured in /etc/fstab. Linux essentials covered the routine: a bad line can drop the machine into emergency mode at the next boot, so edit a copy, check it with findmnt --verify --tab-file, and keep a backup. What that routine misses is the part worth seeing here.
findmnt --verify passes here (the one warning is only that the source is a file, expected for a loop mount) because it does not know noexce is a typo: unknown filesystem options are the kernel's business, not the parser's. The real check is to mount from the copy and read the result. The mount fails, and the kernel log says why.
Fix the typo, confirm the mount works from the copy, then install it: back up the current /etc/fstab, copy the checked file over it, and run daemon-reload so systemd regenerates its mount units from the new table (without it, mount warns and the boot uses the old units).
Whether to add nofail depends on what uses the mount. Without it, a mount you add is required by local-fs.target, and a failure runs that target's OnFailure=, which is emergency.target; the output shows both, and that /boot is required by design. With nofail the mount is only wanted (here, WantedBy=local-fs.target), and systemd.mount(5) says the boot continues whether it mounts or not.
That is right for a mount nothing depends on, and dangerous for a data mount. If the disk behind /srv/pgdata is missing and the boot continues, the database starts on the empty mount point directory on the root filesystem: it may create a fresh cluster, a backup job may fill /, and when the disk returns the mount hides what was written. For data, tie the service to its mount instead: RequiresMountsFor=/srv/pgdata in the service's unit (systemd.unit(5)) makes the service fail to start without its data, or put x-systemd.required-by=postgresql.service on the fstab line. x-systemd.device-timeout= limits how long boot waits for a slow device. Keep nofail for truly optional mounts, such as a scratch disk or an extra view like the bind mount in Try this.
To roll back, restore the backup and daemon-reload. A final findmnt --verify on the live fstab should be clean before you ever reboot.
Try this
On a lab machine, give a directory its own mount options without a partition, using a bind mount (a second view of an existing directory). Put a copy of /usr/bin/true in /var/tmp/probe, then run sudo mount --bind -o nosuid,nodev,noexec /var/tmp/probe /var/tmp/probe and confirm with findmnt that the options are set. Running /var/tmp/probe/true now fails with "Permission denied" and exit status 126. Unmount with sudo umount /var/tmp/probe and confirm the same binary runs again.
A bind mount made by hand is gone after a reboot. To keep it, use the fstab routine above: copy /etc/fstab, append /var/tmp/probe /var/tmp/probe none bind,nosuid,nodev,noexec,nofail 0 0, run sudo findmnt --verify --tab-file on the copy (the lab's run reported "Success, no errors or warnings detected"), mount from the copy with sudo mount --fstab COPY /var/tmp/probe, and check that findmnt lists nosuid,nodev,noexec. nofail fits here because the directory works without the extra view. Unmount and delete the copy when you are done, or install it with a backup to harden a path such as /var/tmp on a host whose partitioning you cannot change.
Takeaway
nosuid and nodev are cheap and safe on /tmp, /var/tmp, /dev/shm, /home and /var; noexec is a useful speed bump on scratch areas but not a sandbox, so test what it breaks first. Change Ubuntu's /tmp through the tmp.mount drop-in and everything else through a checked, backed-up /etc/fstab, and verify with findmnt, never with the file you just edited.