Bug #9232
openSNI not evaluated for a second QUIC connection that reuses the UDP source port
Description
SNI not evaluated for a second QUIC connection that reuses the UDP source port (same flow)¶
Summary¶
In QUIC parsing, once Suricata has seen the first ClientHello on a flow it stops inspecting that flow's client Initials for the rest of the flow's life. When a client opens a second QUIC connection from the same UDP source port (so the packets land on the same flow / 4-tuple), the second connection's ClientHello is never parsed and its quic.sni is never evaluated, even though that second connection has a different DCID and its Initial is independently decryptable.
Net effect: a quic.sni rule that should match the second connection does not fire. There is no alert, no anomaly event, and no decrypt-failure event for the second connection. An observer can hide a flagged SNI behind an earlier, benign connection simply by reusing the source port.
Steps to reproduce¶
Three client to server QUIC connections to the same server, captured on the server side. Each ClientHello carries a distinct DCID:
| conn | SNI | UDP src port |
| 1 | allowed.example | 45555 |
| 2 | blocked.example | 45555 (reused) |
| 3 | blocked.example | 45556 |
Rule:
alert quic any any -> any 443 (msg:"blocked sni"; quic.sni; content:"blocked.example"; sid:1000001; rev:1;)
Run offline against the capture (-k none because actions do not apply to offline pcaps; stock config, QUIC app-layer enabled by default):
suricata -c suricata.yaml -S test.rules -r reuse.pcap -l out -k none
Expected behaviour¶
Two alerts for sid:1000001, one for conn 2 (blocked.example on src port 45555) and one for conn 3 (blocked.example on src port 45556):
event_type: alert, alert.signature_id: 1000001, src_port: 45555event_type: alert, alert.signature_id: 1000001, src_port: 45556- a
quicevent for src port 45555 recordingquic.sni: "blocked.example"
Actual behaviour¶
Only one alert fires, for conn 3 (src port 45556). Conn 2 on the reused port is not evaluated:
- sid:1000001 fires once, only
src_port: 45556. - For
src_port: 45555the onlyquicrecords are conn 1'squic.sni: "allowed.example"and a later record withquic.sni: null; theblocked.exampleClientHello on the reused port is never parsed. - Both connections on 45555 are folded into a single flow (one flow record).
- No alert, no
anomalyevent, and noFailedDecrypt/ErrorOnDataevent for conn 2.
The two connections differ only in that conn 2 reuses conn 1's source port. The second connection does not need to wait for the first to close; the client controls the source port and can start the second connection on it at any time.
Controls (to show the result is sound)¶
- Blocked SNI alone on a fresh port: 1 alert (detection works).
- Blocked then allowed on one port: 1 alert, on the blocked connection.
- The same reuse capture, but with the second connection shifted past the UDP flow timeout (default
flow-timeouts.udpestablished: 300; shifted +600 s so the first flow is aged out): 2 alerts. The alert returns once the connections are on separate flows, which pins the cause to flow reuse. - Allowed SNI twice on one port: 0 alerts (no false positives).
- A retransmitted ClientHello (same DCID, duplicated Initial): still 1 alert (no duplicate), i.e. the gap is specific to a new connection on the reused flow, not to any repeated Initial.
Root cause¶
rust/src/quic/quic.rs, QuicState::parse():
1. Once the first ClientHello is seen, hello_ts / hello_tc is set, and the next client packet returns early:
if (to_server && self.hello_ts) || (!to_server && self.hello_tc) {
// payload is encrypted, stop parsing here
return true;
}
so a later ClientHello on the same flow is never parsed.
2. Initial keys are derived only once:
if self.keys.is_none() && header.ty == QuicType::Initial {
self.keys = quic_keys_initial(u32::from(header.version), &header.dcid);
}
The second connection has a different DCID, so the retained keys would not decrypt it even if it were reached.
There is already a precedent in the same function for resetting handshake state: the Retry branch (8.0.x) sets hello_ts = false; keys = None so the client's re-sent Initial is parsed. A new client DCID on an existing flow could be handled the same way.
Affected versions¶
Reproduced on 7.0.8 and 8.0.6