Re-using socket FDs vs reverse shell

ctf

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