diff --git a/src/backends/learning_mode/windows/src/capability_dacl.rs b/src/backends/learning_mode/windows/src/capability_dacl.rs new file mode 100644 index 00000000..18aab980 --- /dev/null +++ b/src/backends/learning_mode/windows/src/capability_dacl.rs @@ -0,0 +1,891 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Capability recovery from permissive Event 14 DACL data. +//! +//! Some permissive capability events leave `ObjectName` empty and expose the +//! requested capability only through a hex-encoded list of ACEs. This module +//! matches those ACE SIDs against a catalog derived with +//! `DeriveCapabilitySidsFromName`. + +use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; + +use learning_mode_core::{AccessType, ResourceType}; +use windows::core::PWSTR; +use windows::Win32::Foundation::{LocalFree, HLOCAL}; +use windows::Win32::Security::{DeriveCapabilitySidsFromName, GetLengthSid, PSID}; + +use crate::extractors::{ + DecodedEventParts, RawDenial, ACCESS_CHECK_EVENT_ID, KERNEL_GENERAL_PROVIDER, + PRIVACY_ACCESS_CHECK_EVENT_ID, PRIVACY_LEARNING_MODE_PROVIDER, +}; + +const ACE_TYPE_ACCESS_ALLOWED: u8 = 0; +const ACE_TYPE_ACCESS_ALLOWED_CALLBACK: u8 = 9; +const ACE_TYPE_OFFSET: usize = 0; +#[cfg(test)] +const ACE_FLAGS_SIZE: usize = 4; +const STANDARD_ACE_SIZE_OFFSET: usize = 2; +const STANDARD_ACE_ACCESS_MASK_OFFSET: usize = 4; +const STANDARD_ACE_HEADER_SIZE: usize = 8; +const LEGACY_ACE_ACCESS_MASK_OFFSET: usize = 8; +const LEGACY_ACE_HEADER_SIZE: usize = 12; +const SID_FIXED_HEADER_SIZE: usize = 8; +const SID_SUB_AUTHORITY_SIZE: usize = 4; +const MAX_DACL_HEX_CHARS: usize = 256 * 1024; + +const KNOWN_CAPABILITIES: &[&str] = &[ + "internetClient", + "internetClientServer", + "privateNetworkClientServer", + "documentsLibrary", + "picturesLibrary", + "videosLibrary", + "musicLibrary", + "removableStorage", + "sharedUserCertificates", + "appointments", + "contacts", + "chat", + "phoneCall", + "voipCall", + "objects3D", + "userAccountInformation", + // `userPrincipalName` is intentionally excluded because LSASS can + // generate it during token plumbing for workloads that never request it. + "backgroundMediaPlayback", + "codeGeneration", + "allowElevation", + "location", + "microphone", + "webcam", + "proximity", + "bluetooth", + "bluetooth.genericAttributeProfile", + "bluetooth.rfcomm", + "humaninterfacedevice", + "lowLevelDevices", + "pointOfService", + "radios", + "serialcommunication", + "usb", + "wiFiControl", + "gazeInput", + "optical", + "activity", + "graphicsCapture", + "graphicsCaptureProgrammatic", + "graphicsCaptureWithoutBorder", + "screenDuplication", + "appCaptureServices", + "appCaptureSettings", + "backgroundMediaRecording", + "backgroundSpatialPerception", + "backgroundVoIP", + "extendedBackgroundTaskTime", + "extendedExecutionBackgroundAudio", + "extendedExecutionCritical", + "extendedExecutionUnconstrained", + "accessoryManager", + "allAppMods", + "appBroadcastServices", + "appLicensing", + "audioDeviceConfiguration", + "cellularDeviceControl", + "cellularDeviceIdentity", + "cellularMessaging", + "confirmAppClose", + "customInstallActions", + "developmentModeNetwork", + "dualSimTiles", + "enterpriseCloudSSO", + "enterpriseDataPolicy", + "enterpriseDeviceLockdown", + "firstSignInSettings", + "gameBarServices", + "gameList", + "gameMonitor", + "globalMediaControl", + "inputForegroundObservation", + "inputInjectionBrokered", + "inputObservation", + "inputSuppression", + "interopServices", + "liveIdService", + "localSystemServices", + "locationHistory", + "locationSystem", + "modifiableApp", + "networkConnectionManagerProvisioning", + "networkDataPlanProvisioning", + "networkingVpnProvider", + "oemDeploymentInfo", + "oemPublicDirectory", + "packagePolicySystem", + "packageQuery", + "packageWriteRedirectionCompatibilityShim", + "previewInkWorkspace", + "previewPenWorkspace", + "previewStore", + "previewUiComposition", + "protectedApp", + "secondaryAuthenticationFactor", + "secureAssessment", + "shellExperience", + "shellExperienceComposer", + "slapiQueryLicenseValue", + "smbios", + "smsSend", + "startScreenManagement", + "storeLicenseManagement", + "systemManagement", + "targetedContent", + "teamEditionDeviceCredential", + "teamEditionExperience", + "teamEditionView", + "uiAccess", + "uiAutomation", + "unvirtualizedResources", + "walletSystem", + "xboxAccessoryManagement", + "appointmentsSystem", + "chatSystem", + "contactsSystem", + "email", + "emailSystem", + "phoneCallHistory", + "phoneCallHistorySystem", + "phoneLineTransportManagement", + "userDataAccountsProvider", + "userDataSystem", + "userSystemId", + "cortanaPermissions", + "cortanaSpeechAccessory", +]; + +// ID_CAP_LOCATION predates the manifest capability named `location` and has +// fixed app/group SIDs that DeriveCapabilitySidsFromName does not reproduce. +const LEGACY_CAPABILITY_SIDS: &[(&str, &str)] = &[ + ( + "location", + "S-1-15-3-1024-2158456844-3754929254-744589270-3611187126-2481208986-30837703-3416168463-2437063433", + ), + ( + "location", + "S-1-5-32-2158456844-3754929254-744589270-3611187126-2481208986-30837703-3416168463-2437063433", + ), +]; + +#[derive(Debug, thiserror::Error)] +enum DaclDecodeError { + #[error("DACL property is not valid hexadecimal")] + InvalidHex, + #[error("DACL property exceeds the {MAX_DACL_HEX_CHARS}-character decode limit")] + InputTooLarge, + #[error("DACL ACE at offset {0} is truncated")] + TruncatedAce(usize), + #[error("DACL contains an unsupported callback ACE at offset {0}")] + CallbackAce(usize), +} + +struct CapabilityIndex { + by_sid: HashMap, &'static str>, + by_sid_string: HashMap, +} + +impl CapabilityIndex { + fn with_capacity(capacity: usize) -> Self { + Self { + by_sid: HashMap::with_capacity(capacity), + by_sid_string: HashMap::with_capacity(capacity), + } + } + + fn insert(&mut self, sid: Vec, name: &'static str, overwrite: bool) { + let sid_string = format_sid(&sid); + if overwrite { + self.by_sid.insert(sid, name); + if let Some(sid_string) = sid_string { + self.by_sid_string.insert(sid_string, name); + } + } else { + self.by_sid.entry(sid).or_insert(name); + if let Some(sid_string) = sid_string { + self.by_sid_string.entry(sid_string).or_insert(name); + } + } + } + + fn resolve(&self, sid: &[u8]) -> Option<&'static str> { + self.by_sid.get(sid).copied() + } + + fn resolve_string(&self, sid: &str) -> Option<&'static str> { + self.by_sid_string.get(sid).copied() + } + + #[cfg(test)] + fn for_test(entries: &[(&'static str, &[u8])]) -> Self { + let mut index = Self::with_capacity(entries.len()); + for &(name, sid) in entries { + index.insert(sid.to_vec(), name, true); + } + index + } +} + +static CAPABILITY_INDEX: OnceLock = OnceLock::new(); + +pub(crate) fn extract_denials( + parts: &DecodedEventParts, + pid: u32, + filetime: u64, +) -> Vec { + let is_supported_event = (parts.provider == KERNEL_GENERAL_PROVIDER + && parts.event_id == ACCESS_CHECK_EVENT_ID) + || (parts.provider == PRIVACY_LEARNING_MODE_PROVIDER + && matches!( + parts.event_id, + ACCESS_CHECK_EVENT_ID | PRIVACY_ACCESS_CHECK_EVENT_ID + )); + if !is_supported_event { + return Vec::new(); + } + + let names = extract_names(parts, CAPABILITY_INDEX.get_or_init(build_capability_index)); + names + .into_iter() + .map(|name| RawDenial { + pid, + resource_type: ResourceType::Capability, + object_name: name.to_string(), + access_type: AccessType::Unknown, + filetime, + event_id: parts.event_id, + }) + .collect() +} + +fn extract_names<'a>(parts: &DecodedEventParts, index: &'a CapabilityIndex) -> HashSet<&'a str> { + let unidentified_capability = + property_is_empty(parts, "ObjectType") && property_is_empty(parts, "ObjectName"); + if !unidentified_capability { + return HashSet::new(); + } + + let mut names = extract_flattened_dacl_names(parts, index); + let mut explicit: Vec<&str> = parts + .props + .iter() + .filter(|(name, _)| is_dacl_property(name)) + .filter_map(|(_, value)| value.strip_prefix("hex:")) + .collect(); + if let Some(value) = parts + .props + .iter() + .filter(|(name, _)| name.eq_ignore_ascii_case("ComplexData")) + .nth(4) + .and_then(|(_, value)| value.strip_prefix("hex:")) + { + explicit.push(value); + } + + let candidates: Vec<&str> = if explicit.is_empty() { + parts + .props + .iter() + .filter(|(name, _)| name.eq_ignore_ascii_case("ComplexData")) + .filter_map(|(_, value)| value.strip_prefix("hex:")) + .collect() + } else { + explicit + }; + + for candidate in candidates { + let Ok(decoded) = decode_hex(candidate) else { + continue; + }; + let (found, _) = walk_aces(&decoded, index); + names.extend(found); + } + names +} + +fn extract_flattened_dacl_names<'a>( + parts: &DecodedEventParts, + index: &'a CapabilityIndex, +) -> HashSet<&'a str> { + let mut names = HashSet::new(); + let mut in_dacl = false; + let mut ace_type = None; + let mut access_mask = None; + + for (property, value) in &parts.props { + if property.eq_ignore_ascii_case("DaclAce") { + in_dacl = true; + ace_type = None; + access_mask = None; + continue; + } + if !in_dacl { + continue; + } + if property.eq_ignore_ascii_case("SaclRevision") || property.eq_ignore_ascii_case("SaclAce") + { + break; + } + + if property.eq_ignore_ascii_case("AceType") { + ace_type = parse_u32(value); + } else if property.eq_ignore_ascii_case("AccessMask") { + access_mask = parse_u32(value); + } else if property.eq_ignore_ascii_case("Sid") { + if ace_type == Some(ACE_TYPE_ACCESS_ALLOWED as u32) + && access_mask.is_some_and(|mask| mask != 0) + { + if let Some(name) = index.resolve_string(value.trim_matches('"')) { + names.insert(name); + } + } + ace_type = None; + access_mask = None; + } + } + names +} + +fn property_is_empty(parts: &DecodedEventParts, property: &str) -> bool { + parts + .props + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(property)) + .is_some_and(|(_, value)| value.trim_matches('"').is_empty()) +} + +fn is_dacl_property(name: &str) -> bool { + contains_ascii_case_insensitive(name, "dacl") + || contains_ascii_case_insensitive(name, "securitydescriptor") + || contains_ascii_case_insensitive(name, "security_descriptor") + || contains_ascii_case_insensitive(name, "ace") +} + +fn contains_ascii_case_insensitive(value: &str, needle: &str) -> bool { + value + .as_bytes() + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + +fn decode_hex(value: &str) -> Result, DaclDecodeError> { + if value.len() > MAX_DACL_HEX_CHARS { + return Err(DaclDecodeError::InputTooLarge); + } + let mut bytes = Vec::with_capacity(value.len() / 2); + let mut high = None; + for byte in value.bytes().filter(|byte| !byte.is_ascii_whitespace()) { + let nibble = match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + b'A'..=b'F' => byte - b'A' + 10, + _ => return Err(DaclDecodeError::InvalidHex), + }; + match high.take() { + Some(high) => bytes.push((high << 4) | nibble), + None => high = Some(nibble), + } + } + if high.is_some() || bytes.is_empty() { + return Err(DaclDecodeError::InvalidHex); + } + Ok(bytes) +} + +fn parse_u32(value: &str) -> Option { + let value = value.trim().trim_matches('"'); + value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .or_else(|| value.parse().ok()) +} + +fn format_sid(bytes: &[u8]) -> Option { + if bytes.len() < SID_FIXED_HEADER_SIZE || bytes[0] != 1 { + return None; + } + let sub_authority_count = bytes[1] as usize; + let expected = SID_FIXED_HEADER_SIZE + SID_SUB_AUTHORITY_SIZE * sub_authority_count; + if bytes.len() != expected { + return None; + } + let authority = bytes[2..8] + .iter() + .fold(0u64, |value, byte| (value << 8) | u64::from(*byte)); + let mut sid = format!("S-1-{authority}"); + for chunk in bytes[8..].chunks_exact(4) { + let sub_authority = u32::from_le_bytes(chunk.try_into().ok()?); + sid.push('-'); + sid.push_str(&sub_authority.to_string()); + } + Some(sid) +} + +fn parse_sid(value: &str) -> Option> { + let mut parts = value.split('-'); + if parts.next()? != "S" { + return None; + } + let revision: u8 = parts.next()?.parse().ok()?; + if revision != 1 { + return None; + } + let authority: u64 = parts.next()?.parse().ok()?; + if authority > 0x0000_ffff_ffff_ffff { + return None; + } + let sub_authorities: Vec = parts.map(str::parse).collect::>().ok()?; + let count: u8 = sub_authorities.len().try_into().ok()?; + + let mut bytes = + Vec::with_capacity(SID_FIXED_HEADER_SIZE + SID_SUB_AUTHORITY_SIZE * count as usize); + bytes.push(revision); + bytes.push(count); + bytes.extend_from_slice(&authority.to_be_bytes()[2..]); + for sub_authority in sub_authorities { + bytes.extend_from_slice(&sub_authority.to_le_bytes()); + } + Some(bytes) +} + +struct ParsedAce<'a> { + ace_type: u8, + mask: u32, + sid: &'a [u8], + next: usize, +} + +fn parse_ace(bytes: &[u8], cursor: usize) -> Result, DaclDecodeError> { + if bytes.len() - cursor < STANDARD_ACE_HEADER_SIZE + SID_FIXED_HEADER_SIZE { + return Err(DaclDecodeError::TruncatedAce(cursor)); + } + let ace_type = bytes[cursor + ACE_TYPE_OFFSET]; + if ace_type == ACE_TYPE_ACCESS_ALLOWED_CALLBACK { + return Err(DaclDecodeError::CallbackAce(cursor)); + } + + // EvtRender historically exposed this provider's ComplexData in a + // DWORD-padded shape. TDH may expose the native ACCESS_ALLOWED_ACE + // shape, so accept both and prefer the self-framing native form. + let declared_ace_size = + u16::from_le_bytes([bytes[cursor + STANDARD_ACE_SIZE_OFFSET], bytes[cursor + 3]]) as usize; + let (mask_offset, sid_offset, declared_end) = if declared_ace_size == 0 { + if bytes.len() - cursor < LEGACY_ACE_HEADER_SIZE + SID_FIXED_HEADER_SIZE { + return Err(DaclDecodeError::TruncatedAce(cursor)); + } + ( + cursor + LEGACY_ACE_ACCESS_MASK_OFFSET, + cursor + LEGACY_ACE_HEADER_SIZE, + None, + ) + } else { + let end = cursor + .checked_add(declared_ace_size) + .filter(|end| { + *end <= bytes.len() + && declared_ace_size >= STANDARD_ACE_HEADER_SIZE + SID_FIXED_HEADER_SIZE + }) + .ok_or(DaclDecodeError::TruncatedAce(cursor))?; + ( + cursor + STANDARD_ACE_ACCESS_MASK_OFFSET, + cursor + STANDARD_ACE_HEADER_SIZE, + Some(end), + ) + }; + let mask = u32::from_le_bytes( + bytes + .get(mask_offset..mask_offset + 4) + .ok_or(DaclDecodeError::TruncatedAce(cursor))? + .try_into() + .map_err(|_| DaclDecodeError::TruncatedAce(cursor))?, + ); + let sub_authorities = *bytes + .get(sid_offset + 1) + .ok_or(DaclDecodeError::TruncatedAce(cursor))? as usize; + let sid_size = SID_FIXED_HEADER_SIZE + SID_SUB_AUTHORITY_SIZE * sub_authorities; + let sid_end = sid_offset + .checked_add(sid_size) + .filter(|next| *next <= bytes.len()) + .ok_or(DaclDecodeError::TruncatedAce(cursor))?; + let next = match declared_end { + Some(end) if sid_end <= end => end, + Some(_) => return Err(DaclDecodeError::TruncatedAce(cursor)), + None => sid_end, + }; + + Ok(ParsedAce { + ace_type, + mask, + sid: &bytes[sid_offset..sid_end], + next, + }) +} + +fn walk_aces<'a>( + bytes: &[u8], + index: &'a CapabilityIndex, +) -> (HashSet<&'a str>, Option) { + let mut names = HashSet::new(); + let mut cursor = 0; + while cursor < bytes.len() { + let ace = match parse_ace(bytes, cursor) { + Ok(ace) => ace, + Err(error) => return (names, Some(error)), + }; + + if ace.ace_type == ACE_TYPE_ACCESS_ALLOWED && ace.mask != 0 { + if let Some(name) = index.resolve(ace.sid) { + names.insert(name); + } + } + cursor = ace.next; + } + (names, None) +} + +fn build_capability_index() -> CapabilityIndex { + let mut index = + CapabilityIndex::with_capacity(KNOWN_CAPABILITIES.len() * 2 + LEGACY_CAPABILITY_SIDS.len()); + for &name in KNOWN_CAPABILITIES { + let wide: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); + let mut group_sids: *mut PSID = std::ptr::null_mut(); + let mut group_count = 0; + let mut capability_sids: *mut PSID = std::ptr::null_mut(); + let mut capability_count = 0; + + let result = unsafe { + DeriveCapabilitySidsFromName( + PWSTR(wide.as_ptr().cast_mut()), + &mut group_sids, + &mut group_count, + &mut capability_sids, + &mut capability_count, + ) + }; + if result.is_ok() { + if let Some(sid) = unsafe { first_sid(capability_sids, capability_count) } { + index.insert(sid, name, true); + } + if let Some(sid) = unsafe { first_sid(group_sids, group_count) } { + index.insert(sid, name, false); + } + } + + unsafe { + free_sid_array(capability_sids, capability_count); + free_sid_array(group_sids, group_count); + } + } + for &(name, sid) in LEGACY_CAPABILITY_SIDS { + if let Some(sid) = parse_sid(sid) { + index.insert(sid, name, true); + } + } + index +} + +unsafe fn first_sid(array: *const PSID, count: u32) -> Option> { + if array.is_null() || count == 0 { + return None; + } + let sid = unsafe { *array }; + if sid.0.is_null() { + return None; + } + let length = unsafe { GetLengthSid(sid) }; + if length == 0 { + return None; + } + let bytes = unsafe { std::slice::from_raw_parts(sid.0.cast::(), length as usize) }; + Some(bytes.to_vec()) +} + +unsafe fn free_sid_array(array: *mut PSID, count: u32) { + if array.is_null() { + return; + } + for index in 0..count as isize { + let sid = unsafe { *array.offset(index) }; + if !sid.0.is_null() { + let _ = unsafe { LocalFree(Some(HLOCAL(sid.0))) }; + } + } + let _ = unsafe { LocalFree(Some(HLOCAL(array.cast()))) }; +} + +#[cfg(test)] +mod tests { + use super::*; + use windows::core::GUID; + + fn sid() -> Vec { + vec![1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0] + } + + fn internet_client_sid() -> Vec { + vec![ + 1, 2, 0, 0, 0, 0, 0, 15, // revision, count, authority + 3, 0, 0, 0, // capability domain + 1, 0, 0, 0, // internetClient RID + ] + } + + fn legacy_ace(mask: u32, sid: &[u8]) -> Vec { + let mut bytes = vec![ACE_TYPE_ACCESS_ALLOWED, 0, 0, 0]; + bytes.extend_from_slice(&[0; ACE_FLAGS_SIZE]); + bytes.extend_from_slice(&mask.to_le_bytes()); + bytes.extend_from_slice(sid); + bytes + } + + fn standard_ace(mask: u32, sid: &[u8]) -> Vec { + let size = (STANDARD_ACE_HEADER_SIZE + sid.len()) as u16; + let mut bytes = vec![ACE_TYPE_ACCESS_ALLOWED, 0]; + bytes.extend_from_slice(&size.to_le_bytes()); + bytes.extend_from_slice(&mask.to_le_bytes()); + bytes.extend_from_slice(sid); + bytes + } + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02X}")).collect() + } + + fn parts(name: &str, value: String) -> DecodedEventParts { + DecodedEventParts { + provider: PRIVACY_LEARNING_MODE_PROVIDER, + event_id: ACCESS_CHECK_EVENT_ID, + props: vec![ + ("ObjectType".to_string(), "\"\"".to_string()), + ("ObjectName".to_string(), "\"\"".to_string()), + (name.to_string(), format!("hex:{value}")), + ], + } + } + + #[test] + fn extracts_capability_from_named_dacl_property() { + let sid = sid(); + let index = CapabilityIndex::for_test(&[("internetClient", &sid)]); + let names = extract_names(&parts("Dacl", hex(&legacy_ace(1, &sid))), &index); + assert_eq!(names, HashSet::from(["internetClient"])); + } + + #[test] + fn accepts_native_access_allowed_ace_layout() { + let sid = sid(); + let index = CapabilityIndex::for_test(&[("internetClient", &sid)]); + let names = extract_names(&parts("Dacl", hex(&standard_ace(1, &sid))), &index); + assert_eq!(names, HashSet::from(["internetClient"])); + } + + #[test] + fn extracts_capability_from_tdh_flattened_dacl() { + let sid = sid(); + let index = CapabilityIndex::for_test(&[("internetClient", &sid)]); + let mut event = parts("DaclAce", "".to_string()); + event.props.extend([ + ("AceType".to_string(), "0".to_string()), + ("AceFlags".to_string(), "0x0".to_string()), + ("AccessMask".to_string(), "0x1".to_string()), + ("Sid".to_string(), "S-1-1-0".to_string()), + ("SaclRevision".to_string(), "0".to_string()), + ]); + let names = extract_names(&event, &index); + assert_eq!(names, HashSet::from(["internetClient"])); + } + + #[test] + fn ignores_token_capability_sid_outside_dacl() { + let sid = sid(); + let index = CapabilityIndex::for_test(&[("internetClient", &sid)]); + let mut event = parts("TokenCapabilities", "".to_string()); + event + .props + .push(("CapabilitySid".to_string(), "S-1-1-0".to_string())); + assert!(extract_names(&event, &index).is_empty()); + } + + #[test] + fn unidentified_capability_falls_back_to_unnamed_binary_property() { + let sid = sid(); + let index = CapabilityIndex::for_test(&[("internetClient", &sid)]); + let names = extract_names(&parts("ComplexData", hex(&legacy_ace(1, &sid))), &index); + assert_eq!(names, HashSet::from(["internetClient"])); + } + + #[test] + fn unidentified_capability_does_not_scan_unrelated_binary_property() { + let sid = sid(); + let index = CapabilityIndex::for_test(&[("internetClient", &sid)]); + let names = extract_names(&parts("Payload", hex(&legacy_ace(1, &sid))), &index); + assert!(names.is_empty()); + } + + #[test] + fn named_file_event_does_not_scan_unrelated_binary_property() { + let sid = sid(); + let index = CapabilityIndex::for_test(&[("internetClient", &sid)]); + let mut event = parts("ComplexData", hex(&legacy_ace(1, &sid))); + event.props[0].1 = "\"File\"".to_string(); + event.props[1].1 = "\"C:\\data\\file.txt\"".to_string(); + assert!(extract_names(&event, &index).is_empty()); + } + + #[test] + fn named_file_event_does_not_scan_explicit_dacl_property() { + let sid = sid(); + let index = CapabilityIndex::for_test(&[("internetClient", &sid)]); + let mut event = parts("Dacl", hex(&legacy_ace(1, &sid))); + event.props[0].1 = "\"File\"".to_string(); + event.props[1].1 = "\"C:\\data\\file.txt\"".to_string(); + assert!(extract_names(&event, &index).is_empty()); + } + + #[test] + fn named_file_event_does_not_scan_flattened_dacl() { + let sid = sid(); + let index = CapabilityIndex::for_test(&[("internetClient", &sid)]); + let mut event = parts("DaclAce", "".to_string()); + event.props[0].1 = "\"File\"".to_string(); + event.props[1].1 = "\"C:\\data\\file.txt\"".to_string(); + event.props.extend([ + ("AceType".to_string(), "0".to_string()), + ("AccessMask".to_string(), "0x1".to_string()), + ("Sid".to_string(), "S-1-1-0".to_string()), + ]); + assert!(extract_names(&event, &index).is_empty()); + } + + #[test] + fn named_file_event_does_not_scan_fifth_complex_data_property() { + let sid = sid(); + let index = CapabilityIndex::for_test(&[("internetClient", &sid)]); + let mut event = parts("ComplexData", "00".to_string()); + event.props[0].1 = "\"File\"".to_string(); + event.props[1].1 = "\"C:\\data\\file.txt\"".to_string(); + for _ in 0..3 { + event + .props + .push(("ComplexData".to_string(), "hex:00".to_string())); + } + event.props.push(( + "ComplexData".to_string(), + format!("hex:{}", hex(&legacy_ace(1, &sid))), + )); + assert!(extract_names(&event, &index).is_empty()); + } + + #[test] + fn rejects_zero_mask_and_malformed_ace_data() { + let sid = sid(); + let index = CapabilityIndex::for_test(&[("internetClient", &sid)]); + assert!(extract_names(&parts("Dacl", hex(&legacy_ace(0, &sid))), &index).is_empty()); + assert!(extract_names(&parts("Dacl", "00".to_string()), &index).is_empty()); + } + + #[test] + fn rejects_oversized_dacl_property_before_decoding() { + let value = "00".repeat(MAX_DACL_HEX_CHARS / 2 + 1); + assert!(matches!( + decode_hex(&value), + Err(DaclDecodeError::InputTooLarge) + )); + } + + #[test] + fn truncated_tail_preserves_capabilities_from_complete_aces() { + let sid = sid(); + let index = CapabilityIndex::for_test(&[("internetClient", &sid)]); + let mut bytes = standard_ace(1, &sid); + bytes.push(0); + let (names, error) = walk_aces(&bytes, &index); + assert_eq!(names, HashSet::from(["internetClient"])); + assert!(matches!(error, Some(DaclDecodeError::TruncatedAce(_)))); + } + + #[test] + fn truncated_legacy_ace_returns_error_instead_of_panicking() { + let bytes = vec![0; STANDARD_ACE_HEADER_SIZE + SID_FIXED_HEADER_SIZE]; + assert!(matches!( + parse_ace(&bytes, 0), + Err(DaclDecodeError::TruncatedAce(0)) + )); + } + + #[test] + fn dacl_property_matching_is_ascii_case_insensitive() { + assert!(is_dacl_property("Security_DESCRIPTOR")); + assert!(is_dacl_property("dAcLaCe")); + assert!(!is_dacl_property("Payload")); + } + + #[test] + fn ignores_non_permissive_provider() { + let event = DecodedEventParts { + provider: GUID::zeroed(), + event_id: ACCESS_CHECK_EVENT_ID, + props: Vec::new(), + }; + assert!(extract_denials(&event, 1, 2).is_empty()); + } + + #[test] + fn production_index_recovers_empty_object_name_capability() { + let sid = internet_client_sid(); + let event = parts("Dacl", hex(&legacy_ace(1, &sid))); + let denials = extract_denials(&event, 5900, 42); + assert_eq!(denials.len(), 1); + assert_eq!(denials[0].resource_type, ResourceType::Capability); + assert_eq!(denials[0].object_name, "internetClient"); + assert_eq!(denials[0].pid, 5900); + } + + #[test] + fn kernel_general_event_recovers_legacy_location_capability() { + let mut event = parts("DaclAce", "".to_string()); + event.provider = KERNEL_GENERAL_PROVIDER; + event.props.extend([ + ("AceType".to_string(), "0".to_string()), + ("AceFlags".to_string(), "0x0".to_string()), + ("AccessMask".to_string(), "0x20a10".to_string()), + ("Sid".to_string(), LEGACY_CAPABILITY_SIDS[0].1.to_string()), + ("SaclRevision".to_string(), "0".to_string()), + ]); + + let denials = extract_denials(&event, 1264, 42); + assert_eq!(denials.len(), 1); + assert_eq!(denials[0].resource_type, ResourceType::Capability); + assert_eq!(denials[0].object_name, "location"); + } + + #[test] + fn formats_sid_bytes_for_flattened_lookup() { + assert_eq!( + format_sid(&internet_client_sid()).as_deref(), + Some("S-1-15-3-1") + ); + assert!(format_sid(&[1, 2, 0]).is_none()); + } + + #[test] + fn resolves_legacy_location_sid() { + let index = build_capability_index(); + let sid = LEGACY_CAPABILITY_SIDS[0].1; + assert_eq!(index.resolve_string(sid), Some("location")); + assert_eq!( + parse_sid(sid).and_then(|sid| index.resolve(&sid)), + Some("location") + ); + } +} diff --git a/src/backends/learning_mode/windows/src/etl_decode.rs b/src/backends/learning_mode/windows/src/etl_decode.rs index 1258f4a3..b1b99fda 100644 --- a/src/backends/learning_mode/windows/src/etl_decode.rs +++ b/src/backends/learning_mode/windows/src/etl_decode.rs @@ -219,11 +219,18 @@ impl DenialAnalyzer for EtlDenialAnalyzer { /// provider manifests registered on the machine). #[cfg(test)] fn resources_from_events(events: &[CollectedEvent]) -> AnalysisResult { - dedup_to_resources( - events - .iter() - .filter_map(|e| extract_denial(&e.parts, e.pid, e.filetime)), - ) + let mut raws = Vec::new(); + for event in events { + if let Some(raw) = extract_denial(&event.parts, event.pid, event.filetime) { + raws.push(raw); + } + raws.extend(crate::capability_dacl::extract_denials( + &event.parts, + event.pid, + event.filetime, + )); + } + dedup_to_resources(raws) } /// Streams every decoded event in the ETL to `visitor` for schema discovery @@ -411,6 +418,13 @@ unsafe fn process_event_record(event_record: *mut EVENT_RECORD, acc: &mut Accumu { acc.add_raw_denial(raw); } + for raw in crate::capability_dacl::extract_denials( + &parts, + header.ProcessId, + header.TimeStamp as u64, + ) { + acc.add_raw_denial(raw); + } } CollectionMode::Raw => acc.visit_raw_event(&parts), }, @@ -646,16 +660,18 @@ mod tests { // module/extractor docs); a live ETW/TDH read isn't used because it // needs the provider manifests registered on the machine. - fn event(event_id: u16, pid: u32, filetime: u64, kv: &[(&str, &str)]) -> CollectedEvent { + fn event_with_provider( + provider: windows::core::GUID, + event_id: u16, + pid: u32, + filetime: u64, + kv: &[(&str, &str)], + ) -> CollectedEvent { CollectedEvent { pid, filetime, parts: DecodedEventParts { - provider: if event_id == 4907 { - crate::extractors::PRIVACY_LEARNING_MODE_PROVIDER - } else { - crate::extractors::KERNEL_GENERAL_PROVIDER - }, + provider, event_id, props: kv .iter() @@ -665,13 +681,38 @@ mod tests { } } + fn kernel_event(event_id: u16, pid: u32, filetime: u64, kv: &[(&str, &str)]) -> CollectedEvent { + event_with_provider( + crate::extractors::KERNEL_GENERAL_PROVIDER, + event_id, + pid, + filetime, + kv, + ) + } + + fn permissive_event( + event_id: u16, + pid: u32, + filetime: u64, + kv: &[(&str, &str)], + ) -> CollectedEvent { + event_with_provider( + crate::extractors::PRIVACY_LEARNING_MODE_PROVIDER, + event_id, + pid, + filetime, + kv, + ) + } + /// Mirrors the real `Mode="Normal"` (`block`) capture: file/registry /// access checks as event 14 plus a compact capability denial as event 28. #[test] fn block_shape_decodes_and_classifies() { let events = vec![ // File write (DELETE | FILE_READ_DATA -> Write), \??\ prefix. - event( + kernel_event( 14, 5480, 10, @@ -683,7 +724,7 @@ mod tests { ], ), // Registry read (KEY_READ 0x20019 -> Read) stays kernel-form. - event( + kernel_event( 14, 6860, 11, @@ -695,7 +736,7 @@ mod tests { ], ), // Capability denial (event 28) with a decoded identifier. - event( + kernel_event( 28, 0, 12, @@ -706,10 +747,11 @@ mod tests { ("PackageSid", "S-1-15-3-1"), ], ), + kernel_event(27, 5480, 13, &[("Category", "2"), ("Detail", "4")]), ]; let out = resources_from_events(&events).denials; - assert_eq!(out.len(), 3); + assert_eq!(out.len(), 4); assert_eq!(out[0].resource, r"C:\data\test\bin\"); assert_eq!(out[0].resource_type, ResourceType::File); @@ -724,15 +766,21 @@ mod tests { assert_eq!(out[2].resource_type, ResourceType::Capability); assert_eq!(out[2].access_type, AccessType::Unknown); assert_eq!(out[2].pid, 0x1acc, "pid from payload ProcessId"); + + assert_eq!(out[3].resource, "WriteClipboard"); + assert_eq!(out[3].resource_type, ResourceType::Ui); + assert_eq!(out[3].access_type, AccessType::Unknown); } /// Mirrors the real `Mode="Permissive"` (`allow`) capture: the same /// file/registry checks plus a capability check folded into an /// empty-`ObjectType` event 14 (there is no event 28 in this mode). #[test] - fn allow_shape_omits_unidentified_capability_event() { + fn allow_shape_recovers_capability_from_dacl() { + // DWORD-padded allow ACE carrying S-1-15-3-1 (internetClient). + let dacl = "hex:000000000000000001000000010200000000000F0300000001000000"; let events = vec![ - event( + permissive_event( 14, 2292, 20, @@ -744,7 +792,7 @@ mod tests { ], ), // Empty ObjectType == brokered-capability check. - event( + permissive_event( 14, 5900, 21, @@ -753,20 +801,29 @@ mod tests { ("ObjectType", "\"\""), ("ObjectName", "\"\""), ("AccessMask", "0x1"), + ("Dacl", dacl), ], ), + // UI violations continue to come from Kernel-General while the + // permissive provider supplies the access-check stream. + kernel_event(27, 2292, 22, &[("Category", "1"), ("Detail", "0")]), ]; let out = resources_from_events(&events).denials; - assert_eq!(out.len(), 1); + assert_eq!(out.len(), 3); assert_eq!(out[0].resource, r"C:\data\test\bin\"); assert_eq!(out[0].access_type, AccessType::Write); + assert_eq!(out[1].resource, "internetClient"); + assert_eq!(out[1].resource_type, ResourceType::Capability); + assert_eq!(out[1].pid, 5900); + assert_eq!(out[2].resource, "ConvertToGui"); + assert_eq!(out[2].resource_type, ResourceType::Ui); } #[test] fn unidentified_capability_events_are_omitted() { let events = vec![ - event( + kernel_event( 14, 1, 1, @@ -776,8 +833,8 @@ mod tests { ("AccessMask", "0x1"), ], ), - event(28, 0, 2, &[("ProcessId", "0x10"), ("Denied", "true")]), - event(28, 0, 3, &[("ProcessId", "0x20"), ("Denied", "true")]), + kernel_event(28, 0, 2, &[("ProcessId", "0x10"), ("Denied", "true")]), + kernel_event(28, 0, 3, &[("ProcessId", "0x20"), ("Denied", "true")]), ]; assert!(resources_from_events(&events).denials.is_empty()); } @@ -787,10 +844,34 @@ mod tests { #[test] fn non_actionable_events_are_dropped() { let events = vec![ - event(14, 1, 1, &[("ObjectType", "\"Section\"")]), - event(28, 0, 2, &[("ProcessId", "0x10"), ("Denied", "false")]), - event(9999, 1, 3, &[("Foo", "\"bar\"")]), + kernel_event(14, 1, 1, &[("ObjectType", "\"Section\"")]), + kernel_event(28, 0, 2, &[("ProcessId", "0x10"), ("Denied", "false")]), + kernel_event(9999, 1, 3, &[("Foo", "\"bar\"")]), ]; assert!(resources_from_events(&events).denials.is_empty()); } + + #[test] + fn provider_event_vocabulary_is_enforced_in_composition() { + let events = vec![ + permissive_event( + 28, + 0, + 1, + &[("Denied", "true"), ("PackageSid", "S-1-15-3-1")], + ), + kernel_event( + 4907, + 1, + 2, + &[ + ("ObjectType", "\"File\""), + ("ObjectName", "\"C:\\wrong-provider.txt\""), + ("AccessMask", "0x1"), + ], + ), + ]; + + assert!(resources_from_events(&events).denials.is_empty()); + } } diff --git a/src/backends/learning_mode/windows/src/extractors.rs b/src/backends/learning_mode/windows/src/extractors.rs index 5a9e1c79..2f2850e5 100644 --- a/src/backends/learning_mode/windows/src/extractors.rs +++ b/src/backends/learning_mode/windows/src/extractors.rs @@ -212,18 +212,13 @@ pub fn build_denial_from_learning_mode( pid: u32, filetime: u64, ) -> Option { - let category = find_prop(&parts.props, "Category")? - .trim_matches('"') - .to_string(); - let detail = find_prop(&parts.props, "Detail")?.trim_matches('"'); - let object_name = match category.as_str() { - "1" => "ConvertToGui".to_string(), - "2" => parse_u32(detail) - .and_then(ui_operation_name) - .map(str::to_string) - .unwrap_or_else(|| format!("UiOperation({detail})")), - _ => format!("Category({category})/Detail({detail})"), + let category = find_prop(&parts.props, "Category").and_then(|value| parse_u32(value))?; + let detail = match find_prop(&parts.props, "Detail") { + Some(value) => parse_u32(value)?, + None if category == crate::ui::CONVERT_TO_GUI => 0, + None => return None, }; + let object_name = crate::ui::resource_name(category, detail)?; Some(RawDenial { pid, @@ -292,22 +287,6 @@ pub fn build_denial_from_capability( }) } -fn ui_operation_name(value: u32) -> Option<&'static str> { - match value { - 0x001 => Some("Handles"), - 0x002 => Some("ReadClipboard"), - 0x004 => Some("WriteClipboard"), - 0x008 => Some("SystemParameters"), - 0x010 => Some("DisplaySettings"), - 0x020 => Some("GlobalAtoms"), - 0x040 => Some("Desktop"), - 0x080 => Some("ExitWindows"), - 0x100 => Some("IME"), - 0x200 => Some("Injection"), - _ => None, - } -} - /// Parses a `"0x…"` / decimal / bare-hex property value into a `u32`. /// /// The `AccessMask` and `ProcessId` templates render as `win:HexInt32`, so @@ -594,6 +573,76 @@ mod tests { assert_eq!(ev.object_name, "WriteClipboard"); } + #[test] + fn learning_mode_violation_accepts_hex_category_and_detail() { + let p = parts( + 27, + &[ + ("ProcessName", "\"caller.exe\""), + ("Category", "0x2"), + ("Detail", "0x200"), + ], + ); + let ev = extract_denial(&p, 9999, FIXED_FILETIME).expect("should extract"); + assert_eq!(ev.object_name, "Injection"); + } + + #[test] + fn learning_mode_violation_maps_every_ui_limit() { + let expected = [ + (0x0001, "Handles"), + (0x0002, "ReadClipboard"), + (0x0004, "WriteClipboard"), + (0x0008, "SystemParameters"), + (0x0010, "DisplaySettings"), + (0x0020, "GlobalAtoms"), + (0x0040, "Desktop"), + (0x0080, "ExitWindows"), + (0x0100, "IME"), + (0x0200, "Injection"), + ]; + + for (detail, name) in expected { + let detail = detail.to_string(); + let p = parts(27, &[("Category", "2"), ("Detail", detail.as_str())]); + let ev = extract_denial(&p, 9999, FIXED_FILETIME).expect("should extract"); + assert_eq!(ev.object_name, name); + } + } + + #[test] + fn learning_mode_violation_is_supported_from_permissive_provider() { + let p = parts_with_provider( + PRIVACY_LEARNING_MODE_PROVIDER, + 27, + &[("Category", "1"), ("Detail", "0")], + ); + let ev = extract_denial(&p, 9999, FIXED_FILETIME).expect("should extract"); + assert_eq!(ev.object_name, "ConvertToGui"); + } + + #[test] + fn convert_to_gui_does_not_require_detail() { + let p = parts(27, &[("Category", "1")]); + let ev = extract_denial(&p, 9999, FIXED_FILETIME).expect("should extract"); + assert_eq!(ev.object_name, "ConvertToGui"); + } + + #[test] + fn learning_mode_violation_rejects_invalid_required_numbers() { + let invalid_category = parts(27, &[("Category", "not-a-number"), ("Detail", "4")]); + assert!(extract_denial(&invalid_category, 9999, FIXED_FILETIME).is_none()); + + let invalid_detail = parts(27, &[("Category", "2"), ("Detail", "not-a-number")]); + assert!(extract_denial(&invalid_detail, 9999, FIXED_FILETIME).is_none()); + } + + #[test] + fn learning_mode_category_none_is_dropped() { + let p = parts(27, &[("Category", "0"), ("Detail", "0")]); + assert!(extract_denial(&p, 9999, FIXED_FILETIME).is_none()); + } + // ---- event 28 capability denial --------------------------------------- #[test] diff --git a/src/backends/learning_mode/windows/src/lib.rs b/src/backends/learning_mode/windows/src/lib.rs index 213cf2ee..c5d73eac 100644 --- a/src/backends/learning_mode/windows/src/lib.rs +++ b/src/backends/learning_mode/windows/src/lib.rs @@ -35,6 +35,8 @@ mod lifecycle; #[cfg(target_os = "windows")] mod secenv; +#[cfg(target_os = "windows")] +mod capability_dacl; #[cfg(target_os = "windows")] mod capability_names; #[cfg(target_os = "windows")] @@ -45,6 +47,8 @@ mod extractors; mod path_norm; #[cfg(target_os = "windows")] mod tdh_decode; +#[cfg(target_os = "windows")] +mod ui; #[cfg(target_os = "windows")] pub use etl_decode::{visit_raw_events, EtlDenialAnalyzer}; diff --git a/src/backends/learning_mode/windows/src/tdh_decode.rs b/src/backends/learning_mode/windows/src/tdh_decode.rs index d5d7cfcf..87c759a8 100644 --- a/src/backends/learning_mode/windows/src/tdh_decode.rs +++ b/src/backends/learning_mode/windows/src/tdh_decode.rs @@ -10,6 +10,7 @@ //! consistent without wasting cycles on unsupported encodings. use std::collections::HashMap; +use std::fmt::Write as _; use windows::core::GUID; use windows::Win32::System::Diagnostics::Etw::{ @@ -33,6 +34,7 @@ const TDH_INTYPE_UINT64: u16 = 10; const TDH_INTYPE_FLOAT: u16 = 11; const TDH_INTYPE_DOUBLE: u16 = 12; const TDH_INTYPE_BOOLEAN: u16 = 13; +const TDH_INTYPE_BINARY: u16 = 14; const TDH_INTYPE_GUID: u16 = 15; const TDH_INTYPE_POINTER: u16 = 16; const TDH_INTYPE_FILETIME: u16 = 17; @@ -609,6 +611,15 @@ fn format_property_value_with_pointer_size( 8, ), TDH_INTYPE_SID if available >= 8 => format_sid(data, available), + TDH_INTYPE_BINARY if declared_length > 0 && declared_length <= available => { + let bytes = unsafe { std::slice::from_raw_parts(data, declared_length) }; + let mut rendered = String::with_capacity(4 + declared_length * 2); + rendered.push_str("hex:"); + for byte in bytes { + let _ = write!(rendered, "{byte:02X}"); + } + (rendered, declared_length) + } // Known-but-unformatted fixed-width values still have an implicit // payload size when TDH reports a zero declared length. _ => { @@ -1198,4 +1209,13 @@ mod tests { assert_eq!(val, ""); assert_eq!(consumed, bytes.len()); } + + #[test] + fn format_property_value_binary_preserves_hex_payload() { + let bytes = [0x00, 0x0a, 0xfe, 0xff]; + let (value, consumed) = + format_property_value(TDH_INTYPE_BINARY, bytes.len(), bytes.as_ptr(), bytes.len()); + assert_eq!(value, "hex:000AFEFF"); + assert_eq!(consumed, bytes.len()); + } } diff --git a/src/backends/learning_mode/windows/src/ui.rs b/src/backends/learning_mode/windows/src/ui.rs new file mode 100644 index 00000000..c20768c6 --- /dev/null +++ b/src/backends/learning_mode/windows/src/ui.rs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Learning-mode UI violation vocabulary. +//! +//! Event 27 reports a category and detail value. UI-operation details use the +//! `JOB_OBJECT_UILIMIT_*` constants from winnt.h; keeping the mapping here gives +//! the canonical ETL analyzer one source of truth for both block and allow +//! traces. + +/// The workload attempted an operation requiring the Win32k GUI subsystem. +pub(crate) const CONVERT_TO_GUI: u32 = 1; + +/// The workload attempted an operation blocked by a Job UI limit. +pub(crate) const UI_OPERATION: u32 = 2; + +const UI_LIMIT_NAMES: &[(u32, &str)] = &[ + (0x0001, "Handles"), + (0x0002, "ReadClipboard"), + (0x0004, "WriteClipboard"), + (0x0008, "SystemParameters"), + (0x0010, "DisplaySettings"), + (0x0020, "GlobalAtoms"), + (0x0040, "Desktop"), + (0x0080, "ExitWindows"), + (0x0100, "IME"), + (0x0200, "Injection"), +]; + +/// Returns the canonical denial resource for an Event 27 category/detail pair. +/// +/// Category zero means no violation and is omitted. Unknown nonzero categories +/// remain diagnostic so a new OS category is visible without being mistaken +/// for a known policy relaxation. +pub(crate) fn resource_name(category: u32, detail: u32) -> Option { + Some(match category { + 0 => return None, + CONVERT_TO_GUI => "ConvertToGui".to_string(), + UI_OPERATION => ui_operation_name(detail) + .map(str::to_string) + .unwrap_or_else(|| format!("UiOperation({detail})")), + _ => format!("Category({category})/Detail({detail})"), + }) +} + +fn ui_operation_name(value: u32) -> Option<&'static str> { + UI_LIMIT_NAMES + .iter() + .find_map(|(flag, name)| (*flag == value).then_some(*name)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_job_ui_limit_has_a_canonical_resource_name() { + let expected = [ + (0x0001, "Handles"), + (0x0002, "ReadClipboard"), + (0x0004, "WriteClipboard"), + (0x0008, "SystemParameters"), + (0x0010, "DisplaySettings"), + (0x0020, "GlobalAtoms"), + (0x0040, "Desktop"), + (0x0080, "ExitWindows"), + (0x0100, "IME"), + (0x0200, "Injection"), + ]; + + for (detail, name) in expected { + assert_eq!(resource_name(UI_OPERATION, detail).as_deref(), Some(name)); + } + } + + #[test] + fn unknown_values_remain_diagnostic() { + assert_eq!( + resource_name(UI_OPERATION, 0x400).as_deref(), + Some("UiOperation(1024)") + ); + assert_eq!( + resource_name(99, 7).as_deref(), + Some("Category(99)/Detail(7)") + ); + } + + #[test] + fn category_none_is_not_a_denial() { + assert_eq!(resource_name(0, 0), None); + } +}