[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[FD] Continued Apache Struts2 JSON Plugin Insecure Deserialisation
- To: "fulldisclosure@xxxxxxxxxxxx" <fulldisclosure@xxxxxxxxxxxx>
- Subject: [FD] Continued Apache Struts2 JSON Plugin Insecure Deserialisation
- From: Daniel Owens via Fulldisclosure <fulldisclosure@xxxxxxxxxxxx>
- Date: Tue, 27 Oct 2026 16:59:24 +0000
Summary
Thus far we have written extensively about the rampant number of insecure
JavaScript Object Notation (JSON) deserialisers available today, like in
"Struts2 and Related Framework Array/Collection DoS" (26 October 2025) and
"JSON Deserialiser Unconstrained Resource Consumption Quick Overview" (07 March
2026), along with a naïve proof of concept script that demonstrated what we
were talking about -- "JSON Deserialiser Unconstrained Resource Consumption
Proof of Concept" (29 August 2026). Today we will continue analyse the Apache
Struts2 JSON plugin, which serves as a clear case study in how shallow input
limits fail to protect against underlying architectural memory amplification
bugs.
As previously mentioned, this vulnerability has existed and been
known/exploited by members of this team across numerous deserialisers for
decades. JSON and alternative format parsers (such as CBOR or multi-part
forms) are near-universally susceptible to this issue. Some formats allow what
we call "skip counting", which Apache Struts' base deserialiser did for many
years, but many simply do not properly control for memory growth as the parser
deserialises the data. These flaws can be used to cause denial of service
conditions, bypass authentication, inject unintended data into the information
system, and so much more based on what you do, how you do it, and what your
goals are. This work will continue to focus on the DOS aspect to keep things
simple and because it allows us to discuss the issue across systems, rather
than in specific contexts and use cases.
Evaluation of the WW-5618 and S2-072 Hardening Controls
First, let's walk through the timeline. In October 2025, we reported the
"greater issue" and, on 16 January 2026, we, via VulnCheck, attempted to
properly disclose the vulnerability to Apache. Apache classified it as "not a
vulnerability" and, instead, took it as an opportunity to "harden" the code by
putting in a few basic checks via PR #1625
(https://github.com/apache/struts/pull/1625) and PR #1626
(https://github.com/apache/struts/pull/1626) (tracked collectively as WW-5618,
which shipped as version 6.9.0 on 27 April 2026 and version 7.2.1 on 15 June
2026). In short, on 17 March 2026 (after our second writing on the matter),
they added in:
* struts.json.maxElements (default: 10,000)
* struts.json.maxDepth (default: 64)
* struts.json.maxLength (default: 2,097,152)
* struts.json.maxStringLength (default: 262,144)
* struts.json.maxKeyLength (default: 512)
In addition to each of those, the development team added input validation
checks inside of `StrutsJSONreader` (`JSONReader` was, effectively, renamed
thereto as part of the aforementioned pulls -- it is now simply an interface)
that
* check the String length as it is built up (inside of `string(char)`)
* check the String length of a Map key (inside of `object()`)
* check the array depth when deserialising a collection, array, or similar
object that is represented in JSON as a JSON array (inside of `array()`)
* check the number of elements in the collection, array, or similar object
that is represented in JSON as a JSON array (inside of `array()`)
Later, Apache issues S2-072 in a further attempt to improve the input
validation process. The implementation, however, fails to close the gap, due
in part to the boundary checks being applied in isolation to individual
collection instances during recursion, rather than thereacross. Today we'll
look at the `array()` changes and how they have, effectively, solved little,
leaving information systems vulnerable to the same vulnerability that we
continue to discuss.
The Code
The following is taken directly from the official Apache Struts repository --
https://raw.githubusercontent.com/apache/struts/refs/heads/main/plugins/json/src/main/java/org/apache/struts2/json/StrutsJSONReader.java
(27 October 2026):
protected List<Object> array() throws JSONException {
if (this.depth >= this.maxDepth) {
throw new JSONException("JSON array nesting exceeds maximum allowed
depth ("
+ this.maxDepth + "). Use " + JSONConstants.JSON_MAX_DEPTH
+ " to increase the limit.");
}
this.depth++;
try {
List<Object> ret = new ArrayList<>();
Object value = this.read();
while (this.token != ARRAY_END) {
if (ret.size() >= this.maxElements) {
throw new JSONException("JSON array exceeds maximum allowed
elements ("
+ this.maxElements + "). Use " +
JSONConstants.JSON_MAX_ELEMENTS + " to increase the limit.");
}
ret.add(value);
Object read = this.read();
if (read == COMMA) {
value = this.read();
} else if (read != ARRAY_END) {
throw buildInvalidInputException();
}
}
return ret;
} finally {
this.depth--;
}
}
The Process
First, to understand where they continue to go wrong, we have to understand how
they operate:
1. Input traverses to StrutsJSONReader via a call to the public
"read(String)" method.
2. It sets up an iterator, starts it on the first character, sets the depth
to `0`, and then runs `read()`.
3. `read()`coordinates tokenisation and, upon seeing `[` outside of quotes
(single or double), passes tokenisation to `array()`, which is the code above.
4. `array()` checks its depth and, if too great, it throws an exception,
otherwise it continues.
5. `array()` increments `depth`.
6. `array()` creates a new `ArrayList` to store the elements.
7. `array()` tokenises, via `read()` calls, until either its array has too
many elements (at which point an exception is thrown) or the array is ended
(either via `]`, which is positive, or rude termination of the iterator).
8. `array()` decrements `depth` and returns the array to the caller.
The Deep Defect: Tracking Flaws & Heap Amplification
The problem with this process is that it protects only against a very small set
of inputs. For example, the obvious array-of-arrays attack currently means
that you can have 10,000^64 (or 1 x 10^256) elements via an array-of-arrays
structure because the limit checks are scoped per-instance of a single array
context inside the recursion.
Understanding that the default maximum length is two megabytes
(struts.json.maxLength), and assuming that a victim does not adjust that value,
we are talking about roughly 690,000 elements if the JSON is merely an
array-of-arrays of empty strings, or 419,000 elements if the JSON is an
array-of-arrays of nulls or two-character Strings. With those numbers, you
really only need a nesting depth of 2 to build a structure holding 100,000,000
elements - which is 10,000 * 10,000 and far more than fits the payload bill.
But that's not the full story. We know that a pointer -- regardless of to what
it points -- takes 4 bytes if your maximum heap size is under 32GB (with
compressed oops enabled) and 8 bytes on a 64-bit JVM if the heap size is
greater than 32GB. This means that an array of nulls incurs the cost of the
ArrayList wrapper plus the cost of each pointer. The wrapper itself requires
roughly 40 bytes plus its backing array, which accounts for an additional 32-40
bytes plus (elements * pointerSize).
If, instead, we use two-byte strings, we have the cost of the ArrayList, plus
the cost of each pointer, plus the cost of each String. Java interns certain
literals, so duplicates might be interned, but during parsing, this is not
likely. Strings carry substantial object overhead, typically consuming 48
bytes on the heap on a 64-bit machine. Assuming none of the Strings are
interned, a single 10,000 element array of two-character strings consumes
roughly 520KB of RAM - at the transmission cost of just 50,001 characters
(~50KB). At a 2MB maximum payload restriction, an attacker can cleanly pack
roughly 40 of these structures, yielding 20.8MB of RAM for a single collection
layer.
That, however, is only after the ArrayList is fully constructed. ArrayLists
are dynamic -- they start at a capacity of 10 by default and grow automatically
by 50% each time that they need to expand. For an array scaling to 10,000
elements, the capacity progression is simply:
10 -> 15 -> 22 -> 33 -> 49 -> 73 -> 109 -> 163 -> 244 -> 366 -> 549 -> 823 ->
1234 -> 1851 -> 2776 -> 4164 -> 6246 -> 9369 -> 14053
In other words, that single ArrayList backing array will grow 18 times with
each step requiring a fresh array allocation, a memory copy from the old to the
new (where both sit in memory simultaneously), and an unpredictable, eventual
deallocation of the old array by the Garbage Collector.
Moreover, the final array won't stop at 10,000 elements -- its allocated size
will remain at 14,053 internal spaces. The remaining 4,053 slots will be empty
pointers that still consume heap space (~56KB total for the backing array, of
which ~16KB is completely wasted heap space). If transient backing arrays
exist concurrently during expansion, that scales to ~168.6KB of RAM just for a
single array's resizing churn.
When assuming 40 concurrent attackers running an automated script like Commas
of D00m, this transient cost translates to ~6.76MB just for array allocations,
completely independent of the data payload. Balancing the number of arrays
against their node weights allows an attacker to easily optimise this footprint
-- attackers can fill arrays to 9370 elements, forcing the largest wasted
space, and then carefully pick the payload to maximise the consumed space from
the input. For example, boxed objects like Integer are exceptionally heavy
computationally and structurally, and because native Java Collections do not
accept primitives, they force object header overhead across every entry, like
Strings, and so can be especially good options, particularly if mixed with
Strings and other such devices. Adding up these components, a single automated
payload sequence can easily consume:
(20.8MB per request * 40 concurrent requests) + 6.76MB = ~838.8MB
Standard production deployments of Apache Tomcat running on normal VMs default
to a maximum heap space (-Xmx) equivalent to 25% of available physical RAM.
For a standard 4GB host allocation, Tomcat's total available heap size sits at
exactly 1,024MB, so a minor concurrent attack consumes over 80% of the entire
application heap, with an addition 257.3MB consumed by total parallel
allocation, completely exhausting available memory resources and triggering
immediate java.lang.OutOfMemoryError failures that crash the service before
garbage collection can clear the transient data.
Taking Things Seriously
Understanding how the deserialiser operates, how the parser operates, which to
attack, and when to attack each allows discovering (and acting upon) a wide
array of vulnerabilities and attack vectors, with devastating outcomes to the
victim. Organisations need to understand how their system works, how
third-party libraries operate, what your use thereof and reliance thereupon
means to the information system and its data, and understand the risk appetite
of your organisation, your users, and any third-parties upon which you rely.
But it all goes to another point: attackers don't really need true concurrency,
either. A stepped attack, which is much less likely to trigger DoS
protections, can achieve the same goal, as can simply adjusting the rate and
body, optimising what you send and how you send it. In other words, developers
(and security professionals) need to take this seriously because it's not
simply a DoS attack and it's not simply "flood the victim with data".
Truthfully, it's, like so many other issues, all about your system, how it
interacts with and relies on the code, and how imaginative you and your
opponents are.
The vulnerabilities remaining after the release of WW-5618 and S2-072
underscore a common trap in vulnerability remediation -- applying primitive
caps to structural endpoints without altering the recursive allocation
architecture. Without a defence-in-depth approach that balances incoming
stream capacity against object initialisation overhead, the framework continues
to face systemic exposure. Realistically, it should be noted that those
utilising this or any other such library must understand these attacks and the
limitations incumbent upon the third-party -- they cannot stress-test your
environment, perform siege and bombardment testing to know what limitations can
and should be in place, what is and is not acceptable, and do not know what
actions/code are "heavy"/critical ("heavy" meaning CPU/RAM utilisation, wall
clock, calls to I/O, etc.) and could be leveraged to compound the issue --
although it is still vital that such key code as deserialisers (and
serialisers) be written such that they protect themselves.
_______________________________________________
Sent through the Full Disclosure mailing list
https://nmap.org/mailman/listinfo/fulldisclosure
Web Archives & RSS: https://seclists.org/fulldisclosure/