Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,9 @@ Sigma and YARA rules, YARA scan limits, and optional memory scanning.
| `yara_memory_delay_ms` | `750` | Wait after process start before reading memory, so packed code can unpack. |
| `yara_memory_max_process_mb` | `64` | Stop reading a process after this many MB. |
| `yara_memory_max_region_mb` | `8` | Most memory read from one region at a time, in MB. |
| `yara_memory_include_private` | `true` | Scan private (anonymous) memory. On Linux, includes unnamed anonymous mappings, `[heap]`, and `[stack]`; other bracket-named mappings such as `[vdso]`, `[vvar]`, and `[vsyscall]` are excluded. On macOS, the dyld shared cache counts as library memory and is scanned under the image and mapped options instead. |
| `yara_memory_include_private` | `true` | Scan private (anonymous) memory. On Linux, includes unnamed anonymous mappings, `[heap]`, and `[stack]`; other bracket-named mappings such as `[vdso]`, `[vvar]`, and `[vsyscall]` are excluded. On macOS, the dyld shared cache counts as library memory and is scanned under the image and mapped options instead. On Windows, committed `MEM_PRIVATE` regions are scanned. |
| `yara_memory_include_image` | `false` | Scan memory backed by executables and libraries. Linux memfd executions include memfd image mappings even when this is disabled. |
| `yara_memory_include_mapped` | `false` | Scan memory-mapped files. |
| `yara_memory_include_mapped` | `false` | Scan memory-mapped files. On Windows, a committed region is scanned when its base protection is readable (read-only, read-write, write-copy, execute-read, execute-read-write, or execute-write-copy), whatever modifier bits such as `PAGE_NOCACHE` or `PAGE_WRITECOMBINE` accompany it. `PAGE_GUARD`, `PAGE_NOACCESS`, and execute-only regions are never read, and reserved and free regions are not scanned. At debug level the `scanner` target logs one region summary per process, separating `excluded_protection` and `excluded_kind` (never read) from `read_failed` (eligible but the read failed). |

### `[allowlist]`

Expand Down
4 changes: 2 additions & 2 deletions src/config/reference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ pub const CONFIG_OPTIONS: &[ConfigOption] = &[
ConfigOption {
key: "scanner.yara_memory_include_private",
default_note: None,
description: "Scan private (anonymous) memory. On Linux, includes unnamed anonymous mappings, `[heap]`, and `[stack]`; other bracket-named mappings such as `[vdso]`, `[vvar]`, and `[vsyscall]` are excluded. On macOS, the dyld shared cache counts as library memory and is scanned under the image and mapped options instead.",
description: "Scan private (anonymous) memory. On Linux, includes unnamed anonymous mappings, `[heap]`, and `[stack]`; other bracket-named mappings such as `[vdso]`, `[vvar]`, and `[vsyscall]` are excluded. On macOS, the dyld shared cache counts as library memory and is scanned under the image and mapped options instead. On Windows, committed `MEM_PRIVATE` regions are scanned.",
},
ConfigOption {
key: "scanner.yara_memory_include_image",
Expand All @@ -174,7 +174,7 @@ pub const CONFIG_OPTIONS: &[ConfigOption] = &[
ConfigOption {
key: "scanner.yara_memory_include_mapped",
default_note: None,
description: "Scan memory-mapped files.",
description: "Scan memory-mapped files. On Windows, a committed region is scanned when its base protection is readable (read-only, read-write, write-copy, execute-read, execute-read-write, or execute-write-copy), whatever modifier bits such as `PAGE_NOCACHE` or `PAGE_WRITECOMBINE` accompany it. `PAGE_GUARD`, `PAGE_NOACCESS`, and execute-only regions are never read, and reserved and free regions are not scanned. At debug level the `scanner` target logs one region summary per process, separating `excluded_protection` and `excluded_kind` (never read) from `read_failed` (eligible but the read failed).",
},
// allowlist
ConfigOption {
Expand Down
3 changes: 3 additions & 0 deletions src/memory/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

mod types;

#[cfg(any(windows, test))]
mod protection;

#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
Expand Down
138 changes: 138 additions & 0 deletions src/memory/protection.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
//! Windows page-protection classification.
//!
//! `MEMORY_BASIC_INFORMATION.Protect` carries one base protection in the low
//! byte plus modifier bits such as `PAGE_GUARD`, `PAGE_NOCACHE`, and
//! `PAGE_WRITECOMBINE`.
//! Classification masks the modifiers off first, so a modified page is judged by its base protection.
//! Guard and no-access pages are excluded explicitly.

const PAGE_NOACCESS: u32 = 0x01;
const PAGE_READONLY: u32 = 0x02;
const PAGE_READWRITE: u32 = 0x04;
const PAGE_WRITECOPY: u32 = 0x08;
const PAGE_EXECUTE: u32 = 0x10;
const PAGE_EXECUTE_READ: u32 = 0x20;
const PAGE_EXECUTE_READWRITE: u32 = 0x40;
const PAGE_EXECUTE_WRITECOPY: u32 = 0x80;
const PAGE_GUARD: u32 = 0x100;
const BASE_MASK: u32 = 0xff;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Protection {
/// Readable base protection without the guard modifier.
Readable { writable: bool, executable: bool },
/// `PAGE_GUARD` is set: a read would raise a guard-page exception and clear the guard.
Guard,
/// `PAGE_NOACCESS`.
NoAccess,
/// Not readable, such as `PAGE_EXECUTE` (execute-only) or an unknown base value.
Unreadable,
}

pub(super) fn classify(protect: u32) -> Protection {
if protect & PAGE_GUARD != 0 {
return Protection::Guard;
}
match protect & BASE_MASK {
PAGE_NOACCESS => Protection::NoAccess,
PAGE_READONLY => Protection::Readable {
writable: false,
executable: false,
},
PAGE_READWRITE | PAGE_WRITECOPY => Protection::Readable {
writable: true,
executable: false,
},
PAGE_EXECUTE_READ => Protection::Readable {
writable: false,
executable: true,
},
PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY => Protection::Readable {
writable: true,
executable: true,
},
PAGE_EXECUTE => Protection::Unreadable,
_ => Protection::Unreadable,
}
}

#[cfg(test)]
mod tests {
use super::*;

const PAGE_NOCACHE: u32 = 0x200;
const PAGE_WRITECOMBINE: u32 = 0x400;
const PAGE_TARGETS_INVALID: u32 = 0x4000_0000;

fn readable(writable: bool, executable: bool) -> Protection {
Protection::Readable {
writable,
executable,
}
}

#[test]
fn readable_bases_classify_with_and_without_modifiers() {
let bases = [
(PAGE_READONLY, readable(false, false)),
(PAGE_READWRITE, readable(true, false)),
(PAGE_WRITECOPY, readable(true, false)),
(PAGE_EXECUTE_READ, readable(false, true)),
(PAGE_EXECUTE_READWRITE, readable(true, true)),
(PAGE_EXECUTE_WRITECOPY, readable(true, true)),
];
let modifiers = [
0,
PAGE_NOCACHE,
PAGE_WRITECOMBINE,
PAGE_NOCACHE | PAGE_WRITECOMBINE,
PAGE_TARGETS_INVALID,
PAGE_WRITECOMBINE | PAGE_TARGETS_INVALID,
];
for (base, expected) in bases {
for modifier in modifiers {
assert_eq!(
classify(base | modifier),
expected,
"base {base:#x} modifier {modifier:#x}"
);
}
}
}

#[test]
fn guard_pages_are_excluded_for_every_base() {
for base in [
PAGE_NOACCESS,
PAGE_READONLY,
PAGE_READWRITE,
PAGE_WRITECOPY,
PAGE_EXECUTE,
PAGE_EXECUTE_READ,
PAGE_EXECUTE_READWRITE,
PAGE_EXECUTE_WRITECOPY,
] {
assert_eq!(classify(base | PAGE_GUARD), Protection::Guard);
assert_eq!(
classify(base | PAGE_GUARD | PAGE_NOCACHE),
Protection::Guard
);
}
}

#[test]
fn no_access_is_excluded_with_modifiers() {
assert_eq!(classify(PAGE_NOACCESS), Protection::NoAccess);
assert_eq!(classify(PAGE_NOACCESS | PAGE_NOCACHE), Protection::NoAccess);
}

#[test]
fn execute_only_and_unknown_are_unreadable() {
assert_eq!(classify(PAGE_EXECUTE), Protection::Unreadable);
assert_eq!(
classify(PAGE_EXECUTE | PAGE_NOCACHE),
Protection::Unreadable
);
assert_eq!(classify(0), Protection::Unreadable);
}
}
142 changes: 110 additions & 32 deletions src/memory/windows.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use super::protection::{classify, Protection};
use super::{MemoryChunk, MemoryRegion, MemoryRegionKind, MemoryScanConfig, RegionReader};
use anyhow::Result;
use std::ops::ControlFlow;
Expand All @@ -6,37 +7,19 @@ use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Diagnostics::Debug::ReadProcessMemory;
use windows::Win32::System::Memory::{
VirtualQueryEx, MEMORY_BASIC_INFORMATION, MEM_COMMIT, MEM_IMAGE, MEM_MAPPED, MEM_PRIVATE,
PAGE_EXECUTE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD,
PAGE_NOACCESS, PAGE_PROTECTION_FLAGS, PAGE_READONLY, PAGE_READWRITE, PAGE_WRITECOPY,
};
use windows::Win32::System::Threading::{
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ,
};

fn is_readable(protect: PAGE_PROTECTION_FLAGS) -> bool {
matches!(
protect,
PAGE_READONLY
| PAGE_READWRITE
| PAGE_WRITECOPY
| PAGE_EXECUTE_READ
| PAGE_EXECUTE_READWRITE
| PAGE_EXECUTE_WRITECOPY
)
}

fn is_writable(protect: PAGE_PROTECTION_FLAGS) -> bool {
matches!(
protect,
PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
)
}

fn is_executable(protect: PAGE_PROTECTION_FLAGS) -> bool {
matches!(
protect,
PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
)
/// Per-process region counts, so an excluded region is distinguishable from a failed read.
/// `excluded_*` regions were never read; `read_failed` regions were eligible but unreadable.
#[derive(Default)]
struct RegionStats {
eligible: usize,
excluded_protection: usize,
excluded_kind: usize,
read_failed: usize,
}

pub fn visit_process_memory_chunks(
Expand Down Expand Up @@ -67,6 +50,7 @@ pub fn visit_process_memory_chunks(

let mut reader = RegionReader::new(cfg, deadline);
let mut address: usize = 0;
let mut stats = RegionStats::default();

loop {
if reader.is_done() {
Expand Down Expand Up @@ -99,10 +83,16 @@ pub fn visit_process_memory_chunks(
continue;
}

let protect = mbi.Protect;
if protect == PAGE_NOACCESS || protect.contains(PAGE_GUARD) || !is_readable(protect) {
continue;
}
let (writable, executable) = match classify(mbi.Protect.0) {
Protection::Readable {
writable,
executable,
} => (writable, executable),
Protection::Guard | Protection::NoAccess | Protection::Unreadable => {
stats.excluded_protection += 1;
continue;
}
};

let kind = if mbi.Type == MEM_PRIVATE {
MemoryRegionKind::Private
Expand All @@ -122,18 +112,20 @@ pub fn visit_process_memory_chunks(
};

if !include {
stats.excluded_kind += 1;
continue;
}

let region = MemoryRegion {
base: region_base as u64,
size: region_size,
readable: true,
writable: is_writable(protect),
executable: is_executable(protect),
writable,
executable,
kind,
};

stats.eligible += 1;
if reader
.read_region(
region,
Expand All @@ -149,6 +141,7 @@ pub fn visit_process_memory_chunks(
)
};
if result.is_err() || bytes_read == 0 {
stats.read_failed += 1;
tracing::trace!(
target: "scanner",
pid = pid,
Expand All @@ -172,5 +165,90 @@ pub fn visit_process_memory_chunks(
let _ = CloseHandle(handle);
}

tracing::debug!(
target: "scanner",
pid = pid,
eligible = stats.eligible,
excluded_protection = stats.excluded_protection,
excluded_kind = stats.excluded_kind,
read_failed = stats.read_failed,
"YARA memory: region summary"
);

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use windows::Win32::System::Memory::{
VirtualAlloc, VirtualFree, VirtualProtect, MEM_RELEASE, MEM_RESERVE, PAGE_GUARD,
PAGE_PROTECTION_FLAGS, PAGE_READWRITE,
};

const PAGE_NOCACHE: u32 = 0x200;
const PAGE_WRITECOMBINE: u32 = 0x400;

fn scan_config() -> MemoryScanConfig {
MemoryScanConfig {
max_process_bytes: usize::MAX / 2,
max_region_bytes: 1 << 20,
include_private: true,
include_image: false,
include_mapped: false,
delay_ms: 0,
}
}

/// Whether a scanned chunk covers `page`. Matching by address avoids finding a copy of
/// the marker on this test's own heap.
fn page_is_scanned(page: usize) -> bool {
let mut found = false;
visit_process_memory_chunks(std::process::id(), &scan_config(), None, |chunk| {
let start = chunk.base as usize;
if (start..start + chunk.bytes.len()).contains(&page) {
found = true;
return ControlFlow::Break(());
}
ControlFlow::Continue(())
})
.unwrap();
found
}

/// Commit one private page, apply `protect`, and run `check` with the page address.
fn with_page(protect: u32, check: impl FnOnce(usize)) {
unsafe {
let page = VirtualAlloc(None, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
assert!(!page.is_null());
std::ptr::write_bytes(page as *mut u8, 0x41, 4096);
let mut old = PAGE_PROTECTION_FLAGS(0);
VirtualProtect(page, 4096, PAGE_PROTECTION_FLAGS(protect), &mut old).unwrap();
check(page as usize);
let _ = VirtualFree(page, 0, MEM_RELEASE);
}
}

#[test]
fn pages_with_modifier_bits_are_scanned() {
for (name, protect) in [
("rw", 0x04),
("rw+nocache", 0x04 | PAGE_NOCACHE),
("rw+writecombine", 0x04 | PAGE_WRITECOMBINE),
("ro+nocache", 0x02 | PAGE_NOCACHE),
] {
with_page(protect, |page| {
assert!(page_is_scanned(page), "{name} page not scanned");
});
}
}

#[test]
fn guard_and_no_access_pages_are_not_scanned() {
for (name, protect) in [("guard", 0x04 | PAGE_GUARD.0), ("noaccess", 0x01)] {
with_page(protect, |page| {
assert!(!page_is_scanned(page), "{name} page was scanned");
});
}
}
}
Loading