Zion Boggan
repos/Security Portfolio/security-research-notebook/qbft-hasbadproposal-consensus-stall/index.html
zionboggan.com ↗
473 lines · html
History for this file →
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>QBFT HasBadProposal Quorum Inconsistency, Consensus Liveness Violation | Zion Boggan</title>
5
<meta name="description" content="QBFT&amp;#x27;s `HasBadProposal` check is symmetric across the round, one prepared bad proposal halts the round for every validator.">
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/qbft-hasbadproposal-consensus-stall/">
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="QBFT HasBadProposal Quorum Inconsistency - Consensus Liveness Violation | Zion Boggan">
184
<meta property="og:description" content="QBFT&amp;#x27;s `HasBadProposal` check is symmetric across the round, one prepared bad proposal halts the round for every validator.">
185
<meta property="og:url" content="https://zionboggan.com/security-research-notebook/qbft-hasbadproposal-consensus-stall/">
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="QBFT HasBadProposal Quorum Inconsistency - Consensus Liveness Violation | Zion Boggan">
189
<meta name="twitter:description" content="QBFT&amp;#x27;s `HasBadProposal` check is symmetric across the round, one prepared bad proposal halts the round for every validator.">
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":"QBFT HasBadProposal Quorum Inconsistency - Consensus Liveness Violation","description":"QBFT&amp;#x27;s `HasBadProposal` check is symmetric across the round, one prepared bad proposal halts the round for every validator.","url":"https://zionboggan.com/security-research-notebook/qbft-hasbadproposal-consensus-stall/","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/">&larr; Research notebook</a>
199
  <div class="kicker">Consensus stall</div>
200
  <h1>QBFT HasBadProposal Quorum Inconsistency, Consensus Liveness Violation</h1>
201
</div></header>
202
<section><div class="wrap"><div class="content">
203
<h2>Severity: P1 (Criteria B: &ldquo;shutting down/disrupting block production&rdquo;)</h2>
204
<hr />
205
<h2>Summary</h2>
206
<p>A single Byzantine validator can permanently stall block production on the Electroneum
207
Smart Chain by exploiting an inconsistency between how <code>roundChangeSet.Add()</code> and
208
<code>isJustified()</code> handle the <code>HasBadProposal</code> flag. The former accepts a SINGLE message&rsquo;s
209
flag, while the latter requires a QUORUM. This allows a single malicious validator to
210
poison the proposer&rsquo;s prepared-block cache, causing every subsequent proposal to fail
211
justification validation, resulting in an indefinite consensus stall.</p>
212
<p>This violates IBFT/QBFT&rsquo;s fundamental liveness guarantee: the protocol should maintain
213
liveness with up to f = floor((n-1)/3) Byzantine validators.</p>
214
<hr />
215
<h2>Root Cause</h2>
216
<h3>The Inconsistency</h3>
217
<p><strong>File: <code>consensus/istanbul/core/justification.go</code></strong></p>
218
<p>The <code>isJustified()</code> function (called before broadcasting a PRE-PREPARE) computes
219
<code>hasBadProposal</code> from a QUORUM count:</p>
220
<pre><code class="language-go">// justification.go - lines 37-51
221
hasBadProposalCount := 0
222
for _, rcm := range roundChangeMessages {
223
    if rcm.HasBadProposal {
224
        // ... dedup by source
225
        hasBadProposalCount++
226
    }
227
}
228
hasBadProposal := hasBadProposalCount &gt;= uint(quorumSize)  // REQUIRES QUORUM
229
</code></pre>
230
<p>But <code>roundChangeSet.Add()</code> (called when each ROUND-CHANGE message arrives) uses the
231
SINGLE message&rsquo;s <code>HasBadProposal</code> flag:</p>
232
<pre><code class="language-go">// roundchange.go - roundChangeSet.Add()
233
roundChange := msg.(*qbfttypes.RoundChange)
234
if hasMatchingRoundChangeAndPrepares(roundChange, prepareMessages, quorumSize,
235
    roundChange.HasBadProposal,  // &lt;-- SINGLE MESSAGE'S FLAG, NOT QUORUM
236
    rcs.validatorSet) == nil {
237
    rcs.highestPreparedRound[round] = preparedRound
238
    rcs.highestPreparedBlock[round] = preparedBlock  // POISONED
239
    rcs.prepareMessages[round] = prepareMessages
240
}
241
</code></pre>
242
<h3>What <code>HasBadProposal=true</code> Bypasses</h3>
243
<p>In <code>hasMatchingRoundChangeAndPrepares()</code> (justification.go, lines 175-182):</p>
244
<pre><code class="language-go">for _, p := range prepareMessages {
245
    if p.Digest != roundChange.PreparedDigest &amp;&amp; !hasBadProposal {
246
        return errors.New(&quot;prepared message digest does not match...&quot;)
247
    }
248
}
249
</code></pre>
250
<p>When <code>hasBadProposal=true</code>, PREPARE messages for block A are accepted as justification
251
for a ROUND-CHANGE claiming block B was prepared. The round check still applies (PREPARE
252
round must match RC&rsquo;s PreparedRound), but the digest/block mismatch is ignored.</p>
253
<hr />
254
<h2>Exploit Chain</h2>
255
<h3>Prerequisites</h3>
256
<ul>
257
<li>Attacker controls ONE validator key (within IBFT&rsquo;s Byzantine tolerance)</li>
258
<li>Normal block production is occurring</li>
259
</ul>
260
<h3>Step-by-Step</h3>
261
<p><strong>Phase 1, Capture PREPARE Messages</strong></p>
262
<ol>
263
<li>Sequence S, Round 0: The proposer proposes block A.</li>
264
<li>Validators reach the PREPARED state, they broadcast PREPARE messages for
265
   (sequence=S, round=0, digest=hash(A)).</li>
266
<li>The attacker (a validator) captures these PREPARE messages from the P2P gossip.
267
   These messages are signed by legitimate validators and are freely available to any peer.</li>
268
<li>If COMMIT quorum is NOT reached (timeout, network delay, or attacker withholds their
269
   COMMIT), a round change begins.</li>
270
</ol>
271
<p><strong>Phase 2, Poison the Round Change Set</strong></p>
272
<ol start="5">
273
<li>For round 1, the attacker crafts a ROUND-CHANGE message:
274
   <code>Round:          1
275
   PreparedRound:  0        (matches the captured PREPAREs)
276
   PreparedBlock:  block B  (ATTACKER'S ARBITRARY BLOCK)
277
   PreparedDigest: hash(B)  (does NOT match the PREPAREs' digest)
278
   HasBadProposal: true     (bypasses digest check in Add())
279
   Justification:  [captured PREPARE messages for block A, round 0]</code></li>
280
<li>
281
<p>The attacker signs this RC with their validator key and broadcasts it.</p>
282
</li>
283
<li>
284
<p>When any validator processes this RC in <code>handleRoundChange()</code>, it calls
285
   <code>roundChangeSet.Add()</code>:, <code>preparedRound(0) != nil</code> → enters the block, <code>highestPreparedRound[1] == nil</code> → condition met, <code>hasMatchingRoundChangeAndPrepares(rc, prepares, quorum, true, valSet)</code>:</p>
286
<ul>
287
<li>PREPARE.Round(0) == RC.PreparedRound(0) → PASS</li>
288
<li>PREPARE.Digest(hash(A)) != RC.PreparedDigest(hash(B)) &amp;&amp; !true → PASS (bypassed)</li>
289
<li>Quorum of distinct validator signatures → PASS</li>
290
<li><strong>Result: <code>highestPreparedBlock[1] = block B</code> (POISONED)</strong></li>
291
</ul>
292
</li>
293
</ol>
294
<p><strong>Phase 3, Consensus Stall</strong></p>
295
<ol start="8">
296
<li>
297
<p>Legitimate validators also send their RCs for round 1 (with preparedRound=nil,
298
   since they didn&rsquo;t complete a prepare phase after round 0&rsquo;s stall, or with
299
   preparedRound=0 and the correct block A).</p>
300
</li>
301
<li>
302
<p>If a legitimate RC has preparedRound=0 and correct block A, it would try to set
303
   highestPreparedBlock. But:, <code>preparedRound(0).Cmp(highestPreparedRound[1](0)) &gt; 0</code> → FALSE (0 is NOT &gt; 0), The legitimate entry does NOT overwrite the poisoned one.</p>
304
</li>
305
<li>
306
<p>When quorum of RCs is reached for round 1, and the proposer for round 1 executes:
307
    ```go
308
    // roundchange.go, handleRoundChange()
309
    _, proposal := c.highestPrepared(currentRound)  // Returns block B (poisoned)
310
    // HasBadProposal(hash(B)) returns false (block B is unknown)
311
    // proposal = block B</p>
312
<p>// isJustified(blockB, rcPayloads, preparesForBlockA, quorum, valSet)
313
// In isJustified: hasBadProposalCount &lt; quorum (only 1 RC has flag)
314
// Digest check: hash(B) != PREPARE.digest(hash(A)) &amp;&amp; !false → FAILS
315
// Result: &ldquo;prepared messages do not match proposal&rdquo; error
316
```</p>
317
</li>
318
<li>
319
<p>The proposal is BLOCKED. No block is proposed for round 1. Round times out.</p>
320
</li>
321
</ol>
322
<p><strong>Phase 4, Persistent Stall</strong></p>
323
<ol start="12">
324
<li>
325
<p><code>startNewRound(2)</code> is called. <code>ClearLowerThan(2)</code> clears rounds 0 and 1.
326
    Round 2 starts fresh.</p>
327
</li>
328
<li>
329
<p>The attacker re-sends a poisoned RC for round 2:</p>
330
<ul>
331
<li>Same captured PREPAREs from round 0 (they&rsquo;re still valid)</li>
332
<li>PreparedRound=0, PreparedBlock=C (another arbitrary block), HasBadProposal=true</li>
333
</ul>
334
</li>
335
<li>
336
<p>In <code>roundChangeSet.Add()</code> for round 2:</p>
337
<ul>
338
<li><code>highestPreparedRound[2] == nil</code> → condition met</li>
339
<li>poisoning succeeds again</li>
340
</ul>
341
</li>
342
<li>
343
<p>This repeats indefinitely. The attacker can stall EVERY round at this sequence
344
    using the SAME captured PREPARE messages from the original round 0.</p>
345
</li>
346
<li>
347
<p>Since no block is ever committed, the sequence never advances, and the
348
    roundChangeSet is never fully reset (only <code>round == 0</code> triggers <code>newRoundChangeSet</code>).</p>
349
</li>
350
</ol>
351
<hr />
352
<h2>Impact</h2>
353
<ul>
354
<li><strong>Permanent consensus stall</strong>: Block production halts indefinitely at a specific
355
  block height. No new blocks, no transaction processing.</li>
356
<li><strong>Single Byzantine validator</strong>: Only one compromised validator key is required,
357
  well within IBFT&rsquo;s designed tolerance of f = floor((n-1)/3).</li>
358
<li><strong>No recovery without intervention</strong>: The stall persists as long as the attacker
359
  continues sending poisoned RCs. Network restart or attacker removal is required.</li>
360
<li><strong>MEV extraction</strong>: Attacker controls when chain resumes and who proposes next block.</li>
361
<li><strong>Market manipulation</strong>: Timed chain halts enable external short positions.</li>
362
<li><strong>Maps to P1 Criteria B</strong>: &ldquo;gaming consensus for monetary advantage&rdquo;</li>
363
</ul>
364
<h2>MEV Escalation, From DoS to Monetary Gain</h2>
365
<p>The consensus stall is not merely a denial-of-service. The attacker controls WHEN the
366
chain stalls and WHEN it resumes. Combined with deterministic proposer selection, this
367
converts the stall into a consensus-level MEV extraction tool.</p>
368
<h3>Proposer Selection During Round Changes</h3>
369
<p>Proposer selection is deterministic round-robin (<code>validator/default.go:138-148</code>):</p>
370
<pre><code class="language-go">func roundRobinProposer(valSet, proposer, round) {
371
    seed = indexOf(lastProposer) + round + 1
372
    pick = seed % N
373
    return valSet.GetByIndex(pick)
374
}
375
</code></pre>
376
<p>During a stall, <code>lastProposer</code> is fixed (last committed block&rsquo;s proposer). The attacker
377
at validator index <code>A</code> becomes proposer at round:</p>
378
<pre><code>K = (A - lastProposerIdx - 1) mod N
379
</code></pre>
380
<p>This is fully predictable before the attack begins.</p>
381
<h3>Full MEV Attack Chain</h3>
382
<ol>
383
<li>Attacker (1 of N validators) captures PREPARE messages from gossip</li>
384
<li>Sends ROUND-CHANGE with HasBadProposal=true + arbitrary preparedBlock + mismatched PREPAREs</li>
385
<li><code>roundChangeSet.Add()</code> accepts due to single-message HasBadProposal bypass (no quorum check)</li>
386
<li><code>highestPreparedBlock</code> is poisoned → <code>isJustified()</code> fails → no block proposed → round timeout</li>
387
<li>Attacker re-poisons each round, stalling consensus indefinitely</li>
388
<li>Attacker monitors mempool during stall, sees all pending transactions accumulating</li>
389
<li>Attacker calculates round K where they become proposer: <code>K = (attackerIdx - lastProposerIdx - 1) % N</code></li>
390
<li>At round K, attacker stops poisoning, clean round, attacker is proposer</li>
391
<li>Attacker&rsquo;s miner constructs block with chosen transaction ordering, frontrunning, sandwich, censorship</li>
392
<li>Honest validators accept normally, clean round, valid block</li>
393
<li>Repeat from step 2 for next profitable opportunity</li>
394
</ol>
395
<h3>Concrete Scenario</h3>
396
<p><strong>Setup:</strong> 4 validators (A, B, C, D). Attacker controls A (index 0). Last proposer was D (index 3).</p>
397
<ul>
398
<li>Round 0: proposer = (3 + 0 + 1) % 4 = 0 → <strong>Attacker A</strong></li>
399
<li>Round 1: proposer = (3 + 1 + 1) % 4 = 1 → Validator B</li>
400
</ul>
401
<p>If attacker is round 0 proposer:
402
1. See 100K ETN DEX swap in mempool
403
2. Build block: [attacker buy] → [victim swap] → [attacker sell]
404
3. Propose normally (isJustified not checked at round 0)
405
4. Collect sandwich profit</p>
406
<p>If attacker is NOT round 0 proposer:
407
1. Let round 0 proceed, withhold COMMIT → round change
408
2. Poison rounds until attacker&rsquo;s turn
409
3. Resume, propose with MEV-optimized block</p>
410
<h3>Economic Impact</h3>
411
<ul>
412
<li>ETN market cap: $17.4M, daily volume: 415K transactions</li>
413
<li>Timed chain halt + external short position = market manipulation profit</li>
414
<li>BFT tolerance reduced from (n-1)/3 to 0, single validator breaks all guarantees</li>
415
<li>Attack costs nothing (no gas, no stake slashing) and is repeatable indefinitely</li>
416
</ul>
417
<h2>Affected Code Paths</h2>
418
<table>
419
<thead>
420
<tr>
421
<th>File</th>
422
<th>Function</th>
423
<th>Line</th>
424
<th>Issue</th>
425
</tr>
426
</thead>
427
<tbody>
428
<tr>
429
<td><code>consensus/istanbul/core/roundchange.go</code></td>
430
<td><code>roundChangeSet.Add()</code></td>
431
<td>~249</td>
432
<td>Uses single message&rsquo;s HasBadProposal</td>
433
</tr>
434
<tr>
435
<td><code>consensus/istanbul/core/justification.go</code></td>
436
<td><code>isJustified()</code></td>
437
<td>~37-51</td>
438
<td>Requires quorum of HasBadProposal</td>
439
</tr>
440
<tr>
441
<td><code>consensus/istanbul/core/justification.go</code></td>
442
<td><code>hasMatchingRoundChangeAndPrepares()</code></td>
443
<td>~175</td>
444
<td>Digest bypass with hasBadProposal=true</td>
445
</tr>
446
<tr>
447
<td><code>consensus/istanbul/core/roundchange.go</code></td>
448
<td><code>handleRoundChange()</code></td>
449
<td>~118</td>
450
<td>Uses poisoned highestPreparedBlock</td>
451
</tr>
452
<tr>
453
<td><code>consensus/istanbul/core/core.go</code></td>
454
<td><code>startNewRound()</code></td>
455
<td>~183</td>
456
<td>ClearLowerThan doesn&rsquo;t prevent re-poisoning</td>
457
</tr>
458
</tbody>
459
</table>
460
<h2>Suggested Fix</h2>
461
<p>In <code>roundChangeSet.Add()</code>, do NOT use the single message&rsquo;s <code>HasBadProposal</code> flag.
462
Either:
463
1. Always require digest match in <code>hasMatchingRoundChangeAndPrepares()</code> when called
464
   from <code>Add()</code> (set <code>hasBadProposal=false</code>), or
465
2. Defer the <code>highestPreparedBlock</code> decision until quorum is reached and compute
466
   <code>hasBadProposal</code> from the full set of RC messages, consistent with <code>isJustified()</code>.</p>
467
<hr><p style="color:var(--faint);font-size:12.5px;font-family:ui-monospace,Menlo,monospace">Source &middot; github.com/zionboggan/security-research-notebook &middot; writeups/electroneum/qbft-hasbadproposal-consensus-stall.md</p>
468
</div></div></section>
469
<footer><div class="wrap row">
470
  <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>
471
  <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>
472
</div></footer>
473
</body></html>