[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[FD] Continued Apache Struts2 JSON Plugin Insecure Deserialisation



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/