WS-Exfil-Shield: Catching What WAFs Miss After the 101 Handshake

Problem

WAFs inspect the WebSocket upgrade and individual frames against signatures and

content profiles, but they do not correlate behavior across the lifetime of an

established WebSocket session.

All major WAF vendors document the same gap: inspection stops at the HTTP upgrade

handshake; post-upgrade WebSocket frames are not correlated across a session.

Three threat patterns exploit the post-upgrade behavioral blind spot:

**C2 Beacon Timing**:

WebSocket C2 channels are documented in active campaigns —

e.g. PhantomCaptcha (SentinelLabs, Oct 2025) used a multi-stage WebSocket RAT

with wss:// C2 and Base64/JSON commands; LightSpy (Huntress, macOS variant) uses

WebSockets for command delivery and control. The behavioral signal is timing —

beaconing implants tend toward regular intervals, humans do not. WAFs and most

network controls do not analyze inter-frame timing across a session.

**Credential Stuffing Over WebSocket**:

1000 credential pairs over one connection

appear as one HTTP event to perimeter controls. Verizon DBIR 2025: compromised

credentials were the initial access vector in 22% of breaches; the median daily

share of credential stuffing in SSO authentication logs was 19%.

**Exfiltration Signals**:

/export paths, Authorization headers, and oversized

payloads are visible at the handshake; per-frame inspection (where enabled) sees

content but not session-level patterns. BSI Lagebericht 2025 (reporting period

July 2024 - June 2025): 72% of analyzed ransomware incidents included a data leak;

double extortion (encryption + exfiltration) is the dominant attack model.

Solution

Single iRule. No backend changes. Two-stage behavioral detection:

not “what does this frame contain?” but "what does this connection do over time?

— and does the payload confirm it?"

L1 Suspicious URI/header regex + string BWC throttle + HSL alert
L2 C2 beacon timing (CoV) online statistics Sideband check → quarantine/pass + close
L3 High frame rate sliding window IP block + TCP close
L4 Quarantined reconnect sideband verdict Block/release/honeypot + AI analysis

**L1 - Exfiltration signals at the handshake**:

HTTP_REQUEST checks the upgrade

URI against a regex for known exfiltration endpoints (/export, /download, /dump,

/backup, /extract) and scans headers for Authorization, X-API-Key, X-Secret. On

match: BWC policy attached server-to-client (1 Mbps throttle) + HSL alert. No

block — /export might be legitimate. Throttling buys the SOC time to investigate

without disrupting a potentially valid operation.

**L2 - CoV² online algorithm**:

Welford-inspired, 5 table entries per connection

regardless of session length. CoV² (no sqrt() in BIG-IP Tcl) < 0.0225 with >=5

samples = machine-like timing. On detection: iRule sends timing metadata to the

sideband service and waits up to 500ms for a verdict.

FALSE_POSITIVE (allowlisted IP) → session continues untouched;

CONFIRMED or timeout → quarantine table set + TCP close. The quarantined IP will

be routed to the honeypot on its next connection attempt (L4).

**L3 - Frame rate sliding window**:

WS_CLIENT_FRAME tracks frame count per

connection within a 10-second window. At 5 frames in 10 seconds: TCP close + IP

written to blocklist with 1-hour TTL. On any subsequent reconnect attempt,

HTTP_REQUEST rejects the connection immediately. The sliding window resets when

the window expires, allowing legitimate high-frequency bursts to pass without

false positives.

**L4 - Two-stage verification with closed-loop AI verdict**:

When a quarantined IP reconnects, HTTP_REQUEST issues a QUARANTINE_CHECK to the sideband service before routing. Three outcomes apply at handshake time:

PENDING (analysis still in progress) or sideband timeout → connection is silently

routed to the honeypot pool via `pool quarantine_pool`. The attacker’s implant

keeps running, unaware it is isolated. After 5 frames are collected in the

honeypot, the AI analyzer (Claude) classifies payload semantics independent of

timing: agent identifiers, command structure, encoding patterns. Claude’s verdict

is pushed back to the sideband service and cached against the source IP, which

closes the loop:

CONFIRMED → next QUARANTINE_CHECK returns CONFIRMED, iRule emits a C2_CONFIRMED

HSL event (source=claude, confidence, family) and rejects the handshake; the IP

is held in a permanent 24h block. In the test run, the third reconnect after

CONFIRMED never completes the WS upgrade — the client sees

`InvalidMessage: did not receive a valid HTTP response`.

FALSE_POSITIVE → quarantine entry deleted, the IP is released and subsequent

sessions continue normally.

This matters because C2 frameworks implement jitter — randomized beacon intervals

designed to defeat timing-based detection. At >27% jitter (in our test corpus),

CoV rises above threshold and L2 stops firing; the AI layer is jitter-immune

because it inspects payload semantics, not cadence. Neither signal alone is

sufficient. Reference result on a `{“t”:“ping”,“id”:“c2agent01”}` corpus:

verdict=C2, confidence=0.95, family “Generic C2 Heartbeat”, with indicators

including “structured JSON protocol with type field”, "persistent agent identifier

across all frames", “repetitive ping pattern (5/5 frames identical)”, "no human

interaction artifacts", and “deterministic payload, no entropy”.

**Sideband**:

iRule = sensor, endpoint = actor. Used at two points in the flow:

L2 (timing verdict) and L4 (QUARANTINE_CHECK). Pluggable TCP port 9000 listener:

SIEM (Splunk/QRadar), SOAR (auto-block via iControl REST), or AI analyzer

(reference implementation included). catch{} ensures a non-responding endpoint

never delays traffic.

Impact

- Defense in depth with Advanced WAF: WAF guards handshake, signatures, and frame

content; WS-Exfil-Shield adds session-level behavioral detection.

- All thresholds in RULE_INIT — tuning without redeployment; sideband endpoint swappable.

- Graduated enforcement: throttle → quarantine → AI verify → block or release.

Each layer independently tunable.

- Full audit trail: CONNECT, L1_SIGNAL, C2_BEACON, QUARANTINE, RATE_LIMIT, BLOCKED,

C2_CONFIRMED (source=claude, confidence, family) + FALSE_POSITIVE from AI layer.

Demo

Code

# WS-Exfil-Shield iRule
# F5 AppWorld Berlin 2026 - iRules Contest
#
# Four-layer WebSocket security with graduated response:
#   Layer 1: Connection-level exfiltration signals (URL, headers) → BWC throttle
#   Layer 2: C2 beacon timing fingerprint (CoV-based behavioral analysis) → quarantine + TCP close
#   Layer 3: High-frequency frame rate detection (credential stuffing) → TCP close + IP block
#   Layer 4: Quarantined reconnect → sideband verdict check → block/release/honeypot
#
# Two-stage verification:
#   Stage 1 (L2): CoV² detects machine-like timing → sideband confirms → quarantine + TCP close
#   Stage 2 (L4): Reconnect → sideband QUARANTINE_CHECK returns Claude payload verdict:
#     CONFIRMED     → permanent 24h block + C2_CONFIRMED HSL event + reject
#     FALSE_POSITIVE → quarantine released, session continues normally
#     PENDING/timeout → route to honeypot (Claude still analyzing)
#
# External dependencies (pre-configured on BIG-IP):
#   - BWC policy : ws_exfil_throttle  (Network > Bandwidth Controllers, 1 Mbps)
#   - HSL pool   : siem_hsl_pool      (LTM > Pools, UDP 514, points to SIEM/syslog receiver)
#
# Requirements: BIG-IP TMOS 21.x

when RULE_INIT {
    set static::beacon_min_samples   5
    set static::beacon_cv_threshold  0.15  ;# CoV < 0.15 = machine-like timing
    set static::cs_frame_limit       5     ;# max frames per cs_window milliseconds
    set static::cs_window            3000   ;# sliding window size in milliseconds
    set static::cs_block_ttl        3600    ;# IP blocklist TTL in seconds
    set static::bwc_policy "ws_exfil_throttle"
}

# ---------------------------------------------------------------------------
# PROCEDURES
# ---------------------------------------------------------------------------

proc check_beacon_fingerprint { conn_id } {
    set count [table lookup "bcn_count_${conn_id}"]
    if { $count eq "" || $count < $static::beacon_min_samples } { return 0 }

    set sum_t  [table lookup "bcn_sumt_${conn_id}"]
    set sum_t2 [table lookup "bcn_sumt2_${conn_id}"]
    set n      $count

    set mean [expr { double($sum_t) / $n }]
    if { $mean <= 0 } { return 0 }
    set variance [expr { double($sum_t2) / $n - $mean * $mean }]
    if { $variance < 0 } { set variance 0 }

    # CoV² comparison avoids sqrt (not available in BIG-IP Tcl)
    set cov_sq       [expr { $variance / ($mean * $mean) }]
    set cv_thresh_sq [expr { $static::beacon_cv_threshold * $static::beacon_cv_threshold }]

    if { $cov_sq < $cv_thresh_sq } { return 1 }
    return 0
}

proc hsl_send { event data } {
    HSL::send $static::hsl "\{\"event\":\"${event}\",\"ts\":[clock seconds],${data}\}\n"
}

# ---------------------------------------------------------------------------
# EVENTS
# ---------------------------------------------------------------------------

when HTTP_REQUEST {
    if { [string tolower [HTTP::header "Upgrade"]] eq "websocket" } {
        if { ![info exists static::hsl] } {
            set static::hsl [HSL::open -proto UDP -pool siem_hsl_pool]
        }
        set conn_id   "[IP::client_addr]:[TCP::client_port]"
        set client_ip [IP::client_addr]
        set uri       [HTTP::uri]

        # Blocklist check (Layer 3 + confirmed C2 carry-over)
        if { [table lookup "cs_blocked_${client_ip}"] ne "" } {
            log local0.warning "WS-Exfil-Shield: BLOCKED ip=$client_ip conn=$conn_id"
            call hsl_send "BLOCKED" "\"ip\":\"$client_ip\",\"conn\":\"$conn_id\""
            reject
            return
        }

        # Layer 4: Quarantined IP reconnect — check sideband for Claude verdict.
        # CONFIRMED: Claude analyzed honeypot frames and confirmed C2 → permanent block.
        # FALSE_POSITIVE: Claude found no C2 indicators → release quarantine, continue normally.
        # PENDING/timeout: Claude still analyzing → keep routing to honeypot.
        if { [table lookup "quar_${client_ip}"] ne "" } {
            set quar_action "honeypot"
            catch {
                set sb [connect -timeout 100 -protocol TCP 10.10.2.1 9000]
                if { $sb ne "" } {
                    send -timeout 100 $sb "{\"conn_id\":\"$conn_id\",\"ip\":\"$client_ip\",\"threat\":\"QUARANTINE_CHECK\"}\n"
                    set qverdict [recv -timeout 500 $sb]
                    close $sb
                    if { [string match "*\"verdict\":\"CONFIRMED\"*" $qverdict] } {
                        set quar_action "block"
                    } elseif { [string match "*\"verdict\":\"FALSE_POSITIVE\"*" $qverdict] } {
                        set quar_action "release"
                    }
                }
            }
            if { $quar_action eq "block" } {
                table set "cs_blocked_${client_ip}" 1 86400 86400
                log local0.warning "WS-Exfil-Shield: C2_CONFIRMED ip=$client_ip conn=$conn_id"
                call hsl_send "C2_CONFIRMED" "\"ip\":\"$client_ip\",\"conn\":\"$conn_id\""
                reject
                return
            } elseif { $quar_action eq "release" } {
                table delete "quar_${client_ip}"
                log local0.info "WS-Exfil-Shield: FALSE_POSITIVE ip=$client_ip conn=$conn_id"
                call hsl_send "FALSE_POSITIVE" "\"ip\":\"$client_ip\",\"conn\":\"$conn_id\""
                # fall through to normal processing
            } else {
                log local0.info "WS-Exfil-Shield: QUARANTINE ip=$client_ip conn=$conn_id"
                call hsl_send "QUARANTINE" "\"ip\":\"$client_ip\",\"conn\":\"$conn_id\""
                pool quarantine_pool
                return
            }
        }

        # Layer 1: Exfiltration signals in WebSocket upgrade request
        set threat ""
        if { [regexp -nocase {/(export|download|dump|backup|extract)} $uri] } {
            set threat "EXFIL_ENDPOINT"
        }
        if { $threat eq "" } {
            foreach hdr { Authorization X-API-Key X-Secret } {
                if { [HTTP::header $hdr] ne "" } { set threat "SENSITIVE_HEADER"; break }
            }
        }

        if { $threat ne "" } {
            log local0.warning "WS-Exfil-Shield: L1_SIGNAL threat=$threat ip=$client_ip uri=$uri"
            call hsl_send "L1_SIGNAL" "\"ip\":\"$client_ip\",\"conn\":\"$conn_id\",\"threat\":\"$threat\",\"uri\":\"$uri\""
            # Throttle server→client bandwidth to slow active exfiltration
            BWC::policy attach $static::bwc_policy
        }

        table set "ws_start_${conn_id}" [clock clicks -milliseconds] indef 3600
        log local0.info "WS-Exfil-Shield: CONNECT ip=$client_ip conn=$conn_id uri=$uri"
        call hsl_send "CONNECT" "\"ip\":\"$client_ip\",\"conn\":\"$conn_id\",\"uri\":\"$uri\""
    }
}

when WS_CLIENT_FRAME {
    set conn_id   "[IP::client_addr]:[TCP::client_port]"
    set client_ip [IP::client_addr]
    set now       [clock clicks -milliseconds]

    # --- Layer 2: C2 Beacon Timing Fingerprint ---
    set last_ts [table lookup "bcn_last_${conn_id}"]
    if { $last_ts ne "" } {
        set interval [expr { $now - $last_ts }]
        set count  [table lookup "bcn_count_${conn_id}"]
        set sum_t  [table lookup "bcn_sumt_${conn_id}"]
        set sum_t2 [table lookup "bcn_sumt2_${conn_id}"]
        if { $count  eq "" } { set count  0 }
        if { $sum_t  eq "" } { set sum_t  0 }
        if { $sum_t2 eq "" } { set sum_t2 0 }

        if { $interval > 0 } {
            incr count
            set sum_t  [expr { $sum_t  + $interval }]
            set sum_t2 [expr { $sum_t2 + $interval * $interval }]

            table set "bcn_count_${conn_id}"  $count  indef 3600
            table set "bcn_sumt_${conn_id}"   $sum_t  indef 3600
            table set "bcn_sumt2_${conn_id}"  $sum_t2 indef 3600
        }

        if { [call check_beacon_fingerprint $conn_id] } {
            set mean_interval [expr { $sum_t / $count }]
            log local0.warning "WS-Exfil-Shield: C2_BEACON ip=$client_ip conn=$conn_id samples=$count mean_interval=${mean_interval}ms"
            call hsl_send "C2_BEACON" "\"ip\":\"$client_ip\",\"conn\":\"$conn_id\",\"samples\":$count,\"mean_interval_ms\":$mean_interval"
            # Stage 1 sideband: timing verdict determines quarantine vs pass
            # CONFIRMED/timeout → quarantine (honeypot collects payload for Stage 2 Claude analysis)
            # FALSE_POSITIVE    → session continues untouched
            set action "quarantine"
            catch {
                set sb [connect -timeout 100 -protocol TCP 10.10.2.1 9000]
                if { $sb ne "" } {
                    send -timeout 100 $sb "{\"conn_id\":\"$conn_id\",\"ip\":\"$client_ip\",\"threat\":\"C2_BEACON\",\"mean_interval_ms\":$mean_interval}\n"
                    set verdict [recv -timeout 500 $sb]
                    close $sb
                    if { [string match "*\"verdict\":\"FALSE_POSITIVE\"*" $verdict] } {
                        set action "pass"
                    }
                }
            }
            if { $action eq "quarantine" } {
                table set "quar_${client_ip}" 1 indef 1800
                TCP::close
            }
            # action "pass": allowlisted IP — session continues untouched
            return
        }
    }
    table set "bcn_last_${conn_id}" $now indef 3600

    # --- Layer 3: High-frequency frame rate (credential stuffing indicator) ---
    set window_start [table lookup "cs_window_${conn_id}"]
    set frame_count  [table lookup "cs_frames_${conn_id}"]
    if { $window_start eq "" } {
        set window_start $now
        table set "cs_window_${conn_id}" $now indef 3600
    }
    if { $frame_count eq "" } { set frame_count 0 }

    set elapsed [expr { $now - $window_start }]
    if { $elapsed >= $static::cs_window } {
        table set "cs_window_${conn_id}" $now indef 3600
        table set "cs_frames_${conn_id}" 1   indef 3600
    } else {
        incr frame_count
        table set "cs_frames_${conn_id}" $frame_count indef 3600
        if { $frame_count >= $static::cs_frame_limit } {
            log local0.warning "WS-Exfil-Shield: RATE_LIMIT ip=$client_ip conn=$conn_id frames=${frame_count} in ${elapsed}ms"
            call hsl_send "RATE_LIMIT" "\"ip\":\"$client_ip\",\"conn\":\"$conn_id\",\"frames\":$frame_count,\"elapsed_ms\":$elapsed"
            table set "cs_blocked_${client_ip}" 1 $static::cs_block_ttl $static::cs_block_ttl
            TCP::close
            return
        }
    }
}

1 Like

For anyone looking to build something similar here are the prerequisite:

BIG-IP side

  • BIG-IP with LTM + WebSocket profile support
  • iRule attached to a virtual server with HTTP + WebSocket profiles
  • Bandwidth Controller policy (for throttling)
  • HSL pool pointing to a syslog receiver

Sideband service (AI-Analysis / Optional)

  • Python 3.11+
  • anthropic, websockets-server, python-dotenv
  • Your AI API key
  • Two ports open: TCP 9000 (iRule sideband) + WS 8766 (honeypot)

The iRule itself uses only native BIG-IP commands.