Re-using socket FDs vs reverse shell

There is a simple trick Seb and me used in a few CTFs which it seems some CTFS players are not aware of – at least that’s what it looks like when looking at writeups. It’s a simple way of getting a reverse shell on challenges which use a forking server.

Most of the time there are two kinds of pwnables: Binaries which read and write to stdin/stdout and are piped to network sockets with tools like socat. And then there are forking servers, listening and accepting connections by themselves. For simple “piped” challenges, to get a shell a system("/bin/sh") is often all you need. But for forking servers, you often see long ROP chains in writeups creating sockets and using dup2() to bind sockets with a newly spawned shell.

But there is a simpler way. As you may know, all opened files (and sockets) are accessible as “files” under Linux because they are just file descriptors. To access a process’ FDs, you can have a look at the /proc/self/fd endpoint:

$ ls -alF /proc/self/fd/
total 0
dr-x------ 2 robin robin  0 Jul  5 09:53 ./
dr-xr-xr-x 9 robin robin  0 Jul  5 09:53 ../
lrwx------ 1 robin robin 64 Jul  5 09:53 0 -> /dev/pts/3
lrwx------ 1 robin robin 64 Jul  5 09:53 1 -> /dev/pts/3
lrwx------ 1 robin robin 64 Jul  5 09:53 2 -> /dev/pts/3
lr-x------ 1 robin robin 64 Jul  5 09:53 3 -> /proc/27288/fd/

As you can see, I have access to three FDs: 0 (associated with STDIN), 1 (STDOUT), 2 (STDERR) and 3 (because of the inner workings of ls).

And to no surprise, that’s also where your already established connection is accessible from, because a network socket is also associated with a FD. If you know the right FD, you can directly write and read from the already established network connection. You can use strace/ltrace locally and see which FD is used (the numbers are the same for each process):

$ ltrace nc -nlvp 4444
signal(SIGPIPE, 0x1)                                          = 0
getopt(3, 0x7ffe922ddae8, "46bCDdFhI:i:klM:m:NnO:P:p:q:rSs:"...) = 110
getopt(3, 0x7ffe922ddae8, "46bCDdFhI:i:klM:m:NnO:P:p:q:rSs:"...) = 108
getopt(3, 0x7ffe922ddae8, "46bCDdFhI:i:klM:m:NnO:P:p:q:rSs:"...) = 118
getopt(3, 0x7ffe922ddae8, "46bCDdFhI:i:klM:m:NnO:P:p:q:rSs:"...) = 112
getopt(3, 0x7ffe922ddae8, "46bCDdFhI:i:klM:m:NnO:P:p:q:rSs:"...) = -1
getaddrinfo(nil, "4444", 0x7ffe922d9860, 0x7ffe922d9810)      = 0
socket(2, 1, 6)                                               = 3 // [1]
setsockopt(3, 1, 2, 0x7ffe922d980c)                           = 0
setsockopt(3, 1, 15, 0x7ffe922d980c)                          = 0
bind(3, 0x55f9dafaf290, 16, 0)                                = 0
listen(3, 1, 16, 0x7fb2b8e32877)                              = 0 // [2]
freeaddrinfo(0x55f9dafaf260)                                  = <void>
__fprintf_chk(0x7fb2b90fc680, 1, 0x55f9d8d9faf8, 0x55f9d8d9f057Listening on [0.0.0.0] (family 0, port 4444)
) = 45
accept4(3, 0x7ffe922d9920, 0x7ffe922d98b4, 2048)              = 4 // [3]
getnameinfo(0x7ffe922d9920, 16, "", 1025, "", 32, 3)          = 0
__fprintf_chk(0x7fb2b90fc680, 1, 0x55f9d8d9f300, 0x7ffe922d9460Connection from 127.0.0.1 46348 received!
) = 42
poll(0x7ffe922d1820, 4, 0xffffffff, 0

At [1] and [2] you can see how the socket (FD=3) is created and listened on. This is where new connections to port 4444 “arrive” to. At (3) a connection is accepted (and thus a new socket created). The return value of this function is the new socket FD=4 which can be used to read from and write to the connection.

So to sum this up, finding the correct FD numbers and redirecting your spawned /bin/bash might save you some long ROPing:

system("/bin/sh >&4 <&4")

This technique can of course also be used for other scenarios, for example when restrictive firewalls prevent additional connections in and out.

See Also

Title Tags Link
Solving ROP primer level2 Read →
CVE-2016-5195 (Dirtycow) Local Root PoC Read →
Webanalyze, utility to uncover technologies used on websites. Read →
Announcing pentrack.io Read →
Be careful when going client only (Firebase) Read →