Project

General

Profile

Actions

Bug #9201

open
JI PA

http1: CONNECT only changes protocol for the first tx

Bug #9201: http1: CONNECT only changes protocol for the first tx

Added by Jason Ish 18 days ago. Updated about 2 hours ago.

Status:
Resolved
Priority:
Normal
Target version:
Affected Versions:
Effort:
Difficulty:
Label:

Description

Summary

After a connection already has one finished HTTP/1 transaction, a CONNECT that receives a 2xx leaves the flow on HTTP because AppLayerRequestProtocolChange runs only when transaction_cnt == 1. The next client line is still probed by request_connect_probe_data. HtpMethod::new only accepts a fixed set of uppercase methods. An empty line, or a method outside that set such as BIND or SEARCH, sets both request_status and response_status to TUNNEL. Later plaintext HTTP on that connection creates no transaction, so http.uri rules do not match and eve has no HTTP event for it. A following GET is still a transaction and still alerts. A CONNECT that is the first transaction still requests a protocol change, and a later GET is parsed. Unpack suricata-http-connect-tunnel-bypass.tar.gz and run ./verify.sh. The suricata-verify bundle is verify-suricata-http-connect-tunnel-bypass.tar.gz.

Affected Versions

Confirmed on jasonish/suricata:latest, which prints This is Suricata version 8.0.7 RELEASE and is the same image as jasonish/suricata:8.0.7 (LibHTP v8.0.7). Default yaml, HTTP left enabled, HTTP/2 left enabled, IDS mode. The live run also sets flow-timeouts.tcp.closed=1 and flow-timeouts.tcp.established=15 so eve writes the flow record during the run. Those two knobs do not change HTTP parsing. The pcap replay uses the image defaults.

Preconditions

The sensor is inspecting a TCP connection that the HTTP/1 parser accepts. The peer answers CONNECT with a status in 200-299. At least one HTTP transaction on that connection has already completed, so the CONNECT response is not transaction 1. No HTTP feature is disabled. The bytes after the probe are ordinary HTTP/1, not a TLS ClientHello.

Security Impact

On the attacked connection, eve HTTP stops at the warmup GET and the CONNECT. The plaintext GET that the origin accepted and answered is absent, and its http.uri rule does not fire. app_proto stays http and app_proto_expected is unset, so this is not the normal protocol switch. A tcp content rule on the same marker still fires, so raw payload inspection of that segment is not what failed. A GET immediately after the CONNECT response is transaction 2 and the http.uri rule matches. When CONNECT is the first transaction, a following GET is still logged, including a GET sent after BIND. This is not a crash and not a memory-write primitive.

Vulnerable Code

rust/htp/src/request.rs, request_connect_probe_data. The method token is whatever HtpMethod::new returns. Unknown, including an empty token, forces the tunnel. A known method finishes the CONNECT transaction and keeps parsing.

if HtpMethod::new(method) == HtpMethod::Unknown {
self.request_status = HtpStreamState::TUNNEL;
self.response_status = HtpStreamState::TUNNEL
} else {
return self.state_request_complete(input);
}

HtpMethod::new in the same file matches only a fixed list of uppercase byte strings (GET, POST, CONNECT, WebDAV names such as PROPFIND, and so on) and returns Unknown for everything else, including BIND, SEARCH, and an empty slice.

src/app-layer-htp.c, HTPCallbackResponseComplete. The transaction counter is incremented first. The protocol change for a CONNECT 2xx runs only when the count is still 1.

hstate->transaction_cnt++;
if (htp_tx_request_method_number(tx) == HTP_METHOD_CONNECT) {
if ((htp_tx_response_status_number(tx) >= 200) &&
(htp_tx_response_status_number(tx) < 300) && (hstate->transaction_cnt == 1)) {
AppLayerRequestProtocolChange(hstate->f, dp, ALPROTO_UNKNOWN);
}
}

Once libhtp is in TUNNEL, request_data returns without creating another transaction. The flow is still ALPROTO_HTTP1, so nothing re-parses the following plaintext.

Call Chain

  1. Client finishes one HTTP/1 transaction, then sends CONNECT and reads a 2xx (src/app-layer-htp.c HTPCallbackResponseComplete, the transaction_cnt++ at the start of the callback and the == 1 test that follows).
  2. AppLayerRequestProtocolChange is skipped. The flow stays HTTP.
  3. The next client line reaches request_connect_probe_data (rust/htp/src/request.rs).
  4. HtpMethod::new returns Unknown for an empty line, BIND, or SEARCH.
  5. Both statuses become TUNNEL. Later chunks hit the request_status TUNNEL return in request_data and are not turned into HTTP transactions.

Payload

HTTP/1.1 on port 80. Every attack flow starts with a finished GET and a CONNECT 200:

GET /warmup-atk-bind-9f3a HTTP/1.1
Host: origin.example
Connection: keep-alive

CONNECT example.com:443 HTTP/1.1
Host: example.com:443
Connection: keep-alive

The server answers the CONNECT with HTTP/1.1 200 Connection Established and no body. The probe is then one of:

  • a blank line and then GET /atk-empty-marker-9f3a HTTP/1.1
  • BIND example.com:443 HTTP/1.1 and then GET /atk-bind-marker-9f3a HTTP/1.1
  • SEARCH /probe HTTP/1.1 and then GET /atk-search-marker-9f3a HTTP/1.1

The control uses the same prefix and then GET /ctrl-get-marker-9f3a HTTP/1.1 as the next line, with no blank line and no unknown method. The origin answers every request with HTTP/1.1 200 OK.

Reproduction

Image jasonish/suricata:latest. IDS on the Docker bridge, default yaml, HTTP/1 to container port 80, host publish 18081. No -k none on the live path.

Attachment suricata-http-connect-tunnel-bypass.tar.gz:

./verify.sh

show_results.sh on the passing run:

control src_port=42944 http_alerts=1 app_proto=http methods=['GET', 'CONNECT', 'GET']
urls=['/warmup-ctrl-9f3a', 'example.com:443', '/ctrl-get-marker-9f3a']
attack empty src_port=42952 http_alerts=0 raw_alerts=1 app_proto=http origin_saw=True methods=['GET', 'CONNECT']
urls=['/warmup-atk-empty-9f3a', 'example.com:443']
attack bind src_port=42956 http_alerts=0 raw_alerts=1 app_proto=http origin_saw=True methods=['GET', 'CONNECT']
urls=['/warmup-atk-bind-9f3a', 'example.com:443']
attack search src_port=42966 http_alerts=0 raw_alerts=1 app_proto=http origin_saw=True methods=['GET', 'CONNECT']
urls=['/warmup-atk-search-9f3a', 'example.com:443']
bound lone_get src_port=42972 http_alerts=1 marker_in_http=True methods=['CONNECT', 'GET']
bound lone_bind src_port=42976 http_alerts=1 marker_in_http=True methods=['CONNECT', '', 'BIND', 'GET']
hidden_probes=['empty', 'bind', 'search']
VERDICT: PASS

Attachment verify-suricata-http-connect-tunnel-bypass.tar.gz is a suricata-verify directory named suricata-http-connect-tunnel-bypass (test.yaml, test.rules, README.md, writepcap.py, input.pcap). Copy the unpacked directory into the suricata-verify tests/ tree and, from a built source tree, run:

python3 ../path/to/suricata-verify/run.py suricata-http-connect-tunnel-bypass

Expected on an unpatched 8.0.7 build: http.uri alert 1000001 once, alerts 1000002 1000003 1000004 absent, the three attack source ports 41002 41003 41004 each have two http events (warmup GET and CONNECT) and app_proto http, and the marker URLs are absent. Alerts 1000005 and 1000006 are present because those flows start with CONNECT. writepcap.py rebuilds input.pcap. Checksums in that pcap are valid.

Fix Recommendation

request_connect_probe_data and HTPCallbackResponseComplete have to agree. An empty token and a method HtpMethod::new does not know must not set TUNNEL on a flow that just skipped AppLayerRequestProtocolChange. Treat that line like a known method: state_request_complete, then parse the following bytes as HTTP, which is what GET already does. Reserve TUNNEL for a probe that is not an HTTP request line. On that path, request the protocol change even when transaction_cnt is greater than 1, so the flow does not stay on a parser that has stopped reading. Removing only the transaction_cnt 1 test is not enough if unknown methods still force TUNNEL before the switch is applied to the next segment.


Files


Subtasks 1 (1 open — 0 closed)

Bug #9282: http1: CONNECT only changes protocol for the first tx (8.0.x backport)In ReviewPhilippe AntoineActions

JI Updated by Jason Ish 18 days ago Actions #1

  • Status changed from New to Triaged
  • Severity set to MODERATE
  • Label Needs backport to 8.0 added

Moderate or low?

• Visibility: After a completed transaction and successful CONNECT, an empty line, BIND, or SEARCH causes
all subsequent HTTP parsing to stop for that TCP flow. HTTP transactions, logging, URI rules, and other
app-layer inspection are lost; raw TCP inspection continues.

OT Updated by OISF Ticketbot 18 days ago Actions #2

  • Subtask #9202 added

OT Updated by OISF Ticketbot 18 days ago Actions #3

  • Label deleted (Needs backport to 8.0)

JI Updated by Jason Ish 14 days ago Actions #4

  • Status changed from Triaged to Assigned
  • Assignee changed from OISF Dev to Philippe Antoine

PA Updated by Philippe Antoine 14 days ago Actions #5

  • Tracker changed from Security to Feature
  • Severity deleted (MODERATE)
  • Disclosure Date deleted (01/07/2027)

I think this is a feature request to support http methods BIND and SEARCH

PA Updated by Philippe Antoine 5 days ago Actions #6

  • Status changed from Assigned to Feedback

Now looks like `&& (hstate->transaction_cnt == 1)` to limit protocol change from CONNECT to the first tx may be wrong...
Discussed with the reporter in freshdesk...

PA Updated by Philippe Antoine 1 day ago Actions #7

  • Subject changed from http: CONNECT 2xx after an earlier transaction stops HTTP parsing on an empty line or unknown method to http1: CONNECT only changes protocol for the first tx

But https://www.rfc-editor.org/info/rfc9110/#section-3.3 states

For example, a CONNECT request (Section 9.3.6) or a request with the Upgrade header field (Section 7.8) can occur at any time, not just in the first message on a connection.

PA Updated by Philippe Antoine 1 day ago Actions #8

  • Status changed from Feedback to Assigned
  • Label Needs backport to 8.0 added

OT Updated by OISF Ticketbot 1 day ago Actions #9

  • Subtask #9282 added

OT Updated by OISF Ticketbot 1 day ago Actions #10

  • Label deleted (Needs backport to 8.0)

PA Updated by Philippe Antoine 1 day ago Actions #11

  • Tracker changed from Feature to Bug

PA Updated by Philippe Antoine 1 day ago Actions #12

  • Status changed from Assigned to In Review

Gitlab MR for now, I think it can be published

PA Updated by Philippe Antoine 1 day ago Actions #13

  • Private changed from Yes to No

JI Updated by Jason Ish about 2 hours ago Actions #15

  • Status changed from In Review to Resolved
Actions

Also available in: PDF Atom