Feline ouroboros
2021-01-27The concept of Unix pipes can be traced all the way back to the Nintendo Mario Bros. arcade game from 1983, but they didn't really catch on until Linux Torvalds popularized them in the early 90s. Although simple in concept, pipes are central to the Unix motto "Do One Thing Worse And Do It Better". They enable constructs such as
1 | $ grep -irl pizza . | wc -l
|
which tells you how many of your files are worth keeping.
Note how in this example, two unrelated processes were able to put aside their differences and come together in pursuit of a common goal.
This was only possible because a pipe connected them in the most intimate way: stdout
to stdin
.
Not many people visualize pipes correctly due to how the pipe character "|
" is commonly portrayed in the media.
A pipeline such as grep | wc
is often presumed to look like
but that wouldn't make any sense. It's technically more along the lines of
(If the interactive WebGL doesn't render for you, please accept my condolences.)
But I digress. The reason we're assembled here today is to see the abomination that is the autopipe. Let's take a look at what we've got to work with:
1 2 3 4 5 6 | $ cat | cat &
[1] 3464985
$ stat -c %N /proc/$!/fd/*
'/proc/3464985/fd/0' -> 'pipe:[6743132]'
'/proc/3464985/fd/1' -> '/dev/pts/13'
'/proc/3464985/fd/2' -> '/dev/pts/13'
|
Instead of reading its input from the terminal, the second cat
gets it from pipe 6743132
.
What we'd like is to have a process that has the same pipe on both std
ends.
It would be great if we could just reach in and adjust the file descriptors until that is the case.
Alas, that is verboten:
1 2 | $ stat -c %A /proc/$!/fd
dr-x------
|
The shell allows us to create an anonymous pipe between two freshly-spawned processes using |
, but |
has no provision for piping a process back into itself.
This should be self-evident, since the pipe contains no bends.
For now, we will cheat, as that will allow us to get a taste for victory and motivate us to find a better solution.
Behold, the named pipe:
1 2 3 | $ mkfifo x
$ stat -c '%A %F' x
prw-r--r-- fifo
|
You might think that a named pipe is like an anonymous pipe, but with a name, and you would be sort of right, but mostly wrong.
Named pipes are just files that you can reach out and touch, while anonymous pipes are just files that exist in the nebulous pipefs
filesystem.
Let us proceed with the flimflam:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | $ cat <x >x &
[1] 3465644
$ cat /proc/$!/io
rchar: 0
wchar: 0
syscr: 1
syscw: 0
read_bytes: 0
write_bytes: 0
cancelled_write_bytes: 0
$ echo >x; sleep 10; cat /proc/$!/io
rchar: 20390995
wchar: 20385250
syscr: 20385264
syscw: 20385250
read_bytes: 0
write_bytes: 0
cancelled_write_bytes: 0
$ stat -c %N /proc/$!/fd/*
'/proc/3465644/fd/0' -> '/tmp/x'
'/proc/3465644/fd/1' -> '/tmp/x'
'/proc/3465644/fd/2' -> '/dev/pts/13'
|
Looks like we can push almost 2 MB/s of garbage through our named pipe, one byte at a time! This is bottlenecked by how quickly we can do syscalls, and if we dump as many bytes as possible into the pipe without overflowing it (precisely 65,536 of them), we can get upwards of 6 GB/s. Even 10 GigE can't compete with that!
However, there is a sinister mystery afoot, revealed by peeking at the fresh process before anything is dropped into the pipe:
1 2 3 4 5 6 7 8 9 10 11 | $ cat <x >x &
[1] 3466435
$ stat -c %N /proc/$!/fd/*
'/proc/3466435/fd/0' -> '/dev/pts/13'
'/proc/3466435/fd/1' -> '/dev/pts/13'
'/proc/3466435/fd/2' -> '/dev/pts/13'
'/proc/3466435/fd/255' -> '/dev/pts/13'
$ stat -c %N /proc/$!/exe
'/proc/3466435/exe' -> '/usr/bin/bash'
$ grep "^PPid:[[:space:]]*$$" /proc/$!/status
PPid: 3464983
|
We primarily expect three things from our shell when spawning a process: it fork
, but it also dup
and exec
.
It has clearly performed the first of these, but ¿what about the rest?
We strace
into the child to find that, just like on Christmas morning, it's eagerly awaiting until it can open the pipe:
1 | openat(AT_FDCWD, "x", O_RDONLY
|
Once we open the floodgates, the call succeeds successfully, and we resume execution:
1 2 3 4 5 6 7 8 | openat(AT_FDCWD, "x", O_RDONLY) = 3
dup2(3, 0) = 0
close(3) = 0
openat(AT_FDCWD, "x", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3
dup2(3, 1) = 1
close(3) = 0
execve("/usr/bin/cat", ["cat"], ...) = 0
...
|
As it turns out, there's nothing nefarious happening here: opening a named pipe blocks until the other side picks up the phone. That was a close call, but crisis averted!
And now, back to the main event.
As you may have realized, that elaborate sleight of hand was to distract you from the fact that we haven't yet done what we set out to do.
Sure, we have a never-ending stream of trash, but it's missing the desired nuance and subtlety.
The question is: How does one steal an anonymous pipe from a process and give it to another process?
When phrased like that, the solution is obvious.
We use a pair of sacrificial cat
s:
1 2 3 4 5 6 7 8 9 | $ cat | cat &
[1] 3466778
$ cat </proc/$!/fd/0 >/proc/$!/fd/0 &
[2] 3466781
$ kill %1
$ stat -c %N /proc/$!/fd/*
'/proc/3466781/fd/0' -> 'pipe:[6749206]'
'/proc/3466781/fd/1' -> 'pipe:[6749206]'
'/proc/3466781/fd/2' -> '/dev/pts/13'
|
It's alive! And, as before, it takes just a single byte to start the perpetual motion machine:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | $ cat /proc/$!/io
rchar: 5744
wchar: 0
syscr: 13
syscw: 0
read_bytes: 0
write_bytes: 0
cancelled_write_bytes: 0
$ echo >/proc/$!/fd/0; sleep 10; cat /proc/$!/io
rchar: 20283203
wchar: 20277458
syscr: 20277472
syscw: 20277458
read_bytes: 0
write_bytes: 0
cancelled_write_bytes: 0
|
Don't mind the several KB that the process has already read by the time we place something in its stdin
; that mystery is beyond human understanding.
All that remains is to tidy things up into a convenient one-liner, as is tradition.
We first note that the process on the receiving end of the pipe can be a subshell, which will have /proc/self/fd/0
pointing at the pipe.
The very first cat
in our previous attempt (the one that didn't even get so much as its PID printed out) was only there to keep its partner cat
from dying before we could start the target process, but that's no longer necessary, so we replace it with a bootstrapping echo
:
1 | $ echo | ( cat </proc/self/fd/0 >/proc/self/fd/0 )
|
Now you too can create your own infinite garbage loop, with minimal plumbing, in the comfort of your shell.
The next time someone pesters you about a useless use of cat
, you can boldly proclaim that you've seen much, much worse.
And, as always, thanks for watching! Remember to like, subscribe, and hit that bell.
(Bonus Patreon-only content.) Why require the shell to make us a pipe when we can do it ourselves? Here's a production-ready version of the above:
1 2 3 4 5 6 7 8 9 10 | #include <unistd.h>
int main() {
char cat[] = "cat" "cat" "cat" "cat";
close('c'/'a'/'t' + 'c'/'a'/'t');
close('c'/'a'+'t' / 'c'/'a'/'t');
pipe((int *)&cat[3]);
write(1, cat, 1);
execlp(cat, cat, (char *)0);
}
|
Remember, kids: shells are for chumps; all you need in life is syscalls(2)
.