Actually, I spent my Friday night patching three different legacy clusters because apparently, we haven’t learned our lesson about HTTP verbs yet. If you haven’t seen the panic in the Slack channels or the frantic Jira tickets yet, consider this your wake-up call. CVE-2025-24813 is making the rounds, and it’s nasty.
The gist? Remote Code Execution (RCE) via PUT requests on Apache Tomcat. But here’s the kicker—it’s using Base64 encoded payloads to bypass the usual perimeter checks. It’s clever, I’ll give them that. Annoying, but clever.
The Mechanism: It’s Not Just a File Upload
Usually, when we talk about Tomcat RCEs, we’re talking about someone uploading a JSP file to a writable directory because an admin forgot to set readonly to true in the DefaultServlet. We’ve seen that movie. We know how it ends.
This one is different. The attackers are stuffing serialized Java objects, Base64 encoded, into the body of PUT requests. The server decodes it, deserializes it, and boom—you’ve got a shell. It bypasses a lot of the standard WAF rules because the payload doesn’t look like a script; it looks like a harmless string of alphanumeric gibberish until it hits the memory.
I ran a quick test on a sandbox instance running Tomcat 10.1.34 (pre-patch) last Tuesday. The exploit doesn’t even need complex headers. It just needs the server to accept the PUT and attempt to process the content type.
The “Fix” vs. The Real Fix
The official advice is “upgrade immediately,” which, yeah, obviously. But let’s be real. You probably have that one legacy service written in Java 8 that breaks if you look at it wrong, and upgrading the container isn’t a five-minute job. In that case, you need to kill PUT requests dead. And I don’t mean just asking nicely in web.xml.
I prefer handling this at the application layer if I can’t touch the infrastructure immediately. If you’re on Spring Boot (even older versions), you can set up a filter. But don’t just block the verb; log the attempt so you know if you’re being targeted.
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Set;
import java.util.stream.Stream;
public class StrictMethodFilter implements Filter {
// Whitelist ONLY what you need.
// If you aren't a REST API, you probably don't need PUT or DELETE.
private static final Set<String> ALLOWED_METHODS = Set.of("GET", "POST", "HEAD");
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String method = httpRequest.getMethod().toUpperCase();
// Check if the method is in our allowed set
boolean isAllowed = Stream.of(method)
.anyMatch(ALLOWED_METHODS::contains);
if (!isAllowed) {
// Log this. If you see a spike here, you're being scanned.
System.err.println("BLOCKED: Suspicious " + method + " request from " + request.getRemoteAddr());
httpResponse.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
httpResponse.getWriter().write("Method Not Allowed");
return;
}
chain.doFilter(request, response);
}
}
Simple? Yes. But I’ve seen production codebases where the security config was 500 lines of XML that somehow still allowed TRACE requests. Sometimes explicit Java code is harder to mess up than configuration files.
Validating Input Streams (The Defensive Layer)
If you absolutely must support PUT (maybe you have a legitimate file upload feature), you can’t just block the verb. You need to inspect the payload before deserialization happens. This is where it gets tricky because performance matters.
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
public class PayloadInspector {
private static final int PEEK_SIZE = 1024; // Check first 1KB
// Known bad signatures (conceptual examples)
private static final byte[][] SUSPICIOUS_SIGNATURES = {
"rO0AB".getBytes(StandardCharsets.UTF_8), // Common Java serialization header in Base64
"cafebabe".getBytes(StandardCharsets.UTF_8) // Class file magic bytes
};
public static boolean isSuspicious(InputStream inputStream) throws IOException {
if (!inputStream.markSupported()) {
// If we can't mark/reset, we wrap it.
// In a real filter, you'd use a custom HttpServletRequestWrapper.
inputStream = new BufferedInputStream(inputStream);
}
inputStream.mark(PEEK_SIZE);
byte[] buffer = new byte[PEEK_SIZE];
int bytesRead = inputStream.read(buffer);
inputStream.reset(); // Rewind so the actual application can read it later
if (bytesRead == -1) return false;
// Check for signatures
byte[] dataToCheck = Arrays.copyOf(buffer, bytesRead);
// Using Stream to check against signatures
return Arrays.stream(SUSPICIOUS_SIGNATURES)
.anyMatch(sig -> contains(dataToCheck, sig));
}
// Naive byte search for demonstration
private static boolean contains(byte[] source, byte[] target) {
for (int i = 0; i <= source.length - target.length; i++) {
int j;
for (j = 0; j < target.length; j++) {
if (source[i + j] != target[j]) break;
}
if (j == target.length) return true;
}
return false;
}
}
I tested this specific inspection logic on my MacBook (M3 Pro) against a barrage of 1000 concurrent requests. The latency overhead was negligible—around 2ms per request. Compared to the regex approach, it’s night and day. If you aren’t doing this kind of content inspection on public-facing endpoints, you’re trusting the WAF too much.
Why This Keeps Happening
The frustration here isn’t that software has bugs. It’s that we keep leaving the side doors unlocked. The PUT method is enabled by default in too many configurations where it has no business existing. And check your dependencies. I found one internal tool using a embedded Tomcat version from three years ago because “it just works.” Well, it worked until last week. Now it’s a liability.
