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.