Mount options for /tmp and friends

nosuid, nodev and noexec, and their limits.

Intermediate14 min · lesson 11 of 24

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.

Which powers a filesystem needs
World-writable scratch
/tmp
nosuid, nodev, noexec
/var/tmp
nosuid, nodev, noexec
/dev/shm
nosuid, nodev, noexec
User and data
/home
nosuid, nodev
/var
nosuid, nodev
/srv
nosuid, nodev
Logs
/var/log
nosuid, nodev, noexec
/var/log/audit
nosuid, nodev, noexec
Scratch areas and logs lose all three powers; user and data areas keep the ability to run programs. / and /usr keep all three, because the system runs from them.

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.

deploy@web01 · Ubuntu 26.04 LTS
$ findmnt /tmp findmnt /dev/shm findmnt -T /var/tmp
TARGET SOURCE FSTYPE OPTIONS /tmp tmpfs tmpfs rw,nosuid,nodev,nr_inodes=1048576,inode64,usrquota TARGET SOURCE FSTYPE OPTIONS /dev/shm tmpfs tmpfs rw,nosuid,nodev,inode64,usrquota TARGET SOURCE FSTYPE OPTIONS / /dev/vda1 ext4 rw,relatime,discard,errors=remount-ro,commit=30

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.

deploy@rocky10 · Rocky Linux 10.2
$ findmnt -T /tmp findmnt /dev/shm
TARGET SOURCE FSTYPE OPTIONS / /dev/vda3 xfs rw,relatime,seclabel,attr2,inode64,logbufs=8,logbsize=32k,noquota TARGET SOURCE FSTYPE OPTIONS /dev/shm tmpfs tmpfs rw,nosuid,nodev,seclabel,inode64
$ systemctl is-enabled tmp.mount grep ^Options /usr/lib/systemd/system/tmp.mount systemctl is-active tmp.mount
disabled …

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.

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl cat tmp.mount
… [Unit] Description=Temporary Directory /tmp Documentation=https://systemd.io/TEMPORARY_DIRECTORIES Documentation=man:file-hierarchy(7) Documentation=https://systemd.io/API_FILE_SYSTEMS ConditionPathIsSymbolicLink=!/tmp DefaultDependencies=no Conflicts=umount.target Before=local-fs.target umount.target After=swap.target [Mount] What=tmpfs Where=/tmp Type=tmpfs Options=mode=1777,strictatime,nosuid,nodev,size=50%%,nr_inodes=1m,x-systemd.graceful-option=usrquota

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.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo truncate -s 64M /var/tmp/hard-mounts.img sudo mkfs.ext4 -q /var/tmp/hard-mounts.img sudo mkdir /mnt/hard-mounts sudo mount -o loop /var/tmp/hard-mounts.img /mnt/hard-mounts findmnt /mnt/hard-mounts
TARGET SOURCE FSTYPE OPTIONS /mnt/hard-mounts /dev/loop0 ext4 rw,relatime

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).

deploy@web01 · Ubuntu 26.04 LTS
$ lsblk -o NAME,MAJ:MIN,TYPE,MOUNTPOINTS /dev/vda
NAME MAJ:MIN TYPE MOUNTPOINTS vda 253:0 disk ├─vda1 253:1 part / ├─vda13 253:13 part /boot └─vda15 253:15 part /boot/efi
$ sudo cp /usr/bin/id /mnt/hard-mounts/id sudo chmod u+s /mnt/hard-mounts/id sudo mknod -m 0444 /mnt/hard-mounts/disk b 253 0 printf '#!/bin/sh\necho "script ran as $(id -un)"\n' | sudo tee /mnt/hard-mounts/hello.sh >/dev/null sudo chmod 755 /mnt/hard-mounts/hello.sh sudo cp /usr/lib/aarch64-linux-gnu/libz.so.1 /mnt/hard-mounts/ ls -l /mnt/hard-mounts
total 10548 br--r--r-- 1 root root 253, 0 Sep 27 09:19 disk -rwxr-xr-x 1 root root 41 Sep 27 09:19 hello.sh -rwsr-xr-x 1 root root 10643488 Sep 27 09:19 id -rw-r--r-- 1 root root 133544 Sep 27 09:19 libz.so.1 …

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.

deploy@web01 · Ubuntu 26.04 LTS
$ /mnt/hard-mounts/id
uid=1001(deploy) gid=1001(deploy) euid=0(root) groups=1001(deploy),4(adm),27(sudo)
$ head -c 520 /dev/vda | tail -c 8 head -c 520 /mnt/hard-mounts/disk | tail -c 8; echo
head: cannot open '/dev/vda' for reading: Permission denied EFI PART
$ /mnt/hard-mounts/hello.sh
script ran as deploy
$ python3 -c 'import ctypes; z = ctypes.CDLL("/mnt/hard-mounts/libz.so.1"); z.zlibVersion.restype = ctypes.c_char_p; print(z.zlibVersion())'
b'1.3.1'

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.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo mount -o remount,nosuid /mnt/hard-mounts findmnt -no OPTIONS /mnt/hard-mounts /mnt/hard-mounts/id
rw,nosuid,relatime uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),4(adm),27(sudo)

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.

deploy@web01 · Ubuntu 26.04 LTS
$ ls -l /mnt/hard-mounts/id
-rwsr-xr-x 1 root root 10643488 Sep 27 09:19 /mnt/hard-mounts/id

Add nodev, and the device node stops reaching the disk: reading it fails outright.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo mount -o remount,nosuid,nodev /mnt/hard-mounts head -c 520 /mnt/hard-mounts/disk | tail -c 8
head: cannot open '/mnt/hard-mounts/disk' for reading: Permission denied

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".

deploy@web01 · Ubuntu 26.04 LTS
$ sudo mount -o remount,nosuid,nodev,noexec /mnt/hard-mounts findmnt /mnt/hard-mounts /mnt/hard-mounts/hello.sh
TARGET SOURCE FSTYPE OPTIONS /mnt/hard-mounts /dev/loop0 ext4 rw,nosuid,nodev,noexec,relatime -bash: line 3: /mnt/hard-mounts/hello.sh: Permission denied
$ /mnt/hard-mounts/id
-bash: line 1: /mnt/hard-mounts/id: Permission denied

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.

deploy@web01 · Ubuntu 26.04 LTS
$ sh /mnt/hard-mounts/hello.sh
script ran as deploy

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.

deploy@web01 · Ubuntu 26.04 LTS
$ /lib/ld-linux-aarch64.so.1 /mnt/hard-mounts/id
/mnt/hard-mounts/id: error while loading shared libraries: /mnt/hard-mounts/id: failed to map segment from shared object

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.)

deploy@web01 · Ubuntu 26.04 LTS
$ python3 -c 'import ctypes; z = ctypes.CDLL("/mnt/hard-mounts/libz.so.1"); z.zlibVersion.restype = ctypes.c_char_p; print(z.zlibVersion())'
… OSError: /mnt/hard-mounts/libz.so.1: failed to map segment from shared object
What noexec is worth
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:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl edit --stdin --drop-in=50-secopslog-noexec tmp.mount <<'EOF' [Mount] # SecOpsLog: add noexec to Ubuntu's options for /tmp. Options= replaces the # packaged value, so the whole list is repeated; %% is a literal percent sign. Options=mode=1777,strictatime,nosuid,nodev,noexec,size=50%%,nr_inodes=1m,x-systemd.graceful-option=usrquota EOF
Successfully installed edited file '/etc/systemd/system/tmp.mount.d/50-secopslog-noexec.conf'.
$ systemctl cat tmp.mount
… [Mount] What=tmpfs Where=/tmp Type=tmpfs Options=mode=1777,strictatime,nosuid,nodev,size=50%%,nr_inodes=1m,x-systemd.graceful-option=usrquota # /etc/systemd/system/tmp.mount.d/50-secopslog-noexec.conf [Mount] # SecOpsLog: add noexec to Ubuntu's options for /tmp. Options= replaces the # packaged value, so the whole list is repeated; %% is a literal percent sign. Options=mode=1777,strictatime,nosuid,nodev,noexec,size=50%%,nr_inodes=1m,x-systemd.graceful-option=usrquota

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.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo mount -o remount,noexec /tmp findmnt /tmp
TARGET SOURCE FSTYPE OPTIONS /tmp tmpfs tmpfs rw,nosuid,nodev,noexec,nr_inodes=1048576,inode64,usrquota
$ printf '#!/bin/sh\necho hi\n' > /tmp/hard-mounts-hi.sh chmod +x /tmp/hard-mounts-hi.sh /tmp/hard-mounts-hi.sh
-bash: line 3: /tmp/hard-mounts-hi.sh: Permission denied

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.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo rm /etc/systemd/system/tmp.mount.d/50-secopslog-noexec.conf sudo systemctl daemon-reload sudo mount -o remount,exec /tmp findmnt /tmp
TARGET SOURCE FSTYPE OPTIONS /tmp tmpfs tmpfs rw,nosuid,nodev,nr_inodes=1048576,inode64,usrquota

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.

deploy@web01 · Ubuntu 26.04 LTS
$ cp /etc/fstab /var/tmp/hard-mounts.fstab echo '/var/tmp/hard-mounts.img /mnt/hard-mounts ext4 loop,nosuid,nodev,noexce,nofail 0 2' >> /var/tmp/hard-mounts.fstab sudo findmnt --verify --tab-file /var/tmp/hard-mounts.fstab
0 parse errors, 0 errors, 1 warning /mnt/hard-mounts [W] non-bind mount source /var/tmp/hard-mounts.img is a directory or regular file

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.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo mount --fstab /var/tmp/hard-mounts.fstab /mnt/hard-mounts
mount: /mnt/hard-mounts: wrong fs type, bad option, bad superblock on /dev/loop0, missing codepage or helper program, or other error. dmesg(1) may have more information after failed mount system call.
$ sudo dmesg | grep -i "unknown parameter" | tail -n 1
[ 3229.011984] ext4: Unknown parameter 'noexce'

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).

deploy@web01 · Ubuntu 26.04 LTS
$ sed -i 's/noexce/noexec/' /var/tmp/hard-mounts.fstab sudo mount --fstab /var/tmp/hard-mounts.fstab /mnt/hard-mounts findmnt /mnt/hard-mounts sudo umount /mnt/hard-mounts
TARGET SOURCE FSTYPE OPTIONS /mnt/hard-mounts /dev/loop0 ext4 rw,nosuid,nodev,noexec,relatime
$ sudo cp -a /etc/fstab /etc/fstab.bak sudo cp /var/tmp/hard-mounts.fstab /etc/fstab sudo systemctl daemon-reload sudo mount /mnt/hard-mounts findmnt /mnt/hard-mounts
TARGET SOURCE FSTYPE OPTIONS /mnt/hard-mounts /dev/loop0 ext4 rw,nosuid,nodev,noexec,relatime

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.

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl show -p WantedBy,RequiredBy /mnt/hard-mounts systemctl show -p RequiredBy /boot systemctl show -p OnFailure local-fs.target
RequiredBy= WantedBy=local-fs.target RequiredBy=unattended-upgrades.service boot-efi.mount local-fs.target OnFailure=emergency.target

To roll back, restore the backup and daemon-reload. A final findmnt --verify on the live fstab should be clean before you ever reboot.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo umount /mnt/hard-mounts sudo cp -a /etc/fstab.bak /etc/fstab sudo systemctl daemon-reload sudo findmnt --verify
Success, no errors or warnings detected

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.

Quick check
01You mount /tmp with nosuid,nodev,noexec. An attacker with a shell drops payload.py there and runs python3 /tmp/payload.py. It works. Why did noexec not stop it?
Correct — The interpreter is the program being executed; the kernel only checks the mount of /usr/bin/python3, not the script it reads.
Incorrect — noexec blocks running a script file directly too; this works only because you invoked the interpreter, which reads your file as data.
Incorrect — The options are independent. nosuid concerns the SUID bit and has no effect on execution of scripts.
Incorrect — The size option only caps capacity. It has nothing to do with execute permission.
02On Ubuntu 26.04 you want noexec on /tmp. Why edit a tmp.mount drop-in rather than add a line to /etc/fstab?
Incorrect — fstab is still read at every boot; systemd converts it to mount units. The reason is different.
Incorrect — Neither applies to a live mount without a remount or reboot; the reason to use the drop-in is ownership of /tmp.
Correct — /tmp is defined by tmp.mount, not fstab; the drop-in changes Options= there, and an fstab line for /tmp would fight the unit.
Incorrect — noexec is a normal fstab option. The point is which mechanism already owns /tmp on Ubuntu.
03You add /srv/pgdata, a separate disk for PostgreSQL, to /etc/fstab. A colleague wants nofail on the line so that a missing disk never strands a reboot. What is the risk, and what is the better setup?
Incorrect — nofail does not change fsck; the pass number does. The risk lies in what starts while the disk is missing.
Correct — With nofail the boot goes on, so the service must itself require its data mount, or the database writes to the root filesystem.
Incorrect — For a data mount, silently running without the data can be worse than stopping, and it is harder to notice.
Incorrect — nofail changes only whether the mount is required or wanted; it never adds ro.

Related