Linux Rootkits Part 8: Hiding Open Ports
Most userspace system tools just parse and manipulate data from one or more files and present them nicely to STDOUT
. We’ve already seen this with processes (see Part 7), but this time we’re going to do the same thing with open ports. By the end, we’ll be able to open a listener on port 8080 (any port would do) without it showing up in things like netstat
.
Assuming that a file is being read from, we need to try to find out which one. By looking at the output of strace -e openat netstat -tunelp
, we can see that /proc/net/tcp
and /proc/net/tcp6
are both read by netstat
. A sensible guess is that tcp
is going to be for IPv4 connections, and tcp6
is for IPv6 ones. This makes /proc/net/tcp
our target because, if we can control it, then we can control the output produced by netstat
(and others like it).
If you read my container escape writeup, then you’ll know that files under /proc
aren’t really files, but are defined by functions within the kernel that are assigned to different IO operations (open/close/read/write/etc). If we want to control the “contents” of /proc/net/tcp
, then we need to find the function that is called when a process tries to read it. If we grep
for the top line of the output of /proc/net/tcp
, we get a hit: net/ipv4/tcp_ipv4.c
, specifically the function tcp4_seq_show()
. Finally, we need to check that this function is exported - otherwise we can’t hook it!
$ sudo cat /proc/kallsyms | grep tcp4_seq_show
ffffffffbad03500 t tcp4_seq_show
All good! We’ll be able to hook tcp4_seq_show()
using our normal Ftrace method (see Part 2) without any issues.
The eagle-eyed reader will have spotted that
tcp4_seq_show()
can’t possibly be the function called bysys_read()
for/proc/net/tcp
- it’s arguments are all wrong! This is true, but whatever function is really called, ends up usingtcp4_seq_show()
to fill the buffer that’s handed back to the user anyway.
Network Socket Structs⌗
The first thing we see when we look at tcp4_seq_show()
is:
static int tcp4_seq_show(struct seq_file *seq, void *v)
{
struct tcp_iter_stat *st;
struct sock *sk = v;
That argument v
is casted to a new variable sk
as a sock
struct. We’d better take a look at this struct - it can be found in include/net/sock.h
. The source code tells us that this particular struct is the “network layer representation of sockets” - this sounds like what we’re after! There’s bound to be a field in this struct that contains the listening port.
Okay, this struct has a lot of fields. The first one is another struct, this time sock_common
, followed by a bunch of #define
s into it.
struct sock {
struct sock_common __sk_common;
#define sk_node __sk_common.skc_node
/* etc */
};
This is kinda like cheating - it lets us refer directly to sock->sk_node
without having to go through sock_common
first. A little odd, but whatever - let’s take a look at sock_common
in the same file.
struct sock_common {
/* redacted for clarity */
/* skc_dport && skc_num must be grouped as well */
union {
__portpair skc_portpair;
struct {
__be16 skc_dport;
__u16 skc_num;
};
};
/* redacted for clarity */
};
Aha! The skc_dport
and skc_num
fields are union
ed together into __portpair
. Looks like we’ve found our target! Looking back up at the definition of the sock
struct, we see on line 365:
#define sk_num __sk_common.skc_num
This means that we should be able to just dereference the sk_num
field of a pointer to a sock
struct and get the local port that’s being listened on! Earlier, we saw that tcp4_seq_show()
casts one of it’s arguments to a sock
struct and calls it sk
(line 2603). In particular, note that it looks like a single socket is passed at a time to tcp4_seq_show()
, so we should expect it to be called many times as it goes through all the open connections.
To help cement this, we can take a look at the “contents” of /proc/net/tcp
again:
$ cat /proc/net/tcp
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: 00000000:E115 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 864656 1 00000000f6208100 99 0 0 10 0
# etc
The first entry under local_address
is 00000000:E115
. Anyone who’s had the pleasure of writing a reverse shell in assembly before will probably recognise that this is an IP/port combo in hex. Indeed, 0xe115 = 57621
, and the output of netstat
confirms that a process is listening on 0.0.0.0:57621
(in this case, the culprit was Spotify).
When we write our hook, we will cast v
to a sock
struct too (calling it sk
for simplicity) and just dereference sk->sk_num
to get the listening port number for each entry!
Writing the hook⌗
Believe it or not, understanding all of that was the hard part! The actual hook itself is pretty simple, as you’ll soon see. The point of going through all of the above was to illustrate the research into the kernel source code that is required to know how to build these modules. That is usually the longest part of the process!
So, we know that tcp4_seq_show()
is going to get called repeatedly when we read from /proc/net/tcp
, and that a pointer to a sock
struct is passed to it in the second argument. All our hook needs to do is check if the listening port equals the one we want to hide, return 0
if it does, or return the real tcp4_seq_show()
if it doesn’t. The only caveat is that sometimes v
isn’t initialized (like if we’re just printing the top line of the table). In this case, v = 0x1
, so we need to check that this is not the case before trying to dereference it.
/*
* Usual function declaration for the real tcp4_seq_show
*/
static asmlinkage long (*orig_tcp4_seq_show)(struct seq_file *seq, void *v);
/*
* Function hook for tcp4_seq_show()
*/
static asmlinkage long hook_tcp4_seq_show(struct seq_file *seq, void *v)
{
struct sock *sk = v;
/*
* Check if sk_num is 8080
* (0x1f90 = 8080 in hex)
* If sk doesn't point to anything, then it points to 0x1
*/
if (sk != 0x1 && sk->sk_num == 0x1f90)
return 0;
/*
* Otherwise, just return with the real tcp4_seq_show()
*/
return orig_tcp4_seq_show(seq, v);
}
Note that, just like in Part 4, we don’t have to worry about multiple versions of the hook involving
pt_regs
becausetcp4_seq_show()
is not a syscall!
Pretty simple, right? This is probably the shortest function hook yet, but it took me longer than any of the others to write! There were many rabbit-holes that I got lost down before I settled on the method outlined above.
Putting it all together⌗
Now that we’ve written the hook, go ahead and finish up all the Ftrace parts (Part 2), or grab the source from the repo.
Once you’ve built the module, go ahead and load it with insmod
. In another terminal, create a netcat listener on port 8080 with nc -lvnp 8080
. Now run the usual netstat -tunelp
and you’ll see that port 8080 doesn’t show up! Unloading the rootkit with rmmod
and trying netstat again will show you that it’s really there afterall.
Note that we aren’t actually touching the internal socket table within the kernel, so the functionality of the connection is totally unimpaired!