use std::{
any::Any,
collections::{HashMap, HashSet},
sync::Arc,
};
use crate::{
cilassembly::GeneratorConfig,
compiler::{EventKind, EventLog},
deobfuscation::techniques::{
netreactor::{helpers, hooks},
Detection, Detections, Evidence, Technique, TechniqueCapability, TechniqueCategory,
WorkingAssembly,
},
emulation::{EmValue, EmulationOutcome, EmulationProcess, HeapObject, ProcessBuilder},
error::Error,
metadata::{
method::{ExceptionHandler, MethodBody},
tables::{MethodDefRaw, TableDataOwned, TableId},
token::Token,
validation::ValidationConfig,
},
CilObject, Result,
};
const MIN_STUB_COUNT: usize = 5;
const MIN_STUB_RATIO: f64 = 0.10;
#[derive(Debug)]
pub struct NecroBitFindings {
pub stub_method_tokens: Vec<Token>,
pub init_method_token: Option<Token>,
pub runtime_type_token: Option<Token>,
pub module_cctor_token: Option<Token>,
pub trial_check_tokens: Vec<Token>,
pub body_patcher_token: Option<Token>,
pub purely_injected_cctors: Vec<Token>,
pub modified_cctors: Vec<Token>,
pub total_methods: usize,
}
pub struct NetReactorNecroBit;
impl Technique for NetReactorNecroBit {
fn id(&self) -> &'static str {
"netreactor.necrobit"
}
fn name(&self) -> &'static str {
".NET Reactor NecroBit (Method Body Decryption)"
}
fn category(&self) -> TechniqueCategory {
TechniqueCategory::Protection
}
fn capabilities(&self) -> Vec<TechniqueCapability> {
vec![TechniqueCapability::ByteTransform]
}
fn detect(&self, assembly: &CilObject) -> Detection {
let stub_scan = helpers::scan_stub_methods(assembly);
let stub_count = stub_scan.stub_methods.len();
let stub_ratio = if stub_scan.total_il_methods > 0 {
stub_count as f64 / stub_scan.total_il_methods as f64
} else {
0.0
};
let primary_met = stub_count >= MIN_STUB_COUNT && stub_ratio > MIN_STUB_RATIO;
if !primary_met {
return Detection::new_empty();
}
let fan_in = helpers::find_cctor_fan_in_target(assembly);
let has_fan_in = fan_in.is_some();
let trial_checks = helpers::find_trial_checks(assembly);
let has_trial_on_module = trial_checks.iter().any(|t| t.is_on_module_type);
let has_trial = !trial_checks.is_empty();
let body_patcher = helpers::find_body_patcher(assembly);
let has_patcher = body_patcher.is_some();
if !has_fan_in && !has_trial && !has_patcher {
return Detection::new_empty();
}
let module_cctor_token = assembly.types().module_cctor();
let init_method_token = fan_in.as_ref().map(|f| f.target_token);
let runtime_type_token = init_method_token.and_then(|init_token| {
assembly
.method(&init_token)
.and_then(|m| m.declaring_type_rc())
.map(|t| t.token)
});
let (purely_injected_cctors, modified_cctors) = if let Some(ref fan_in_result) = fan_in {
let classification = helpers::classify_injected_cctors(
assembly,
fan_in_result.target_token,
&fan_in_result.calling_cctors,
);
(classification.purely_injected, classification.modified)
} else {
(Vec::new(), Vec::new())
};
let stub_method_tokens: Vec<Token> =
stub_scan.stub_methods.iter().map(|(t, _)| *t).collect();
let trial_check_tokens: Vec<Token> = trial_checks.iter().map(|t| t.method_token).collect();
let findings = NecroBitFindings {
stub_method_tokens,
init_method_token,
runtime_type_token,
module_cctor_token,
trial_check_tokens: trial_check_tokens.clone(),
body_patcher_token: body_patcher,
purely_injected_cctors: purely_injected_cctors.clone(),
modified_cctors,
total_methods: stub_scan.total_il_methods,
};
let mut evidence = Vec::new();
evidence.push(Evidence::BytecodePattern(format!(
"{} of {} IL methods have NecroBit stub bodies ({:.0}%)",
stub_count,
stub_scan.total_il_methods,
stub_ratio * 100.0,
)));
if let Some(ref fan_in_result) = fan_in {
evidence.push(Evidence::Structural(format!(
"Init method 0x{:08X} called by {} type .cctors ({} locals, {} instructions)",
fan_in_result.target_token.value(),
fan_in_result.calling_cctors.len(),
fan_in_result.target_local_count,
fan_in_result.target_instruction_count,
)));
}
if has_trial_on_module {
evidence.push(Evidence::BytecodePattern(
"Trial/time-bomb check on <Module> (DateTime + TimeSpan.Days + throw)".to_string(),
));
} else if has_trial {
evidence.push(Evidence::BytecodePattern(
"Trial/time-bomb check method detected (DateTime + TimeSpan.Days + throw)"
.to_string(),
));
}
if has_patcher {
evidence.push(Evidence::BytecodePattern(
"Body patcher method (Marshal.Copy + ReadInt32/64 + IntPtr.Size)".to_string(),
));
}
let mut detection = Detection::new_detected(
evidence,
Some(Box::new(findings) as Box<dyn Any + Send + Sync>),
);
if let Some(rt_token) = runtime_type_token {
detection.cleanup_mut().add_type(rt_token);
}
for &cctor_token in &purely_injected_cctors {
detection.cleanup_mut().add_method(cctor_token);
}
for &trial_token in &trial_check_tokens {
detection.cleanup_mut().add_method(trial_token);
}
if let Some(patcher_token) = body_patcher {
detection.cleanup_mut().add_method(patcher_token);
}
detection
}
fn byte_transform(
&self,
assembly: &mut WorkingAssembly,
detection: &Detection,
_detections: &Detections,
) -> Option<Result<EventLog>> {
let events = EventLog::new();
let Some(findings) = detection.findings::<NecroBitFindings>() else {
return Some(Ok(events));
};
if findings.stub_method_tokens.is_empty() {
return Some(Ok(events));
}
let co = match assembly.cilobject() {
Ok(v) => v,
Err(e) => return Some(Err(e)),
};
let bytes = co.file().data().to_vec();
let cilobject =
match CilObject::from_mem_with_validation(bytes, ValidationConfig::analysis()) {
Ok(v) => v,
Err(e) => return Some(Err(e)),
};
let cilobject_arc = Arc::new(cilobject);
let module_cctor = match findings.module_cctor_token.ok_or_else(|| {
Error::Deobfuscation("No <Module>::.cctor found for NecroBit emulation".to_string())
}) {
Ok(v) => v,
Err(e) => return Some(Err(e)),
};
let mut bypass_tokens: HashSet<Token> = HashSet::new();
for &token in &findings.trial_check_tokens {
bypass_tokens.insert(token);
}
for &token in &findings.purely_injected_cctors {
bypass_tokens.insert(token);
}
for &token in &findings.modified_cctors {
bypass_tokens.insert(token);
}
let mut builder = ProcessBuilder::new()
.assembly_arc(Arc::clone(&cilobject_arc))
.name("necrobit-emulation")
.with_max_instructions(50_000_000)
.with_max_call_depth(200)
.with_timeout_ms(300_000);
builder = builder.hook(hooks::create_antitamper_bypass_hook());
if !bypass_tokens.is_empty() {
builder = builder.hook(hooks::create_trial_bypass_hook(bypass_tokens));
}
let process = match builder.build() {
Ok(v) => v,
Err(e) => return Some(Err(e)),
};
let outcome = match process.execute_method(module_cctor, vec![]) {
Ok(v) => v,
Err(e) => return Some(Err(e)),
};
let instructions_executed = match outcome {
EmulationOutcome::Completed { instructions, .. }
| EmulationOutcome::Breakpoint { instructions, .. } => instructions,
EmulationOutcome::UnhandledException {
instructions,
exception,
..
} => {
log::info!(
"NecroBit emulation ended with exception after {} instructions: {:?} \
— extracting method bodies from emulation state",
instructions,
exception,
);
instructions
}
EmulationOutcome::LimitReached { limit, .. } => {
log::warn!(
"NecroBit emulation exceeded limit ({limit:?}) \
— attempting body extraction from partial state",
);
0
}
EmulationOutcome::Stopped { reason, .. } => {
return Some(Err(Error::Deobfuscation(format!(
"NecroBit emulation stopped: {reason}"
))));
}
EmulationOutcome::RequiresSymbolic { reason, .. } => {
return Some(Err(Error::Deobfuscation(format!(
"NecroBit emulation requires symbolic execution: {reason}"
))));
}
};
let extracted_bodies = match find_variant_a_blob(&process) {
Some(blob) => match parse_variant_a_blob(&blob, &cilobject_arc) {
Ok(v) => v,
Err(e) => return Some(Err(e)),
},
None => match extract_bodies_from_image(
&process,
&cilobject_arc,
&findings.stub_method_tokens,
) {
Ok(v) => v,
Err(e) => return Some(Err(e)),
},
};
drop(process);
if extracted_bodies.is_empty() {
return Some(Err(Error::Deobfuscation(
"No method bodies could be extracted from heap or PE image".to_string(),
)));
}
let stub_set: HashSet<Token> = findings.stub_method_tokens.iter().copied().collect();
let restored_count = extracted_bodies
.iter()
.filter(|(token, _)| stub_set.contains(token))
.count();
log::info!(
"NecroBit: extracted {} method bodies ({} of {} stubs restored, {} instructions)",
extracted_bodies.len(),
restored_count,
findings.stub_method_tokens.len(),
instructions_executed,
);
if restored_count == 0 {
return Some(Err(Error::Deobfuscation(format!(
"NecroBit emulation did not restore any stub methods \
(extracted {} bodies, {} were stubs)",
extracted_bodies.len(),
findings.stub_method_tokens.len(),
))));
}
for (token, _) in &extracted_bodies {
if stub_set.contains(token) {
events
.record(EventKind::MethodBodyDecrypted)
.method(*token)
.message(format!("Decrypted method body 0x{:08x}", token.value()));
}
}
let cilobject = match Arc::try_unwrap(cilobject_arc)
.map_err(|_| Error::Deobfuscation("Assembly still shared after emulation".into()))
{
Ok(v) => v,
Err(e) => return Some(Err(e)),
};
let mut cil_assembly = cilobject.into_assembly();
for (method_token, body_bytes) in &extracted_bodies {
if MethodBody::from(body_bytes).is_err() {
log::warn!(
"Skipping invalid decrypted body for 0x{:08X} ({} bytes)",
method_token.value(),
body_bytes.len()
);
continue;
}
let placeholder_rva = cil_assembly.store_method_body(body_bytes.clone());
let rid = method_token.row();
#[allow(clippy::redundant_closure_for_method_calls)]
let existing_row = match cil_assembly
.view()
.tables()
.and_then(|t| t.table::<MethodDefRaw>())
.and_then(|table| table.get(rid))
.ok_or_else(|| Error::Deobfuscation(format!("MethodDef row {rid} not found")))
{
Ok(v) => v,
Err(e) => return Some(Err(e)),
};
let updated_row = MethodDefRaw {
rid: existing_row.rid,
token: existing_row.token,
offset: existing_row.offset,
rva: placeholder_rva,
impl_flags: existing_row.impl_flags,
flags: existing_row.flags,
name: existing_row.name,
signature: existing_row.signature,
param_list: existing_row.param_list,
};
if let Err(e) = cil_assembly.table_row_update(
TableId::MethodDef,
rid,
TableDataOwned::MethodDef(updated_row),
) {
return Some(Err(e));
}
}
events
.record(EventKind::MethodBodyDecrypted)
.message(format!(
"NecroBit protection removed: {restored_count} method bodies decrypted \
({instructions_executed} instructions emulated)",
));
let config = GeneratorConfig::default();
let new_assembly =
match cil_assembly.into_cilobject_with(ValidationConfig::analysis(), config) {
Ok(v) => v,
Err(e) => return Some(Err(e)),
};
assembly.replace_assembly(new_assembly);
Some(Ok(events))
}
fn requires_regeneration(&self) -> bool {
true
}
}
fn is_necrobit_data_array(data: &[u8]) -> bool {
if data.len() < 28 {
return false;
}
let read_u32 = |off: usize| -> u32 {
u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
};
let first_token = read_u32(0);
if first_token >> 24 != 0x06 {
return false;
}
let group_count = read_u32(16) as usize;
if group_count > 0 && group_count <= 500 {
let header_end = 24 + group_count * 8;
if header_end + 4 > data.len() {
return false;
}
let method_count = read_u32(header_end) as usize;
if method_count == 0 || method_count > 5000 {
return false;
}
header_end + 4 + method_count * 12 <= data.len() + method_count * 8
} else if group_count == 0 {
if data.len() < 36 {
return false;
}
let body_header_byte = data[32]; let is_fat = body_header_byte & 0x03 == 0x03;
let is_tiny = body_header_byte & 0x03 == 0x02;
is_fat || is_tiny
} else {
false
}
}
fn find_variant_a_blob(process: &EmulationProcess) -> Option<Vec<u8>> {
let heap = process.address_space().managed_heap();
let mut best: Option<Vec<u8>> = None;
for (_href, obj) in heap.iter().ok()? {
let HeapObject::Array { elements, .. } = &obj else {
continue;
};
if elements.len() < 500 {
continue;
}
let bytes: Vec<u8> = elements
.iter()
.filter_map(|e| match e {
EmValue::I32(v) => Some((v & 0xFF) as u8),
_ => None,
})
.collect();
if !is_necrobit_data_array(&bytes) {
continue;
}
let group_count = u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]) as usize;
if group_count == 0 {
continue; }
if best.as_ref().is_none_or(|prev| bytes.len() > prev.len()) {
best = Some(bytes);
}
}
best
}
fn parse_variant_a_blob(data: &[u8], assembly: &CilObject) -> Result<Vec<(Token, Vec<u8>)>> {
let read_u32 = |off: usize| -> u32 {
u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
};
if data.len() < 24 {
return Err(Error::Deobfuscation(
"Decrypted data too small for header".to_string(),
));
}
let group_count = read_u32(16) as usize;
let header_end = 24 + group_count * 8;
if data.len() < header_end + 4 {
return Err(Error::Deobfuscation(
"Decrypted data too small for group entries".to_string(),
));
}
let mut header_patches: HashMap<u32, u32> = HashMap::new();
for i in 0..group_count {
let rva = read_u32(24 + i * 8);
let val = read_u32(24 + i * 8 + 4);
header_patches.insert(rva, val);
}
let method_count = read_u32(header_end) as usize;
let mut offset = header_end + 4;
let mut rva_to_token: HashMap<u32, Token> = HashMap::new();
for method_entry in assembly.methods().iter() {
let method = method_entry.value();
if let Some(rva) = method.rva.filter(|&r| r > 0) {
rva_to_token.insert(rva, method.token);
}
}
let mut bodies = Vec::new();
for _ in 0..method_count {
if offset + 12 > data.len() {
break;
}
let il_start_rva = read_u32(offset);
let v2 = read_u32(offset + 4);
let il_size = read_u32(offset + 8) as usize;
offset += 12;
if il_start_rva == 0 && v2 == 0 && il_size == 0 {
break;
}
if offset + il_size > data.len() {
log::warn!(
"NecroBit: truncated IL data at RVA 0x{il_start_rva:04X} (need {il_size}, have {})",
data.len() - offset
);
break;
}
let il_bytes = &data[offset..offset + il_size];
offset += il_size;
let (method_token, is_fat) = if let Some(&token) = rva_to_token.get(&(il_start_rva - 1)) {
(token, false)
} else if let Some(&token) = rva_to_token.get(&(il_start_rva - 12)) {
(token, true)
} else {
log::warn!("NecroBit: no method found for IL start RVA 0x{il_start_rva:04X}");
continue;
};
let body_bytes = if is_fat {
let method_rva = il_start_rva - 12;
let (max_stack, is_init_local, local_var_sig_token) =
resolve_fat_header_metadata(method_rva, v2, &header_patches);
let exception_handlers = read_on_disk_exception_handlers(assembly, method_rva);
let new_body = MethodBody {
size_code: il_size,
size_header: 12,
local_var_sig_token,
max_stack,
is_fat: true,
is_init_local,
is_exception_data: !exception_handlers.is_empty(),
exception_handlers,
};
let mut buf = Vec::with_capacity(12 + il_size + 64);
new_body
.write_to(&mut buf, il_bytes)
.map_err(|e| Error::Deobfuscation(format!("encode fat body: {e}")))?;
buf
} else {
if il_size > 63 {
log::warn!(
"NecroBit: IL too large for tiny header at 0x{:08X} ({il_size} bytes)",
method_token.value()
);
continue;
}
let new_body = MethodBody {
size_code: il_size,
size_header: 1,
local_var_sig_token: 0,
max_stack: 8,
is_fat: false,
is_init_local: false,
is_exception_data: false,
exception_handlers: Vec::new(),
};
let mut buf = Vec::with_capacity(1 + il_size);
new_body
.write_to(&mut buf, il_bytes)
.map_err(|e| Error::Deobfuscation(format!("encode tiny body: {e}")))?;
buf
};
bodies.push((method_token, body_bytes));
}
log::info!(
"NecroBit: parsed {}/{method_count} method bodies from decrypted array ({} bytes)",
bodies.len(),
data.len()
);
Ok(bodies)
}
fn resolve_fat_header_metadata(
method_rva: u32,
v2: u32,
header_patches: &HashMap<u32, u32>,
) -> (usize, bool, u32) {
let local_var_sig_token = header_patches
.get(&(method_rva + 8))
.copied()
.filter(|v| (v >> 24) == 0x11)
.unwrap_or(0);
let (flags_and_size, maxstack) = if let Some(&fat_hdr) = header_patches.get(&method_rva) {
if (fat_hdr >> 24) != 0x11 {
((fat_hdr & 0xFFFF) as u16, (fat_hdr >> 16) as u16)
} else {
(0x3013u16, v2 as u16)
}
} else {
(0x3013u16, v2 as u16)
};
let is_init_local = (flags_and_size & 0x0010) != 0;
(maxstack as usize, is_init_local, local_var_sig_token)
}
fn read_on_disk_exception_handlers(assembly: &CilObject, method_rva: u32) -> Vec<ExceptionHandler> {
let file = assembly.file();
let Some(offset) = file.rva_to_offset(method_rva as usize).ok() else {
return Vec::new();
};
let Some(body_data) = file.data().get(offset..) else {
return Vec::new();
};
MethodBody::from_raw(body_data)
.ok()
.map(|b| b.exception_handlers)
.unwrap_or_default()
}
fn extract_bodies_from_image(
process: &EmulationProcess,
assembly: &CilObject,
stub_tokens: &[Token],
) -> Result<Vec<(Token, Vec<u8>)>> {
let image_base = process.image_base().ok_or_else(|| {
Error::Deobfuscation("No PE image base for NecroBit extraction".to_string())
})?;
let image_size = assembly.file().data().len() as u64;
let addr_space = process.address_space();
let mut bodies = Vec::new();
let mut read_failures = 0usize;
let mut parse_failures = 0usize;
let mut still_stubs = 0usize;
for &token in stub_tokens {
let Some(method) = assembly.method(&token) else {
continue;
};
let Some(rva) = method.rva.filter(|&r| r > 0) else {
continue;
};
let addr = image_base + u64::from(rva);
let available = image_size.saturating_sub(u64::from(rva)) as usize;
if available == 0 {
continue;
}
let buffer = match addr_space.read(addr, available) {
Ok(b) => b,
Err(_) => {
read_failures += 1;
continue;
}
};
let body = match MethodBody::from(&buffer) {
Ok(b) => b,
Err(_) => {
parse_failures += 1;
continue;
}
};
if body.size_code == 4 {
still_stubs += 1;
continue;
}
let total_size = body.size();
if total_size > buffer.len() {
continue;
}
bodies.push((token, buffer[..total_size].to_vec()));
}
if bodies.is_empty() && !stub_tokens.is_empty() {
log::debug!(
"NecroBit image extraction: {} read failures, {} parse failures, \
{} still stubs (of {} total)",
read_failures,
parse_failures,
still_stubs,
stub_tokens.len(),
);
}
log::info!(
"NecroBit: extracted {} method bodies from PE image ({} stubs checked)",
bodies.len(),
stub_tokens.len(),
);
Ok(bodies)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use crate::{
deobfuscation::techniques::{Detections, Technique, WorkingAssembly},
metadata::validation::ValidationConfig,
CilObject,
};
fn load_sample(name: &str) -> CilObject {
let path = format!("tests/samples/packers/netreactor/7.5.0/{name}");
CilObject::from_path_with_validation(&path, ValidationConfig::analysis())
.unwrap_or_else(|e| panic!("Failed to load {name}: {e}"))
}
#[test]
fn test_detect_positive() {
let path = "tests/samples/packers/netreactor/7.5.0/reactor_necrobit.exe";
if !Path::new(path).exists() {
eprintln!("Skipping test: sample not found at {path}");
return;
}
let assembly = load_sample("reactor_necrobit.exe");
let technique = NetReactorNecroBit;
let detection = technique.detect(&assembly);
assert!(
detection.is_detected(),
"NetReactorNecroBit should detect NecroBit in reactor_necrobit.exe"
);
assert!(
!detection.evidence().is_empty(),
"Detection should have evidence"
);
let findings = detection
.findings::<NecroBitFindings>()
.expect("Should have NecroBitFindings");
assert!(
!findings.stub_method_tokens.is_empty(),
"Should find stub methods"
);
assert!(
findings.init_method_token.is_some(),
"Should find init method via .cctor fan-in"
);
assert!(
findings.module_cctor_token.is_some(),
"Should find <Module>::.cctor"
);
assert!(
!findings.trial_check_tokens.is_empty(),
"Should find trial check methods"
);
assert!(
findings.body_patcher_token.is_some(),
"Should find body patcher method"
);
}
#[test]
#[ignore]
fn test_byte_transform() {
let path = "tests/samples/packers/netreactor/7.5.0/reactor_necrobit.exe";
if !Path::new(path).exists() {
eprintln!("Skipping test: sample not found at {path}");
return;
}
let _ = env_logger::builder()
.filter_level(log::LevelFilter::Info)
.is_test(true)
.try_init();
let assembly = load_sample("reactor_necrobit.exe");
let technique = NetReactorNecroBit;
let detection = technique.detect(&assembly);
assert!(detection.is_detected(), "Should detect NecroBit");
let mut working = WorkingAssembly::new(assembly);
let detections = Detections::new();
let result = technique.byte_transform(&mut working, &detection, &detections);
match result {
Some(Ok(events)) => {
eprintln!("byte_transform succeeded with {} events", events.len());
}
Some(Err(e)) => {
panic!("byte_transform returned error: {e}");
}
None => {
panic!("byte_transform returned None (skipped)");
}
}
}
#[test]
fn test_detect_negative() {
let path = "tests/samples/packers/netreactor/7.5.0/original.exe";
if !Path::new(path).exists() {
eprintln!("Skipping test: sample not found at {path}");
return;
}
let assembly = load_sample("original.exe");
let technique = NetReactorNecroBit;
let detection = technique.detect(&assembly);
assert!(
!detection.is_detected(),
"NetReactorNecroBit should not detect NecroBit in original.exe"
);
}
}