← THE INDEX  ·  WRITEUP

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

Any authenticated ClickHouse user, including SELECT-only accounts, can crash the entire managed instance in one HTTP request by passing two deeply nested JSON documents to JSONMergePatch, which recurses without a depth limit.

Summary

Aiven's managed ClickHouse service is vulnerable to a denial-of-service attack that kills the server process (SIGSEGV) via a single SELECT query. Any authenticated user, including accounts with only SELECT privileges, can execute SELECT JSONMergePatch(...) with two deeply nested JSON documents and crash the entire instance. The vulnerability is in the merge_objects recursive lambda in src/Functions/jsonMergePatch.cpp: it recurses to the full document depth with no depth limit and no stack-size check. At depth 25,000 or greater, the recursive call stack exhausts the thread stack and the process dies with SIGSEGV.

The crash payload can also be stored in MergeTree table columns. Any future query that applies JSONMergePatch to stored poison data crashes the server on behalf of the querying user, not the attacker. A writer with INSERT access to a shared table can plant poison data; an analyst running a routine metadata merge query later triggers the crash.

Impact

The server is unavailable for 8 to 23 seconds per crash while Aiven's orchestration restarts the ClickHouse process. A scripted attacker that re-crashes on each recovery achieves 100% effective downtime: in testing, six crash cycles over 93 seconds produced 93 seconds of downtime. All in-flight queries are aborted on every crash and all connected clients are disconnected.

Attack surface summary: - Single SELECT, any authenticated user including SELECT-only accounts - Stored crash payload in MergeTree survives server restarts - Cross-user trigger: writer plants data, analyst's innocent query crashes the server - CREATE VIEW with the payload crashes during type inference, before the view is stored - Sustained crash loop achieves 100% effective downtime

Depth threshold observed in testing: depth 20,000 does not crash; depth 25,000 reliably crashes.

Root cause

RapidJSON is configured with kParseIterativeFlag on line 14 of jsonMergePatch.cpp, so it parses arbitrarily deep JSON documents iteratively without overflowing the stack. The parsing is safe. However, the subsequent merge_objects recursive lambda does not use an iterative approach and has no depth counter or checkStackSize() call:

// src/Functions/jsonMergePatch.cpp:70-91
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
        }
    }
};

ClickHouse's existing protections do not apply: max_parser_depth limits the SQL parser, not JSON document parsing; checkStackSize() is used throughout the query pipeline but was never added to merge_objects. Confirmed on ClickHouse 25.3.14.1.

Proof of concept

The one-line crash requires only curl. All credentials and host identifiers have been replaced with placeholders.

Disclosure and fix

Reported to Aiven through their bug bounty program. Aiven triaged this as P1 (Critical impact and/or easy difficulty). Recommended fix in src/Functions/jsonMergePatch.cpp: add a depth counter to merge_objects and throw a ClickHouse exception when it exceeds a reasonable limit:

auto merge_objects = [&](auto && self, auto && lhs, const auto & rhs,
                          int depth = 0) -> void
{
    if (depth > 1000)
        throw Exception(ErrorCodes::TOO_DEEP_RECURSION,
                        "JSONMergePatch: document nesting exceeds limit");
    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, depth + 1);
    }
};

Alternatively, checkStackSize() can be called at the top of the lambda body, which is the pattern already used throughout the ClickHouse query pipeline.