Continued Apache Struts2 JSON Plugin Insecure Deserialisation
Full Disclosuremailing list archivesFrom: Daniel Owens via Fulldisclosure <fulld 2026-10-29 04:11:43 Author: seclists.org(查看原文) 阅读量:11 收藏

fulldisclosure logo

Full Disclosure mailing list archives


From: Daniel Owens via Fulldisclosure <fulldisclosure () seclists org>
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/


Current thread:

  • Continued Apache Struts2 JSON Plugin Insecure Deserialisation Daniel Owens via Fulldisclosure (Oct 28)

文章来源: https://seclists.org/fulldisclosure/2026/Oct/49
如有侵权请联系:admin#unsafe.sh