Bug #8457
Updated by Alexey Monastyrskiy 21 days ago
h2. Summary The DCE/RPC detection engine causes the @dcerpc.iface@ keyword to unconditionally return a match if the BIND request did not have the @PFC_FIRST_FRAG@ flag set, unless the signature uses the @any_frag@ modifier. This means a signature designed to alert on a specific DCE/RPC interface will alert on any DCE/RPC interface if the client omits the first-fragment flag during the BIND phase. Signatures that use the @any_frag@ modifier are not affected. h2. Affected Code @rust/src/dcerpc/detect.rs@, @match_backuuid()@ Reproduced on 8.0.4. The code is identical in 7.0.15. The behavior is also present on current @main@ and @main-8.0.x@. h2. Background For BIND PDUs with minor version 0, the "DCE 1.1: RPC specification":https://pubs.opengroup.org/onlinepubs/9629399/chap12.htm#tagcjh_17_06_02 explicitly states that "the run-time assumes no fragmentation." Therefore, a BIND PDU with @pfc_flags=0x00@ is perfectly valid and compliant with the specification. Windows endpoints accept BIND requests with @pfc_flags=0x00@ (confirmed with live traffic on port 135), producing a valid BIND_ACK. Similarly, Samba's server-side BIND handler (@dcesrv_bind()@ in @librpc/rpc/dcesrv_core.c@) calls @dcerpc_verify_ncacn_packet_header@ with @required_flags=0@. _Note_: Our testing used minor version 0 BIND PDUs only. We have not tested Windows behavior for minor version 1 BIND PDUs. h2. What Happens In @rust/src/dcerpc/detect.rs@, the @match_backuuid()@ walks function iterates over the per-flow interface UUID entries and compares them accepted UUIDs to check if the signature. The faulty pattern is interface matches the same on released branches and on @main@: at one specified in the top of each loop iteration @ret@ signature (code shown is set to @1@; when @any_frag@ is off and the first-fragment flag is missing on the entry, the code @continue@s without clearing @ret@, so a later return can still be a match without a successful UUID comparison. *Released 7.0.x and 8.0.x* (example below from 8.0.4): the loop iterates @bindack.accepted_uuid_list@. Return type was @u8@. <pre><code class="rust"> fn match_backuuid( tx: &DCERPCTransaction, state: &mut DCERPCState, if_data: &mut DCEIfaceData, ) -> u8 { let mut ret = 0; if let Some(ref bindack) = state.bindack { for uuidentry in bindack.accepted_uuid_list.iter() { ret = 1; // <--- ret is set to 1 here // if any_frag is not enabled, we need to match only against the first fragment if if_data.any_frag == 0 && (uuidentry.flags & DCERPC_UUID_ENTRY_FLAG_FF == 0) { SCLogDebug!("any frag not enabled"); continue; // <--- skips the UUID check, but ret remains 1 } // ... BIND_ACK result check, UUID comparison, context ID and version checks ... </code></pre> *Current @main@* (state as of commit @b0e63a2c9@): the loop iterates @state.interface_uuids@; return type is @c_int@; rejected/unacked entries can appear in the list (see next section). The same @ret = 1@ + @continue@ on the first-fragment guard still appears *before* the @!uuidentry.acked || uuidentry.result != 0@ guard. <pre><code class="rust"> fn match_backuuid( tx: &DCERPCTransaction, state: &mut DCERPCState, if_data: &mut DCEIfaceData, ) -> c_int { let mut ret = 0; if !state.interface_uuids.is_empty() { for uuidentry in &state.interface_uuids { ret = 1; if if_data.any_frag == 0 && (uuidentry.flags & DCERPC_UUID_ENTRY_FLAG_FF == 0) { SCLogDebug!("any frag not enabled"); continue; // ret still 1 } if !uuidentry.acked || uuidentry.result != 0 { ret = 0; continue; } // ... UUID, context ID, version ... </code></pre> If the BIND request was missing the @PFC_FIRST_FRAG@ flag, the @DCERPC_UUID_ENTRY_FLAG_FF@ bit is not set in @uuidentry.flags@. When the signature does not use @any_frag@ (the default behavior), the @if@ condition evaluates to true, and the code hits @continue@. The @continue@ skips the UUID comparison, but @ret@ remains @1@ from the top of the iteration. If no subsequent iteration resets @ret@ to @0@, the function returns @1@ (Match) without ever comparing UUIDs. For example, if the BIND negotiated a single interface, the loop has one iteration: @ret@ is set to @1@, the UUID comparison is skipped, and the function returns a match regardless of which interface was requested. If the signature *does* use @any_frag@, the @if@ condition evaluates to false, the UUID is checked, and the match is evaluated correctly. h2. Reproduction 1. Create a Suricata rule targeting a dummy interface without the @any_frag@ modifier: <pre> alert dcerpc any any -> any any (msg:"DCERPC BIND test"; dcerpc.iface:11111111-2222-3333-4444-555555555555; sid:1;) </pre> 2. Send a DCE/RPC BIND request for a completely different interface (e.g., @12345678-1234-1234-1234-123456789012@) but clear the @PFC_FIRST_FRAG@ flag (@pfc_flags = 0x00@ instead of @0x03@). 3. The server accepts the BIND request and replies with a BIND_ACK. 4. Send a REQUEST PDU. *Expected*: No alert, because the interface UUIDs do not match. *Actual*: Suricata generates an alert for the rule. Attached PCAP: @repro_remote.pcapng@ — live capture against a Windows endpoint on port 135. Contains two connections: one where the BIND has @pfc_flags=0x00@ (frames 28-33; triggers the false positive) and one where the BIND has @pfc_flags=0x03@ (frames 38-43; control, no false positive). h2. Impact This is a false positive issue, not an evasion issue. The bug causes @dcerpc.iface@ to match _more_ broadly than intended — any accepted interface satisfies any @dcerpc.iface@ signature when @PFC_FIRST_FRAG@ is missing. An attacker cannot use this to _evade_ detection; the interface is still correctly bound and traffic is processed normally. The practical impact is spurious alerts: a signature written for one DCE/RPC interface will fire on unrelated interfaces if the client happens to omit the first-fragment flag in the BIND. This only affects standalone DCE/RPC (typically port 135). DCE/RPC over SMB (typically port 445) is not affected (see below). h2. Note on Rejected Interfaces A few lines down in the same function on *released* 7.0.x / 8.0.x, function, there is a seemingly identical pattern with rejected binds: <pre><code class="rust"> // if the uuid has been rejected(uuidentry->result == 1), we skip to the next uuid if uuidentry.result != 0 { SCLogDebug!("Skipping to next UUID"); continue; } </code></pre> If this code were reached, it would also return @1@ (Match) for rejected interfaces due to the same @ret = 1@ initialization. However, this path seems to be dead code in 8.0.4. In @rust/src/dcerpc/dcerpc.rs@, the @process_bindack_pdu()@ function explicitly breaks and skips adding the UUID to @accepted_uuid_list@ if @ack_result != 0@. Because rejected interfaces are never added to the list, the loop in @match_backuuid()@ never processes them. h2. Note on @main@ and @main-8.0.x@ branches A recent update on @main@ (commit "05a11e289":https://github.com/OISF/suricata/commit/05a11e289#diff-2704bf5e190356affa14ca322bd393326f34a82c69b974139872fab94e2f6836R75) and cherry-picked to @main-8.0.x@ (commit "691114e95":https://github.com/OISF/suricata/commit/691114e95cd09c6fa4034df9809f6c79ae4e4ea0) changed @match_backuuid()@ to iterate over the interface list that includes rejected and unacked entries, not just accepted ones. The patch added @ret = 0@ before @continue@ for the @!acked || result != 0@ check — but the @PFC_FIRST_FRAG@ / @any_frag@ guard check (line 70-72) still uses has the @ret = 1@ + @continue@ pattern and executes _before_ the acked/result check. This potentially means the false positive on @main@ and @main-8.0.x@ triggers not only for accepted BINDs (as in released versions) but also for rejected and unacked BINDs when @PFC_FIRST_FRAG@ is missing. We have not tested any of these scenarios on a build from @main@ or @main-8.0.x@. h2. DCE/RPC over SMB is not affected This bug only affects standalone DCE/RPC (@ALPROTO_DCERPC@, typically port 135). DCE/RPC encapsulated in SMB (@ALPROTO_SMB@, typically port 445) uses a completely different code path. *On released 7.0.x and 8.0.x*, the The C dispatcher in @src/detect-dce-iface.c@ @detect-dce-iface.c@ (@DetectDceIfaceMatchRust@) branches on @f->alproto@: @ALPROTO_DCERPC@ calls into Rust @match_backuuid()@ (the buggy function described above), while @ALPROTO_SMB@ calls @rs_smb_tx_get_dce_iface()@. The SMB function uses clean exact-match logic that checks @i.acked && i.ack_result == 0 && i.uuid == if_uuid@ without any fragment-flag dependency. It does not have the SMB-side matcher (historically exposed as @SCSmbTxGetDceIface@ @ret = 1@ / @rs_smb_tx_get_dce_iface@ depending on branch). @continue@ pattern. *On current @main@*, @dcerpc.iface@ lives entirely in @rust/src/dcerpc/detect.rs@ and @src/detect-dce-iface.c@ is gone ("4fc6ca5d6":https://github.com/OISF/suricata/commit/4fc6ca5d6 — Redmine 8391). SMB traffic still does not use @match_backuuid()@. h2. References * "DCE 1.1: RPC -- Fragmentation and Reassembly":https://pubs.opengroup.org/onlinepubs/9629399/chap12.htm#tagcjh_17_06_02 * "match_backuuid in 7.0.15 (tag suricata-7.0.15), 7.0.15, lines 62-72":https://github.com/OISF/suricata/blob/suricata-7.0.15/rust/src/dcerpc/detect.rs#L62-L72 * "match_backuuid in 8.0.4 (tag suricata-8.0.4), 8.0.4, lines 62-72":https://github.com/OISF/suricata/blob/suricata-8.0.4/rust/src/dcerpc/detect.rs#L62-L72 * "match_backuuid on main (commit b0e63a2c9), main, lines 80-97":https://github.com/OISF/suricata/blob/b0e63a2c952828c28a1eb26fb9fca3e9ef01b562/rust/src/dcerpc/detect.rs#L80-L97 * "dcerpc_iface_match + dcerpc_tx_match_dce_iface (commit b0e63a2c9)":https://github.com/OISF/suricata/blob/b0e63a2c952828c28a1eb26fb9fca3e9ef01b562/rust/src/dcerpc/detect.rs#L234-L265 * "smb_tx_match_dce_iface (commit b0e63a2c9)":https://github.com/OISF/suricata/blob/b0e63a2c952828c28a1eb26fb9fca3e9ef01b562/rust/src/smb/detect.rs#L132-L175 * "detect/dcerpc: move iface keyword to rust (4fc6ca5d6)":https://github.com/OISF/suricata/commit/4fc6ca5d6c48b0fd1b0eb53b29d5011179acc38f 62-72":https://github.com/OISF/suricata/blob/main/rust/src/dcerpc/detect.rs#L62-L72 * "Samba dcesrv_core.c -- dcesrv_bind()":https://github.com/samba-team/samba/blob/a094a29e426cc79e23bb4d866334d7735159fb41/librpc/rpc/dcesrv_core.c#L1099-L1120 (see @0@ passed for @required_flags@ when @dcerpc_verify_ncacn_packet_header@ is called)