Bug #9187
openhttp1: splices a buffered line across a TCP gap into a request that was never contiguous
Description
Summary¶
With default HTTP/1, bytes that arrive before the first newline of a request line stay in request_buf. A sequence gap outside the body states logs INVALID_GAP and returns CLOSED, without clearing that buffer and without setting the parser status to closed. HTPHandleRequestData treats every return other than ERROR as success, so the next segment is parsed by the same object and concatenated with the leftover line. Suricata then alerts on a URI that is not contiguous in the capture. The same request with those missing bytes filled in alerts only on the real URI, and the process keeps parsing later messages. Unpack the attachment and run ./verify.sh.
CWE¶
- CWE-436 Interpretation Conflict
CVSS 3.1: 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N)
Affected Versions¶
Tested jasonish/suricata:latest, which prints Suricata 8.0.7 RELEASE. HTTP/1 is enabled in the stock configuration. No libhtp personality, checksum, or gap setting was changed. Flow and stats timers were shortened only so the HTTP log flushed during the run. The same return-without-clearing branch is in the response parser.
Preconditions¶
The sensor must be inspecting the TCP flow with default HTTP/1. A sequence hole has to land while a request line (or, by the same return, a header) is still missing its newline, and the bytes after the hole must still be delivered and acknowledged. No login on the sensor is required. A gap inside an identity body is a separate path and is not this bug.
Security Impact¶
The parser publishes a request line built from bytes that were not adjacent on the wire. In this run the /safeSPLICE rule fired once, the real URI /safe-HOLE-SPLICE did not, eve recorded INVALID_GAP, and the attack pcap has a 6-byte sequence hole between GET /safe and SPLICE HTTP/1.1 with no contiguous /safeSPLICE. Parsing continued. The header value yes-hsplice matched, both responses were status 200, and a later GET /after-gap on the same connection matched. The process stayed up. This does not show a crash, memory corruption, or code execution. The response parser has the same gap return and the same line-buffer append. This run exercises the request side.
Vulnerable Code¶
rust/htp/src/request.rs keeps an unfinished line, then on a non-body gap returns CLOSED without touching the buffer or request_status:
fn handle_request_absent_lf(&mut self, data: &ParserData) -> Result<()> {
self.check_request_buffer_limit(data.len())?;
self.request_buf.add(data.as_slice());
self.request_data_consume(data, data.len());
Err(HtpStatus::DATA_BUFFER)
}
if chunk.is_gap()
&& self.request_state != State::BodyIdentity
&& self.request_state != State::IgnoreDataAfterHTTP09
&& self.request_state != State::Finalize
{
htp_error!(self.logger, HtpLogCode::INVALID_GAP, "Gaps are not allowed during this state");
return HtpStreamState::CLOSED;
}
The next chunk is still in State::Line, so request_line glues the buffer to the new segment:
let mut data = take(&mut self.request_buf);
data.add(line);
self.request_data_consume(input, line.len());
self.request_line_complete(data.as_slice())
src/app-layer-htp.c only fails the direction on HTP_STREAM_STATE_ERROR. CLOSED falls through and the flow stays open:
const int r = htp_connp_request_data(hstate->connp, &ts, input, input_len);
switch (r) {
case HTP_STREAM_STATE_ERROR:
ret = -1;
break;
default:
break;
}
Unfinished headers sit in request_header rather than request_buf, and that buffer is also left intact by the early return. response.rs does the same with response_buf (response_line around the take(&mut self.response_buf) append, and the gap branch that returns CLOSED).
Call Chain¶
ReassembleUpdateAppLayer (src/stream-tcp-reassemble.c) delivers STREAM_GAP to AppLayerHandleTCPData (src/app-layer.c). HTPHandleRequestData (src/app-layer-htp.c) calls htp_connp_request_data. ConnectionParser::request_data (rust/htp/src/request.rs, gap branch) returns CLOSED. The following segment hits request_line in the same parser and appends request_buf.
Payload¶
Control, one contiguous client segment, no hole:
GET /safe-HOLE-SPLICE HTTP/1.1
Host: hsplice.local
X-Parser-Live: yes-hsplice
Attack, same bytes with -HOLE- omitted and the sequence number jumped by 6:
GET /safe
<6 byte sequence hole>
SPLICE HTTP/1.1
Host: hsplice.local
X-Parser-Live: yes-hsplice
The parser's request line is GET /safeSPLICE HTTP/1.1. A second request, GET /after-gap HTTP/1.1, follows on the same connection, with a normal HTTP/1.1 200 OK after each request.
Reproduction¶
jasonish/suricata:latest sniffs bridge br-hsplice from the host network (passive inspection, not a published service port). Injector container hsplice-app is 172.31.11.20 on network suri-hsplice, subnet 172.31.11.0/24. Sensor container hsplice-suri.
./verify.sh
control splice_alerts=0 real_alerts=1 later_alerts=1 urls=['/safe-HOLE-SPLICE', '/after-gap']
attack splice_alerts=1 real_alerts=0 header_alerts=1 status_alerts=2 later_alerts=1
attack urls=['/safeSPLICE', '/after-gap'] anomalies=['INVALID_GAP']
attack_pcap_has_spliced_bytes=False
attack_seq_holes hole=6 prev_tail='GET /safe' next_head='SPLICE HTTP/1.1'
suricata_running=True
VERDICT: PASS
Fix Recommendation¶
On the invalid-gap branch in request_data and response_data, drop the partial line (request_buf and request_header, response_buf and the response header buffer), set that direction's status to ERROR, and return ERROR. HTPHandleRequestData / HTPHandleResponseData already stop the direction on ERROR. Do not append bytes from after the hole onto a buffer filled before it. Body states that already accept gaps can stay as they are.
Files
PA Updated by Philippe Antoine about 4 hours ago
- Tracker changed from Security to Bug
- Status changed from New to Assigned
- Assignee changed from OISF Dev to Philippe Antoine
- Private changed from Yes to No
- Disclosure Date deleted (
01/07/2027)
TCP gaps are not attacker controlled, so this is a bug