Vivian Voss

The udev Rule That Runs as Root

unix universe freebsd security device management

A jail on one of my servers has nine entries in /dev. The host it runs on has a hundred and eleven. I did not write a rule to arrange that, and I did not audit a list of devices to work out which ones a web server has no business seeing. The default shipped that way, and the default is one line long.

[devfsrules_hide_all=1]
add hide

Everything after that is an unhiding. The jail ruleset picks up null, zero, random, urandom, crypto, the pseudo-terminals, and, because this machine runs ZFS, zfs. Nine entries, arrived at by starting from nothing and granting inwards.

There is nothing clever about that arrangement. It is old and it is dull, and the reasoning behind it is worth an hour of anybody's Monday, because the same problem was solved twice in the Unix world and the two answers went off in rather different directions.

The whole language, on one page

The rule language that governs device visibility on FreeBSD has two conditions and six actions. That is all of it.

The conditions are path, which matches a glob against the node's name, and type, which matches the kind of device. The actions are hide, unhide, user, group, mode, and include, the last of which pulls in another ruleset so that the jail rules can be built out of the basic ones. devfs.rules(5) runs to seventy lines on this machine and documents the file format completely, with room left over for two worked examples.

Read that list again and notice what is absent from it. There is no action that starts a program. There is no action that runs a shell. A devfs rule can make a device node visible, invisible, owned by somebody, or readable by a group, and when it has done that it has exhausted its vocabulary.

Nobody is planning to lift that limitation, because it is the point of the thing. The file that decides what appears in /dev was given a vocabulary in which arbitrary execution cannot be expressed, which means that a person auditing it only has to understand six verbs to know everything it could possibly do.

Measured on FreeBSD 15.1-RELEASE-p3 6 actions in the devfs rule language 3 udev keys that start a process, of about 35 70 lines of manual for the whole format 111 : 9 entries in /dev, host against jail The nine are not a curated list. They are what is left after a default that begins by hiding everything. Counted with ls; ruleset 4 from /etc/defaults/devfs.rules, applied through devfs_ruleset in the jail configuration.

The other house

Linux solves the same problem with udev, and udev is a great deal more capable. The rules language has around twenty match keys, covering the subsystem, the driver, the device path, kernel attributes, environment variables, tags, and the result of an external program. It has roughly fifteen assignment keys, and three of those start a process: PROGRAM runs something to produce a value for matching, IMPORT{program} runs something to import variables, and RUN specifies, in the manual's own words, "a program to be executed after processing of all the rules for the event".

Those rules are read from four directories: /usr/lib/udev/rules.d, /usr/local/lib/udev/rules.d, /run/udev/rules.d and /etc/udev/rules.d, with the local administration directory taking priority and the volatile runtime directory outranking the vendor one. The daemon that reads them runs as root, because assigning ownership to a device node is work that requires it.

So a file whose ordinary job is to give a disk a stable name can also start a root process, and the two capabilities live in the same syntax, in directories that the same person maintains.

The security community noticed. MITRE ATT&CK carries this as T1546.017, Udev Rules, filed under both persistence and privilege escalation, with the REPTILE rootkit listed as a real-world user. Getting your own technique number in that catalogue is a peculiar sort of compliment, and it is worth being precise about what it means: the technique requires root to write the rule file in the first place, so it is more often how an intruder stays than how they arrive. The interesting part is what it buys them once they are in. A rule attached to /dev/random executes whenever an application touches /dev/random, which on most machines is an unremarkable thing happening constantly, and the process that spawns is a child of the device manager, which is not where anybody looks first.

To be fair to udev, and the fairness is owed: it does jobs that a server operating system barely has. Desktop hotplug, session-based access to a webcam through uaccess, firmware loading for hardware that needs it on attach, and a device zoo against which FreeBSD's looks positively sparse. Somebody plugging in a graphics tablet on a laptop is being served by machinery that has no equivalent on my machines, and they are better off for it.

The same job, filed two ways FreeBSD Linux creates the node devfs, in the kernel devtmpfs, in the kernel sets owner and mode devfs, at creation systemd-udevd, afterwards interval between the two none a window, hence udevadm settle visibility rule can execute no, six actions, none of them yes: PROGRAM, IMPORT, RUN where event actions live devd, separate file and daemon udev, same file, same daemon Both kernels create the node. The difference begins at the next line of the table.

What was thrown out in 2006, and why

Here is the part that surprised me when I went looking, because the story is usually told as though Linux never had this option.

Linux had its own device filesystem. Richard Gooch wrote it, it was merged into 2.3.46 in February 2000, and it did roughly what FreeBSD's does: the kernel populated /dev with the devices that actually existed. It was marked obsolete in 2003, scheduled for removal in July 2005, and finally deleted in 2.6.18. Greg Kroah-Hartman wrote the removal entry, and it is four lines that have been quoted ever since:

devfs has been unmaintained for a number of years, has unfixable races, contains a naming policy within the kernel that is against the LSB, and can be replaced by using udev.

Four reasons. Three of them are about that particular code. Unmaintained describes what had become of it: Gooch had stopped touching it years before, no major distribution had enabled it, and a filesystem nobody maintains belongs nowhere near a kernel. Replaceable describes the successor. The unfixable races were real and specific, and they were the strongest of the four.

The fourth reason is the one that travels. Naming policy in the kernel, contrary to the LSB, is an argument from architecture: mechanism belongs below and policy belongs above, so a kernel that decides what a device is called has taken a decision that was never its to take. It is a tidy principle. It is also the only one of the four that points at FreeBSD, and anybody who wants to argue with what I run should argue with that one.

Two answers, twenty-six years FreeBSD devfs in 2.0 1994 default in 5.0, with devd 2003 only method in 6.0 2005 Linux devfs in 2.3.46 2000 obsolete, udev begins 2003 deleted in 2.6.18 2006 CVE-2009-1185 2009 absorbed into systemd 2012 Both projects had the same idea. Only one of them kept it.

The question the principle does not ask

So let us take it seriously and ask what the move upstairs actually did to the races.

On Linux today the kernel creates the device node through devtmpfs, with default ownership and default permissions, and then emits a uevent. systemd-udevd receives it, works through the rules, applies the ownership, the group, the mode and the symlinks, and only after all of that does anything in a RUN key execute. Between the node appearing and the rules landing on it there is a window. It is usually short. It is not zero, and it is not the kernel's to close, because the kernel has already finished its part.

udevadm settle exists to wait for that window, and the systemd documentation is admirably blunt about what it is for: blocking until subsystems catch up with reality, kept around for tools that need it. Anybody who has written a provisioning script that reads a device immediately after creating it has met this personally, usually at a bad hour, usually as an intermittent failure in continuous integration that passes when re-run.

On FreeBSD the ruleset is attached to the mount, and a node arrives already carrying the ownership and the mode the ruleset specifies. There is no interval in which the node exists and the policy has not yet been applied, because the same piece of code does both things at the same moment.

Which is the thing worth saying plainly. Arguing about which layer a decision belongs in is a way of never reaching the place where the fault actually lives.

Moving the naming policy out of the kernel satisfied the principle and left the race where it was, one storey further up, at the join between the kernel that creates and the daemon that corrects. The seam moved one storey up, and the gap it left behind is the same gap, because the question was never which side of the boundary the policy sat on. It was whether creating a device and deciding what it is are one operation or two.

The limit

Four deductions, and the first one matters most, because it is the objection I would raise if somebody handed me this piece.

FreeBSD has a device daemon that runs commands, and it is called devd. Warner Losh wrote it, it appeared in 5.0 alongside the rest of this machinery, and devd.conf(5) is 599 lines of manual page describing, among other things, how to attach an action to an event. Anybody who can write that file can run something as root when a device appears. FreeBSD plainly does execute things on device events, and pretending otherwise would be silly. What it keeps apart is the paperwork. The file deciding what /dev shows and the file deciding what happens on an event are read by different programs out of different vocabularies, and neither one has the other's powers.

There is a second difference in how those events arrive, and it is the one the history makes vivid. devd hooks into devctl, a kernel device that allows a single reader, and multiplexes to any clients over a Unix domain socket. In 2009, udev before 1.4.1 did not verify that a netlink message had come from the kernel at all, and an unprivileged user could forge one and have udev act on it as root. That was CVE-2009-1185, Jon Oberheide published a working exploit, and it was fixed promptly. That flaw was fixed in short order, and the reason the same trick never applied to devd is simply the shape of the channel it listens on.

The naming-policy objection stands, as far as it goes. FreeBSD's devfs does hold naming and permission policy in the kernel. I think the trade is a good one, and I have just spent several paragraphs explaining why, but somebody who values the boundary more than the atomicity is making a real argument and not merely a fastidious one.

The ATT&CK technique needs root. It is a way to stay, and a fairly elegant one, and anybody quoting the technique number as though it were an unauthenticated hole has read the wrong half of the page.

And "FreeBSD is secure" is not a sentence I will write. devfs has had advisories and jails have had escapes, and the base system is not made of better atoms than anybody else's. What differs is the shape of the attack surface and who answers for it when it fails.

The bill

The usual shape of this section is a cost, and here the cost is a curious one, because it was paid in advance and by somebody else.

Linux was handed a device filesystem that was genuinely badly built, and after watching it rot for six years it drew a conclusion about the entire category. That conclusion has been carried for twenty years, mostly by people who never ran the thing that caused it. The successor is more capable and more portable, and it drives a great deal more hardware than anything I operate. It also brought a rule language in which giving a disk a predictable name and starting a root process are neighbouring lines in the same file, and a window between node and policy that has its own command for waiting on it.

My nine device entries in that jail did not require a security review or a scheduled task to verify that nothing had been added. They are what is left after a default that begins by hiding everything, expressed in a language that cannot run a program even if somebody wanted it to.

One might call it a failure of ambition. Seventy lines of manual describing six verbs, and an argument I have not had to have since 2003.