4

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.

2
  • 2
    I'm going to write an answer, but I need an important extra bit of information: how are you invoking the original shell script? Are you running it from a simple terminal window? Although I can explain the permissions issue, your command injection example should not work as is in that case (though there are other ways to make it work), which makes me think that something other than a plain graphical terminal is involved here.
    – marcan
    May 13, 2016 at 7:11
  • It is being executed through an application using GLib.spawn_async() method May 13, 2016 at 8:59

1 Answer 1

5

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.

3
  • This makes sense. I need to read up more on file descriptors in Linux. They work a little differently than I thought they would. Thank you for the detailed answer. It was very informative. May 13, 2016 at 10:22
  • I was trying to create a background root shell from an application which could be used to run root commands. This way the user would be prompted only once for the password and the main application itself could be started as a normal user. It seems to be a bad idea to start a bash process in this way as it could be misused by other applications. Maybe I can encrypt the commands being passed between the application and the root shell so that the root shell will only execute the commands sent by the application? Thanks for explaining the behaviour of pipes. I need to experiment more with this. May 13, 2016 at 10:29
  • 1
    Essentially, the moment you start a root shell, you are giving any other process running as that user root access. Encryption won't help, because any other process running as that user could use something like ptrace to read all the encryption keys off your app. This is no different from doing a simple sudo -i which has the same problem. What you can do is write a secure application that runs as root and limits the actions that the user can take through it. Whether this makes sense or not depends on whether that would meaningfully increase security for your particular use case.
    – marcan
    May 13, 2016 at 16:28

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .