Frontend Labs
⋯

Day 73 — WAF Normalization Pipeline

Learning Objectives

  • Build the multi-layer normalization pipeline that separates basic WAFs from production ones
  • Test 10+ encoding bypass attempts against your normalized matcher
  • Understand why ModSecurity has 8 normalization passes

Exercise: Normalization Pipeline

package waf

import (
    "html"
    "net/url"
    "regexp"
    "strings"
    "unicode"
)

var (
    sqlCommentRe    = regexp.MustCompile(`\/\*.*?\*\/`)
    multiSpaceRe    = regexp.MustCompile(`\s+`)
    nullByteRe      = regexp.MustCompile(`\x00`)
    htmlEntityRe    = regexp.MustCompile(`&#x?[0-9a-fA-F]+;?|&\w+;`)
)

// NormalizationPipeline applies layers of decoding before rule matching.
// Each layer decodes a different encoding that attackers might use.
func NormalizationPipeline(input string) string {
    result := input

    // Pass 1: Remove null bytes (used to truncate strings in C-based systems)
    result = nullByteRe.ReplaceAllString(result, "")

    // Pass 2: URL decode (handles %XX encoding)
    if decoded, err := url.QueryUnescape(result); err == nil {
        result = decoded
    }

    // Pass 3: HTML entity decode (handles < > < <)
    result = html.UnescapeString(result)

    // Pass 4: Second URL decode (catches double-encoded: %2520 → %20 → space)
    if decoded, err := url.QueryUnescape(result); err == nil {
        result = decoded
    }

    // Pass 5: Strip SQL comments (/**/ used to split keywords: UN/**/ION)
    result = sqlCommentRe.ReplaceAllString(result, " ")

    // Pass 6: Normalize whitespace (tab, newline used to split keywords)
    result = strings.Map(func(r rune) rune {
        if unicode.IsSpace(r) { return ' ' }
        return r
    }, result)

    // Pass 7: Collapse multiple spaces
    result = multiSpaceRe.ReplaceAllString(result, " ")

    // Pass 8: Lowercase (for case-insensitive matching)
    result = strings.ToLower(strings.TrimSpace(result))

    return result
}

// Test bypass attempts — all should be normalized to detectable form:
var bypassAttempts = []struct {
    input, expectedNorm, attack string
}{
    {
        input:       "UN/**/ION SE/**/LECT",
        expectedNorm: "union select",
        attack:      "SQL comment evasion",
    },
    {
        input:       "%55NION%20%53ELECT",
        expectedNorm: "union select",
        attack:      "URL encoding",
    },
    {
        input:       "%2555NION",
        expectedNorm: "union",
        attack:      "Double URL encoding (%25 → % → %55 → U)",
    },
    {
        input:       "UNION",
        expectedNorm: "union",
        attack:      "HTML entity encoding (U = U)",
    },
    {
        input:       "sElEcT\t\n*\tFrOm\tusers",
        expectedNorm: "select * from users",
        attack:      "Mixed case + whitespace",
    },
    {
        input:       "\x00UNION SELECT",
        expectedNorm: "union select",
        attack:      "Null byte prefix",
    },
}

func TestNormalizationBypass() {
    for _, tc := range bypassAttempts {
        norm := NormalizationPipeline(tc.input)
        detected := strings.Contains(norm, "union") || strings.Contains(norm, "select")
        fmt.Printf("[%s] input=%q norm=%q detected=%v\n", tc.attack, tc.input, norm, detected)
    }
}

Real-World Context

OWASP ModSecurity Core Rule Set (CRS) — the reference implementation used by nginx, Apache, and cloud WAFs — has 8 specific normalization transformation steps. Without normalization, a WAF that detects UNION SELECT is trivially bypassed by %55NION SELECT. This is exactly why attackers test their payloads against WAFs first.


Generated from week10.md · 8 Oct 2026 16:36