Bash – Executing commands in an elevated bash process by writing to the standard input of its parent script process

bashcommand linefile-descriptorsprivilegesSecurity

I have a simple bash script bash.sh that starts another bash instance using pkexec.

#!/bin/bash
bash -c 'pkexec bash'

When executed this shows a prompt for the user to enter their password. The main script bash.sh runs as normal user but the bash instance started by it runs as root with elevated privileges.

When I open a terminal window and try to write some command to the standard input of the elevated bash process it throws a permission error (as expected) .

echo 'echo hello' > /proc/<child-bash-pid>/fd/0

The problem is that when I write to the parent process (bash.sh) it gets passed to the child bash process which then executes the command.

echo 'echo hello' > /proc/<parent-bash.sh-pid>/fd/0

I'm not able to understand how this is possible? Since the parent is running as a normal user why am I (a normal user) allowed to pass commands to the child process which is running with higher privileges?

I understand the fact that the standard input of the child process is connected to the standard input of the parent script, but if this is allowed then any ordinary process can execute root commands by writing to the parent process of a rooted bash process.

This does not seem logical. What am I missing?

Note: I verified that the child is executing the command passed to the parent by deleting a file in /usr/share which only root would have permission to do.

sudo touch /usr/share/testfile

echo 'rm -f /usr/share/testfile' > /proc/<parent-bash.sh-pid>/fd/0

The file was deleted successfully.

Best Answer

This is normal. To understand it, let's see how file descriptors work and how they are passed between processes.

You mentioned that you are using GLib.spawn_async() to spawn the shell script. That function, presumably, creates a pipe to be used for sending data into the child's stdin (or perhaps you create the pipe yourself and pass it to the function). To spawn the child process, that function will fork() off a new process, rearrange its file descriptors such that the stdin pipe becomes fd 0, and then exec() your script. Since the script starts with #!/bin/bash, the kernel interprets this by exec()ing a bash shell, which then runs your shell script. That shell script forks and execs yet another bash (this is redundant, by the way; you don't really need the bash -c in there). No file descriptors are rearranged, so the new process inherits the same pipe as its stdin file descriptor. Note that this isn't "connected" to its parent process per se - in fact, the file descriptors reference one and the same pipe, the one that was created or assigned by GLib.spawn_async(). In effect, we are merely creating aliases for the pipe: fd 0 in these processes all reference the pipe.

The process is repeated when pkexec is invoked - but pkexec is a suid root binary. That means that, when that binary is exec()ed, it runs as root, yet its stdin is still connected to the original pipe. pkexec then does its permission checks (which involve prompting for a password), and then ultimately exec()s bash. Now we have a root shell which is taking its input from a pipe, while a number of other processes owned by your user also have a reference to that pipe.

The important thing to understand is that, under POSIX semantics, file descriptors have no permissions. Files have permissions, but file descriptors represent the privilege to access a file (or an abstract buffer like a pipe). You can pass a file descriptor to a new process, or even to an existing process (via UNIX sockets), and the permission to access the file travels with the file descriptors. You can even open a file, then change its owner to another user, and yet still access the file through the original fd as the previous owner, since permissions are only checked at the time the file is opened. In this way, file descriptors allow communication across privilege boundaries. By having a process owned by your user and a process owned by root share the same file descriptor, you are granting both processes the same rights over that file descriptor. And, since the fd is a pipe, and the root process is taking commands from that pipe, that allows the other process owned by your user to issue commands as root. The pipe itself has no concept of an owner, just a series of processes that happen to have open file descriptors to it.

Furthermore, since the basic Linux security model assumes that a user has complete control over all of their processes, that means you can peek into /proc to gain access to the fd, as you have done. You can't do this via the /proc entry of the bash process running as root (since you aren't root) but you can do it for your own process, and the resulting pipe file descriptor acquired is exactly the same as if you could do it directly to the child process running as root. Thus, echoing data into the pipe causes the kernel to bounce it back to the processes reading from the pipe - in this case, only the child root shell, which is actively reading commands from the pipe.

If the shell script were invoked from a terminal, then echoing data into its standard input file descriptor would actually end up writing data to the terminal, and it would be displayed to the user (but not executed by the shell). This is because terminal devices are bidirectional, and, in fact, the terminal would be connected to both stdin and stdout (and stderr). However, terminals have special ioctl methods for injecting input data, so it it still possible to inject commands into the root shell as a user (it just takes more than a simple echo).

In general, you've discovered an unfortunate truth about privilege escalation: the moment you allow a user to escalate to a root shell by any means, effectively, any application run by that user should be assumed to be able to abuse that escalation (while it exists). The user becomes root, for security intents and purposes. Even if this kind of stdin injection weren't possible, for example, if you were running the script under a terminal, you could simply use X server keyboard injection support to send commands directly at the graphical level. Or you could use gdb to attach to a process with the open pipe and inject writes into it. The only way to close this hole is to have the root shell directly connected to a secure I/O channel to the (physical) user that cannot be tampered with by unprivileged processes. This is hard to do without severely restricting usability.

One last thing worth noting: normally, (anonymous) pipes have a read end and a write end, i.e. two separate file descriptors. The end passed to the child processes as stdin is the read end, while the write end would stay in the original process that called GLib.spawn_async(). That means that the child processes can't actually write into stdin to send data back to themselves or to the bash running as root (of course, processes don't normally write into stdin, though nothing says you can't - but in this case it wouldn't work when stdin is the read end of a pipe). However, the kernel's /proc mechanism for accessing file descriptors from another process subverts this: if a process has an open fd to the read end of a pipe, but you try to open its respective /proc fd file for writing, then the kernel will actually give you the write end of the same pipe instead. Alternatively, you could go look for the /proc entry corresponding to the original process that called GLib.spawn_async(), find the end of the pipe that is open for writing, and write into that, which would not depend on this special kernel behavior; this is mostly a curiosity but doesn't really change the security issue.

Related Question