1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
//! VM packer / obfuscator family fingerprinting from PE section layout.
//!
//! Many commercial-style protection schemes leave a recognisable "shape" in
//! the section table: randomised lowercase-alphanumeric section names,
//! tiny seed-blob sections, a single large executable section, sometimes a
//! runtime-populated function-pointer table. When the shape matches, we
//! can emit a one-line hint that tells the analyst what they're dealing
//! with before they spend hours figuring it out manually.
//!
//! Current catalogue:
//! - **PyVMProtect** — author's signature scheme (`r/ReverseEngineering`
//! posts, `crackmev3.pyd`, `crackmev5.pyd`, ...). Recognisable by:
//! * Single executable section with a 5-character lowercase-
//! alphanumeric random-looking name (e.g. `.7qx3j`, `.2z7n8`).
//! * Three or four 8-byte read-only sections (the seed blobs the
//! init chain consumes).
//! * A 256-byte RW `.fptable` section that holds runtime-resolved
//! Win32 API pointers.
//! * One large `.irfts`-style RO section that carries the encrypted
//! IAT names + 209-string pool.
//! * One `.2ke3f`-style RW section holding sbox / vtable / seeds.
use goblin::Object;
/// Result of a fingerprint pass. `family` is human-readable. `notes` is
/// a short list of lines describing the strongest signals.
#[derive(Debug, Clone)]
pub struct Fingerprint {
pub family: &'static str,
pub notes: Vec<String>,
}
/// Looks at a parsed `goblin::Object` and returns a `Fingerprint` if it
/// matches a known VM-packer shape. Currently recognises only the
/// PyVMProtect family.
pub fn detect(obj: &Object<'_>) -> Option<Fingerprint> {
match obj {
Object::PE(pe) => detect_pe(pe),
_ => None,
}
}
fn is_random_lowercase_alphanumeric(name: &str) -> bool {
// Strip leading dot.
let stem = name.strip_prefix('.').unwrap_or(name);
if stem.len() < 4 || stem.len() > 6 {
return false;
}
if !stem
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
{
return false;
}
// Reject well-known names that happen to be all-lowercase: text, data,
// bss, rdata, idata, edata, pdata, rsrc, reloc, tls, crt, debug, init,
// bound, srdata, sxdata, ndata, rodata, sdata, sbss.
matches!(
stem,
"text"
| "data"
| "bss"
| "rdata"
| "idata"
| "edata"
| "pdata"
| "rsrc"
| "reloc"
| "tls"
| "debug"
| "init"
| "bound"
| "srdata"
| "sxdata"
| "ndata"
| "rodata"
| "sdata"
| "sbss"
| "got"
| "plt"
| "fini"
| "ctors"
| "dtors"
| "rsrcz"
)
.not()
}
trait BoolNot {
fn not(self) -> bool;
}
impl BoolNot for bool {
fn not(self) -> bool {
!self
}
}
fn detect_pe(pe: &goblin::pe::PE) -> Option<Fingerprint> {
let sections = &pe.sections;
if sections.is_empty() {
return None;
}
let mut signals = Vec::new();
let mut random_named: Vec<String> = Vec::new();
let mut seed_blob_count = 0usize;
let mut has_fptable = false;
let mut large_exec_random = false;
let mut total_random = 0usize;
for section in sections {
let name = section.name().unwrap_or("");
let vsize = section.virtual_size as usize;
if name == ".fptable" {
has_fptable = true;
}
if is_random_lowercase_alphanumeric(name) {
total_random += 1;
if vsize == 8 {
seed_blob_count += 1;
}
// Single large executable random-named section is the
// signature.
const IMAGE_SCN_MEM_EXECUTE: u32 = 0x2000_0000;
if (section.characteristics & IMAGE_SCN_MEM_EXECUTE) != 0 && vsize >= 0x10_000 {
large_exec_random = true;
}
random_named.push(name.to_string());
}
}
// Score the signals. PyVMProtect needs at minimum:
// - One large random-named executable section
// - At least 2 8-byte seed blobs
// - .fptable section
let strong = large_exec_random && seed_blob_count >= 2 && has_fptable;
if !strong {
return None;
}
if large_exec_random {
signals.push(format!(
"single large executable section with random name ({})",
random_named
.iter()
.find(|n| !n.is_empty())
.cloned()
.unwrap_or_default()
));
}
if seed_blob_count >= 2 {
signals.push(format!(
"{} ×8-byte seed-blob sections (random-named)",
seed_blob_count
));
}
if has_fptable {
signals.push("`.fptable` runtime-populated API pointer table".to_string());
}
if total_random >= 5 {
signals.push(format!(
"{} total randomised section names — typical PyVMProtect template",
total_random
));
}
Some(Fingerprint {
family: "PyVMProtect",
notes: signals,
})
}
/// Produce the multi-line banner the CLI prints when a fingerprint is hit.
pub fn banner(fp: &Fingerprint) -> String {
let mut out = String::new();
out.push_str(&format!("// [vm-fingerprint] family: {}\n", fp.family));
for n in &fp.notes {
out.push_str(&format!("// - {}\n", n));
}
if fp.family == "PyVMProtect" {
out.push_str("// hint: VM dispatcher likely reads opcode → sbox lookup → ");
out.push_str("vtable XOR → CALL [trampoline].\n");
out.push_str(
"// hint: const-pool resolver decrypts type-tagged entries via PCG \
keystream — buffer alloc happens BEFORE tag check, so unknown \
tags can leak plaintext into scratch heap.\n",
);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn random_alphanumeric_names_match() {
assert!(is_random_lowercase_alphanumeric(".7qx3j"));
assert!(is_random_lowercase_alphanumeric(".2z7n8"));
assert!(is_random_lowercase_alphanumeric(".jwvaz"));
assert!(is_random_lowercase_alphanumeric(".hylll"));
}
#[test]
fn standard_names_rejected() {
assert!(!is_random_lowercase_alphanumeric(".text"));
assert!(!is_random_lowercase_alphanumeric(".data"));
assert!(!is_random_lowercase_alphanumeric(".rdata"));
assert!(!is_random_lowercase_alphanumeric(".pdata"));
assert!(!is_random_lowercase_alphanumeric(".reloc"));
assert!(!is_random_lowercase_alphanumeric(".rsrc"));
assert!(!is_random_lowercase_alphanumeric(".bss"));
}
#[test]
fn uppercase_or_long_rejected() {
assert!(!is_random_lowercase_alphanumeric(".TEXT"));
assert!(!is_random_lowercase_alphanumeric(".verylongname"));
assert!(!is_random_lowercase_alphanumeric(".x"));
}
}