YARA Memory Scanning
Overview
YARA was designed for file scanning, but its most valuable application in malware detection is against live process memory. Running a rule set across every mapped region of every process finds:
- Decrypted shellcode that was never written to disk
- PE headers in private memory (hollowed or reflectively-loaded images)
- Beacon configuration blobs embedded in allocated buffers
- Direct-syscall stubs in non-image executable memory
Memory scanning is noisy on production systems without careful tuning. The pages below walk through writing effective rules, limiting scan scope to high-signal regions, and deploying them in both offline forensics (Volatility) and continuous on-host contexts.
Scanning every page of every process is expensive. A practical on-host scanner focuses on
private (MEM_PRIVATE), executable (PAGE_EXECUTE_*) regions. That filter reduces the scan
surface by 90% or more on a typical Windows host while covering all the high-value injection
targets.
Anatomy of a useful memory rule
A memory rule differs from a file rule in two ways: it cannot rely on PE structure (there may be no headers), and it must tolerate variable base addresses and ASLR. Good memory rules use byte strings, not PE section references:
rule ProcessHollow_MZ_In_Private_Memory
{
meta:
description = "MZ header mapped as MEM_PRIVATE — not backed by a file on disk"
author = "maldev-wiki"
mitre = "T1055.012"
strings:
$mz = { 4D 5A } // MZ magic
$pe_sig = { 50 45 00 00 } // PE\0\0 from NT headers offset
condition:
// MZ at offset 0 of the region, PE sig within the first 512 bytes
$mz at 0 and $pe_sig in (0..512)
}rule ProcessHollow_MZ_In_Private_Memory
{
meta:
description = "MZ header mapped as MEM_PRIVATE — not backed by a file on disk"
author = "maldev-wiki"
mitre = "T1055.012"
strings:
$mz = { 4D 5A } // MZ magic
$pe_sig = { 50 45 00 00 } // PE\0\0 from NT headers offset
condition:
// MZ at offset 0 of the region, PE sig within the first 512 bytes
$mz at 0 and $pe_sig in (0..512)
}rule DirectSyscall_Stub
{
meta:
description = "mov r10,rcx / mov eax,imm / syscall in non-image memory"
author = "maldev-wiki"
mitre = "T1106"
strings:
// 49 89 CA = mov r10, rcx
// B8 ?? ?? ?? ?? = mov eax, <SSN>
// 0F 05 = syscall
$stub = { 49 89 CA B8 ?? ?? ?? ?? 0F 05 }
condition:
$stub
}rule DirectSyscall_Stub
{
meta:
description = "mov r10,rcx / mov eax,imm / syscall in non-image memory"
author = "maldev-wiki"
mitre = "T1106"
strings:
// 49 89 CA = mov r10, rcx
// B8 ?? ?? ?? ?? = mov eax, <SSN>
// 0F 05 = syscall
$stub = { 49 89 CA B8 ?? ?? ?? ?? 0F 05 }
condition:
$stub
}rule CobaltStrike_Beacon_Config
{
meta:
description = "XOR-0x69 encoded Cobalt Strike beacon configuration block"
author = "maldev-wiki"
reference = "SentinelOne beacon config parser"
mitre = "T1071.001"
strings:
// Config block starts with a magic that XORs to 0x0001 after key
// This is the decoded magic after XOR with 0x69
$magic = { 00 01 }
// Typical config field type markers after decoding
$field_marker = { 00 01 00 01 }
condition:
for any i in (1..#magic) : (
uint16(@magic[i]) == 0x0100
)
}rule CobaltStrike_Beacon_Config
{
meta:
description = "XOR-0x69 encoded Cobalt Strike beacon configuration block"
author = "maldev-wiki"
reference = "SentinelOne beacon config parser"
mitre = "T1071.001"
strings:
// Config block starts with a magic that XORs to 0x0001 after key
// This is the decoded magic after XOR with 0x69
$magic = { 00 01 }
// Typical config field type markers after decoding
$field_marker = { 00 01 00 01 }
condition:
for any i in (1..#magic) : (
uint16(@magic[i]) == 0x0100
)
}Scanning live memory in Python
import ctypes, ctypes.wintypes as wt, yara, sys
# Compile all rules from a directory
rules = yara.compile(filepaths={
f: f for f in __import__('glob').glob('rules/*.yar')
})
PROCESS_VM_READ = 0x0010
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT = 0x1000
PAGE_EXECUTE_READ = 0x20
PAGE_EXECUTE_READWRITE = 0x40
PAGE_EXECUTE_WRITECOPY = 0x80
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
_fields_ = [
("BaseAddress", wt.LPVOID),
("AllocationBase", wt.LPVOID),
("AllocationProtect", wt.DWORD),
("RegionSize", ctypes.c_size_t),
("State", wt.DWORD),
("Protect", wt.DWORD),
("Type", wt.DWORD),
]
def scan_pid(pid):
k32 = ctypes.windll.kernel32
proc = k32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION,
False, pid)
if not proc:
return
addr = 0
mbi = MEMORY_BASIC_INFORMATION()
sz = ctypes.sizeof(mbi)
EXEC = {PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY}
while k32.VirtualQueryEx(proc, addr, ctypes.byref(mbi), sz):
# only scan committed, private, executable pages
if (mbi.State == MEM_COMMIT and
mbi.Protect in EXEC and
mbi.Type == 0x20000): # MEM_PRIVATE
buf = (ctypes.c_char * mbi.RegionSize)()
read = wt.SIZE_T(0)
k32.ReadProcessMemory(proc, addr, buf, mbi.RegionSize,
ctypes.byref(read))
if read.value:
matches = rules.match(data=bytes(buf))
for m in matches:
print(f"[!] PID {pid} @ {addr:#x} — {m.rule}")
addr += mbi.RegionSize
k32.CloseHandle(proc)
if __name__ == "__main__":
scan_pid(int(sys.argv[1]))import ctypes, ctypes.wintypes as wt, yara, sys
# Compile all rules from a directory
rules = yara.compile(filepaths={
f: f for f in __import__('glob').glob('rules/*.yar')
})
PROCESS_VM_READ = 0x0010
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT = 0x1000
PAGE_EXECUTE_READ = 0x20
PAGE_EXECUTE_READWRITE = 0x40
PAGE_EXECUTE_WRITECOPY = 0x80
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
_fields_ = [
("BaseAddress", wt.LPVOID),
("AllocationBase", wt.LPVOID),
("AllocationProtect", wt.DWORD),
("RegionSize", ctypes.c_size_t),
("State", wt.DWORD),
("Protect", wt.DWORD),
("Type", wt.DWORD),
]
def scan_pid(pid):
k32 = ctypes.windll.kernel32
proc = k32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION,
False, pid)
if not proc:
return
addr = 0
mbi = MEMORY_BASIC_INFORMATION()
sz = ctypes.sizeof(mbi)
EXEC = {PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY}
while k32.VirtualQueryEx(proc, addr, ctypes.byref(mbi), sz):
# only scan committed, private, executable pages
if (mbi.State == MEM_COMMIT and
mbi.Protect in EXEC and
mbi.Type == 0x20000): # MEM_PRIVATE
buf = (ctypes.c_char * mbi.RegionSize)()
read = wt.SIZE_T(0)
k32.ReadProcessMemory(proc, addr, buf, mbi.RegionSize,
ctypes.byref(read))
if read.value:
matches = rules.match(data=bytes(buf))
for m in matches:
print(f"[!] PID {pid} @ {addr:#x} — {m.rule}")
addr += mbi.RegionSize
k32.CloseHandle(proc)
if __name__ == "__main__":
scan_pid(int(sys.argv[1]))Offline scanning with Volatility 3
# Scan all process memory in a captured image
$ vol -f memory.raw windows.vadyarascan --yara-rules rules/process_hollow.yar --pid 0 # 0 = all processes
# Target a specific process by name
$ vol -f memory.raw windows.vadyarascan --yara-rules rules/direct_syscall.yar --pid $(vol -f memory.raw windows.pslist | grep svchost | awk '{print $2}')
# Search for strings across all memory (quick triage)
$ vol -f memory.raw windows.vadyarascan --yara-string "/C2_HOSTNAME/" # grep-style for IOC hunting# Scan all process memory in a captured image
$ vol -f memory.raw windows.vadyarascan --yara-rules rules/process_hollow.yar --pid 0 # 0 = all processes
# Target a specific process by name
$ vol -f memory.raw windows.vadyarascan --yara-rules rules/direct_syscall.yar --pid $(vol -f memory.raw windows.pslist | grep svchost | awk '{print $2}')
# Search for strings across all memory (quick triage)
$ vol -f memory.raw windows.vadyarascan --yara-string "/C2_HOSTNAME/" # grep-style for IOC huntingRule quality checklist
| Criterion | Guidance |
|---|---|
| Specificity | Rule should not match anything in a clean Windows install |
| Resilience | Avoid offsets that change with ASLR; use relative byte sequences |
| Performance | Limit wide patterns (?? wildcards); anchor with short fixed strings |
| Scope | Memory rules: add pe.is_pe only if the target is a full PE; omit for shellcode |
| Version coverage | Test against multiple Windows versions and build dates |
False-positive rate on production is the hardest thing to get right. An on-host scanner that
generates one alert per host per day will be ignored within a week. Tune against a known-clean
golden image before deploying to production, and suppress on the %SystemRoot% path if scanning
on-disk.