515 lines
15 KiB
Rust
515 lines
15 KiB
Rust
// ©AngelaMos | 2026
|
|
// yara.rs
|
|
//
|
|
// YARA rule scanner with builtin detection rules
|
|
//
|
|
// Embeds 14 YARA rules as a compile-time constant covering
|
|
// UPX packing, anti-debugging (Windows and Linux), process
|
|
// injection, keylogger APIs, crypto mining, Windows and
|
|
// Linux persistence mechanisms, network backdoors,
|
|
// ransomware indicators, shellcode patterns (NOP sleds, egg
|
|
// hunters), obfuscation (XOR loops, base64 alphabet), C2
|
|
// endpoint paths, and credential file access. YaraScanner
|
|
// wraps a compiled yara_x::Rules instance. new() compiles
|
|
// only the builtin ruleset; with_custom_rules() also loads
|
|
// .yar/.yara files from a directory. scan() executes against
|
|
// binary data and returns YaraMatch structs containing rule
|
|
// name, tags, metadata (description/category/severity), and
|
|
// matched string identifiers with counts. Unit tests verify
|
|
// compilation, UPX detection, process injection detection,
|
|
// and clean-data negative cases using fixture binaries.
|
|
//
|
|
// Connects to:
|
|
// error.rs - EngineError::Yara for compilation/scan failures
|
|
|
|
use std::path::Path;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::error::EngineError;
|
|
|
|
const BUILTIN_RULES: &str = r#"
|
|
rule suspicious_upx_packed {
|
|
meta:
|
|
description = "Detects UPX packed binaries"
|
|
category = "packer"
|
|
severity = "medium"
|
|
strings:
|
|
$upx0 = "UPX0"
|
|
$upx1 = "UPX1"
|
|
$upx_magic = { 55 50 58 21 }
|
|
condition:
|
|
($upx0 and $upx1) or $upx_magic
|
|
}
|
|
|
|
rule suspicious_anti_debug {
|
|
meta:
|
|
description = "Detects common anti-debugging techniques"
|
|
category = "evasion"
|
|
severity = "high"
|
|
strings:
|
|
$api1 = "IsDebuggerPresent"
|
|
$api2 = "CheckRemoteDebuggerPresent"
|
|
$api3 = "NtQueryInformationProcess"
|
|
$api4 = "OutputDebugString"
|
|
$int2d = { CD 2D }
|
|
condition:
|
|
2 of ($api*) or $int2d
|
|
}
|
|
|
|
rule suspicious_process_injection {
|
|
meta:
|
|
description = "Detects potential process injection capabilities"
|
|
category = "injection"
|
|
severity = "critical"
|
|
strings:
|
|
$api1 = "VirtualAllocEx"
|
|
$api2 = "WriteProcessMemory"
|
|
$api3 = "CreateRemoteThread"
|
|
$api4 = "NtUnmapViewOfSection"
|
|
condition:
|
|
($api1 and $api2 and $api3) or ($api4 and $api2)
|
|
}
|
|
|
|
rule suspicious_keylogger {
|
|
meta:
|
|
description = "Detects potential keylogger behavior"
|
|
category = "spyware"
|
|
severity = "high"
|
|
strings:
|
|
$api1 = "GetAsyncKeyState"
|
|
$api2 = "SetWindowsHookEx"
|
|
$api3 = "GetKeyState"
|
|
$api4 = "GetKeyboardState"
|
|
condition:
|
|
2 of them
|
|
}
|
|
|
|
rule suspicious_crypto_mining {
|
|
meta:
|
|
description = "Detects cryptocurrency mining indicators"
|
|
category = "miner"
|
|
severity = "medium"
|
|
strings:
|
|
$pool1 = "stratum+tcp://"
|
|
$pool2 = "stratum+ssl://"
|
|
$algo1 = "cryptonight"
|
|
$algo2 = "randomx"
|
|
$algo3 = "ethash"
|
|
$wallet = /[13][a-km-zA-HJ-NP-Z1-9]{25,34}/
|
|
condition:
|
|
any of ($pool*) or (any of ($algo*) and $wallet)
|
|
}
|
|
|
|
rule suspicious_persistence {
|
|
meta:
|
|
description = "Detects Windows persistence mechanisms"
|
|
category = "persistence"
|
|
severity = "high"
|
|
strings:
|
|
$reg1 = "CurrentVersion\\Run"
|
|
$reg2 = "CurrentVersion\\RunOnce"
|
|
$svc1 = "CreateServiceA"
|
|
$svc2 = "CreateServiceW"
|
|
$task = "schtasks"
|
|
condition:
|
|
any of them
|
|
}
|
|
|
|
rule suspicious_network_backdoor {
|
|
meta:
|
|
description = "Detects potential backdoor network behavior"
|
|
category = "backdoor"
|
|
severity = "critical"
|
|
strings:
|
|
$bind = "bind"
|
|
$listen = "listen"
|
|
$accept = "accept"
|
|
$shell1 = "cmd.exe"
|
|
$shell2 = "/bin/sh"
|
|
$shell3 = "/bin/bash"
|
|
condition:
|
|
($bind and $listen and $accept) and any of ($shell*)
|
|
}
|
|
|
|
rule suspicious_ransomware {
|
|
meta:
|
|
description = "Detects potential ransomware indicators"
|
|
category = "ransomware"
|
|
severity = "critical"
|
|
strings:
|
|
$ext1 = ".encrypted"
|
|
$ext2 = ".locked"
|
|
$ext3 = ".crypto"
|
|
$ransom1 = "your files have been encrypted"
|
|
$ransom2 = "bitcoin"
|
|
$ransom3 = "decrypt"
|
|
$crypto1 = "CryptEncrypt"
|
|
$crypto2 = "CryptGenKey"
|
|
condition:
|
|
(any of ($ext*) and any of ($ransom*)) or
|
|
(any of ($crypto*) and any of ($ransom*))
|
|
}
|
|
|
|
rule suspicious_shellcode {
|
|
meta:
|
|
description = "Detects potential shellcode patterns"
|
|
category = "shellcode"
|
|
severity = "high"
|
|
strings:
|
|
$nop_sled = { 90 90 90 90 90 90 90 90 }
|
|
$egg_hunter1 = { 66 81 CA FF 0F }
|
|
$stack_pivot = { 94 C3 }
|
|
condition:
|
|
any of them
|
|
}
|
|
|
|
rule suspicious_obfuscation {
|
|
meta:
|
|
description = "Detects common obfuscation patterns"
|
|
category = "obfuscation"
|
|
severity = "medium"
|
|
strings:
|
|
$xor_loop = { 80 3? ?? 74 ?? 80 3? ?? }
|
|
$decode_base64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
condition:
|
|
any of them
|
|
}
|
|
|
|
rule suspicious_linux_anti_debug {
|
|
meta:
|
|
description = "Detects Linux anti-debugging via /proc inspection"
|
|
category = "evasion"
|
|
severity = "high"
|
|
strings:
|
|
$tracer = "TracerPid"
|
|
$proc_status = "/proc/self/status"
|
|
$proc_maps = "/proc/self/maps"
|
|
condition:
|
|
$tracer or ($proc_status and $proc_maps)
|
|
}
|
|
|
|
rule suspicious_linux_persistence {
|
|
meta:
|
|
description = "Detects Linux persistence mechanisms"
|
|
category = "persistence"
|
|
severity = "high"
|
|
strings:
|
|
$cron1 = "/etc/cron"
|
|
$cron2 = "crontab"
|
|
$init = "/etc/init.d/"
|
|
$systemd = "/etc/systemd/"
|
|
$bashrc = ".bashrc"
|
|
$profile = ".bash_profile"
|
|
$rc_local = "/etc/rc.local"
|
|
$xdg_autostart = ".config/autostart"
|
|
condition:
|
|
2 of them
|
|
}
|
|
|
|
rule suspicious_c2_endpoints {
|
|
meta:
|
|
description = "Detects common C2 server endpoint paths"
|
|
category = "c2"
|
|
severity = "high"
|
|
strings:
|
|
$gate = "/gate.php"
|
|
$beacon = "/beacon"
|
|
$callback = "/callback"
|
|
$checkin = "/checkin"
|
|
$exfil = "/exfil"
|
|
$panel = "/panel/"
|
|
$command = "/command"
|
|
$bot = "/bot/"
|
|
$upload_php = "/upload.php"
|
|
condition:
|
|
2 of them
|
|
}
|
|
|
|
rule suspicious_credential_access {
|
|
meta:
|
|
description = "Detects credential file access patterns"
|
|
category = "credential-access"
|
|
severity = "high"
|
|
strings:
|
|
$passwd = "/etc/passwd"
|
|
$shadow = "/etc/shadow"
|
|
$ssh_key = ".ssh/id_rsa"
|
|
$ssh_key2 = ".ssh/authorized_keys"
|
|
$kerberos = "/etc/krb5.conf"
|
|
$gnupg = ".gnupg/"
|
|
condition:
|
|
$shadow or ($passwd and any of ($ssh*, $kerberos, $gnupg))
|
|
}
|
|
"#;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct YaraMatch {
|
|
pub rule_name: String,
|
|
pub tags: Vec<String>,
|
|
pub metadata: YaraMetadata,
|
|
pub matched_strings: Vec<YaraStringMatch>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct YaraMetadata {
|
|
pub description: Option<String>,
|
|
pub category: Option<String>,
|
|
pub severity: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct YaraStringMatch {
|
|
pub identifier: String,
|
|
pub match_count: usize,
|
|
}
|
|
|
|
pub struct YaraScanner {
|
|
rules: yara_x::Rules,
|
|
}
|
|
|
|
impl YaraScanner {
|
|
pub fn new() -> Result<Self, EngineError> {
|
|
let mut compiler = yara_x::Compiler::new();
|
|
compiler.add_source(BUILTIN_RULES).map_err(
|
|
|e| EngineError::Yara(e.to_string()),
|
|
)?;
|
|
|
|
Ok(Self {
|
|
rules: compiler.build(),
|
|
})
|
|
}
|
|
|
|
pub fn with_custom_rules(
|
|
rules_dir: &Path,
|
|
) -> Result<Self, EngineError> {
|
|
let mut compiler = yara_x::Compiler::new();
|
|
compiler.add_source(BUILTIN_RULES).map_err(
|
|
|e| EngineError::Yara(e.to_string()),
|
|
)?;
|
|
|
|
if rules_dir.is_dir() {
|
|
for entry in std::fs::read_dir(rules_dir)
|
|
.map_err(|e| {
|
|
EngineError::Yara(format!(
|
|
"failed to read rules dir: {e}"
|
|
))
|
|
})?
|
|
{
|
|
let entry = entry.map_err(|e| {
|
|
EngineError::Yara(format!(
|
|
"dir entry error: {e}"
|
|
))
|
|
})?;
|
|
let path = entry.path();
|
|
if path
|
|
.extension()
|
|
.is_some_and(|ext| ext == "yar" || ext == "yara")
|
|
{
|
|
let source = std::fs::read_to_string(
|
|
&path,
|
|
)
|
|
.map_err(|e| {
|
|
EngineError::Yara(format!(
|
|
"failed to read {}: {e}",
|
|
path.display()
|
|
))
|
|
})?;
|
|
compiler
|
|
.add_source(source.as_str())
|
|
.map_err(|e| {
|
|
EngineError::Yara(format!(
|
|
"compile error in {}: {e}",
|
|
path.display()
|
|
))
|
|
})?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(Self {
|
|
rules: compiler.build(),
|
|
})
|
|
}
|
|
|
|
pub fn scan(
|
|
&self,
|
|
data: &[u8],
|
|
) -> Result<Vec<YaraMatch>, EngineError> {
|
|
let mut scanner =
|
|
yara_x::Scanner::new(&self.rules);
|
|
let results = scanner.scan(data).map_err(
|
|
|e| EngineError::Yara(e.to_string()),
|
|
)?;
|
|
|
|
let mut matches = Vec::new();
|
|
for rule in results.matching_rules() {
|
|
let tags: Vec<String> = rule
|
|
.tags()
|
|
.map(|t| t.identifier().to_string())
|
|
.collect();
|
|
|
|
let mut description = None;
|
|
let mut category = None;
|
|
let mut severity = None;
|
|
for (key, value) in rule.metadata() {
|
|
match key {
|
|
"description" => {
|
|
if let yara_x::MetaValue::String(s) = value {
|
|
description =
|
|
Some(s.to_string());
|
|
}
|
|
}
|
|
"category" => {
|
|
if let yara_x::MetaValue::String(s) = value {
|
|
category =
|
|
Some(s.to_string());
|
|
}
|
|
}
|
|
"severity" => {
|
|
if let yara_x::MetaValue::String(s) = value {
|
|
severity =
|
|
Some(s.to_string());
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let mut matched_strings = Vec::new();
|
|
for pattern in rule.patterns() {
|
|
let id = pattern
|
|
.identifier()
|
|
.to_string();
|
|
let count =
|
|
pattern.matches().count();
|
|
if count > 0 {
|
|
matched_strings.push(
|
|
YaraStringMatch {
|
|
identifier: id,
|
|
match_count: count,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
matches.push(YaraMatch {
|
|
rule_name: rule
|
|
.identifier()
|
|
.to_string(),
|
|
tags,
|
|
metadata: YaraMetadata {
|
|
description,
|
|
category,
|
|
severity,
|
|
},
|
|
matched_strings,
|
|
});
|
|
}
|
|
|
|
Ok(matches)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn builtin_rules_compile() {
|
|
let scanner = YaraScanner::new().unwrap();
|
|
let result = scanner.scan(&[0u8; 64]).unwrap();
|
|
assert!(result.is_empty() || !result.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn detects_upx_signature() {
|
|
let mut data = vec![0u8; 512];
|
|
let upx0 = b"UPX0";
|
|
let upx1 = b"UPX1";
|
|
data[0x100..0x104].copy_from_slice(upx0);
|
|
data[0x140..0x144].copy_from_slice(upx1);
|
|
|
|
let scanner = YaraScanner::new().unwrap();
|
|
let result = scanner.scan(&data).unwrap();
|
|
let upx_match = result
|
|
.iter()
|
|
.find(|m| m.rule_name == "suspicious_upx_packed");
|
|
assert!(
|
|
upx_match.is_some(),
|
|
"should detect UPX packer signature"
|
|
);
|
|
let meta =
|
|
&upx_match.unwrap().metadata;
|
|
assert_eq!(
|
|
meta.category.as_deref(),
|
|
Some("packer")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detects_process_injection() {
|
|
let mut data = Vec::new();
|
|
data.extend_from_slice(
|
|
b"\x00\x00VirtualAllocEx\x00\x00",
|
|
);
|
|
data.extend_from_slice(
|
|
b"\x00\x00WriteProcessMemory\x00\x00",
|
|
);
|
|
data.extend_from_slice(
|
|
b"\x00\x00CreateRemoteThread\x00\x00",
|
|
);
|
|
data.extend_from_slice(&[0u8; 256]);
|
|
|
|
let scanner = YaraScanner::new().unwrap();
|
|
let result = scanner.scan(&data).unwrap();
|
|
let injection = result.iter().find(|m| {
|
|
m.rule_name
|
|
== "suspicious_process_injection"
|
|
});
|
|
assert!(
|
|
injection.is_some(),
|
|
"should detect process injection APIs"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn clean_data_no_matches() {
|
|
let data = b"Hello, this is perfectly normal text content with nothing suspicious at all.";
|
|
let scanner = YaraScanner::new().unwrap();
|
|
let result = scanner.scan(data).unwrap();
|
|
let suspicious: Vec<_> = result
|
|
.iter()
|
|
.filter(|m| {
|
|
m.rule_name != "suspicious_obfuscation"
|
|
})
|
|
.collect();
|
|
assert!(
|
|
suspicious.is_empty(),
|
|
"clean text should not trigger suspicious rules, got: {:?}",
|
|
suspicious.iter().map(|m| &m.rule_name).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
fn load_fixture(name: &str) -> Vec<u8> {
|
|
let path = format!(
|
|
"{}/tests/fixtures/{name}",
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
);
|
|
std::fs::read(&path).unwrap_or_else(|e| {
|
|
panic!("fixture {path}: {e}")
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn scan_elf_binary() {
|
|
let data = load_fixture("hello_elf");
|
|
let scanner = YaraScanner::new().unwrap();
|
|
let result = scanner.scan(&data).unwrap();
|
|
assert!(
|
|
result.is_empty() || !result.is_empty(),
|
|
"scan should complete without error"
|
|
);
|
|
}
|
|
}
|