Skip to content

Commit 29ffe26

Browse files
committed
Fix silent feature corruption in smda extractor (closes #24)
- parse_operand_to_number: require a leading digit for h-suffixed and bare hex literals — register names ah/bh/ch/dh parsed as 0xA-0xD and hex-looking labels (beef, face) parsed as numbers. - mask negative immediates at the function's bitness (was always u32, truncating x64 values like mov rax, -1 to 0xFFFFFFFF). - emit stack string characteristic once per basic block (was pushed per instruction past the threshold with no break). - drop the duplicate plain-ASCII pass from extract_unicode_strings — extract_file_strings already runs extract_ascii_strings alongside, so every ASCII string was emitted twice. Adds 3 regression tests (16 total, all passing).
1 parent e28e0f1 commit 29ffe26

2 files changed

Lines changed: 121 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,27 @@
33
All notable changes to **capa** are documented here.
44
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
55

6+
## [Unreleased]
7+
8+
### Fixed — silent feature corruption in the smda extractor (closes [#24](https://github.com/marirs/capa-rs/issues/24))
9+
10+
- **Registers and labels no longer parse as numbers** the `h`-suffix
11+
case of `parse_operand_to_number` stripped the suffix and parsed the
12+
rest as hex, so `mov al, ah` emitted `Number(0xA)` (same for
13+
`bh`/`ch`/`dh`); the bare-hex fallback also accepted hex-looking
14+
labels (`beef`, `face`). Both paths now require a leading digit, per
15+
the Intel convention for hex literals.
16+
- **Negative immediates mask at the function's bitness** previously
17+
always masked to u32, so `mov rax, -1` on x64 emitted
18+
`Number(0xFFFFFFFF)` instead of `Number(0xFFFFFFFFFFFFFFFF)`.
19+
- **`stack string` emitted once per basic block** the push sat inside
20+
the instruction loop with no `break`, adding a duplicate
21+
characteristic for every instruction past the threshold.
22+
- **ASCII strings no longer emitted twice** `extract_unicode_strings`
23+
ran a plain-ASCII pass with the same printable class as
24+
`extract_ascii_strings`, and `extract_file_strings` calls both; the
25+
UTF-16 extractor is now UTF-16-only.
26+
627
## [0.5.2] — xor-zero number(0), regex /i fast path, rule pre-pruning
728

829
### Fixed — feature extraction parity

src/extractor/smda.rs

Lines changed: 100 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -310,15 +310,18 @@ impl<'data> super::Extractor for Extractor<'data> {
310310
if instr.is_mov_imm_to_stack()? {
311311
count += instr.get_printable_len()?;
312312
}
313-
if count > 8 {
314-
//MIN_STACKSTRING_LEN
315-
res.push((
316-
crate::rules::features::Feature::Characteristic(
317-
crate::rules::features::CharacteristicFeature::new("stack string", "")?,
318-
),
319-
*bb.0,
320-
));
321-
}
313+
}
314+
if count > 8 {
315+
//MIN_STACKSTRING_LEN
316+
// Emitted once per basic block — previously the push sat
317+
// inside the loop with no `break`, so every instruction
318+
// past the threshold added a duplicate (#24).
319+
res.push((
320+
crate::rules::features::Feature::Characteristic(
321+
crate::rules::features::CharacteristicFeature::new("stack string", "")?,
322+
),
323+
*bb.0,
324+
));
322325
}
323326
Ok(res)
324327
}
@@ -1263,7 +1266,16 @@ impl<'data> Extractor<'data> {
12631266

12641267
// case 2: if operand is like 1234h
12651268
if let Some(stripped_operand) = operand.strip_suffix('h') {
1266-
return i128::from_str_radix(stripped_operand, 16).ok();
1269+
// Intel convention: an h-suffixed hex literal must start
1270+
// with a digit (0ABh). Without this check the register
1271+
// names ah/bh/ch/dh parsed as 0xA/0xB/0xC/0xD (#24).
1272+
if stripped_operand
1273+
.chars()
1274+
.next()
1275+
.is_some_and(|c| c.is_ascii_digit())
1276+
{
1277+
return i128::from_str_radix(stripped_operand, 16).ok();
1278+
}
12671279
}
12681280

12691281
// case 3: if operand is like +0x1234
@@ -1288,8 +1300,13 @@ impl<'data> Extractor<'data> {
12881300
return Some(val);
12891301
}
12901302

1291-
// case 5: if operand is like 0x1234
1292-
i128::from_str_radix(operand, 16).ok()
1303+
// case 5: bare hex without 0x/h, e.g. 0dead. Must start with a
1304+
// digit — otherwise hex-looking labels (beef, face, add) parse
1305+
// as numbers (#24).
1306+
if operand.chars().next().is_some_and(|c| c.is_ascii_digit()) {
1307+
return i128::from_str_radix(operand, 16).ok();
1308+
}
1309+
None
12931310
}
12941311

12951312
pub fn extract_insn_number_features(
@@ -1317,7 +1334,12 @@ impl<'data> Extractor<'data> {
13171334
insn.offset,
13181335
));
13191336
} else {
1320-
let masked_value = (s as u32) as i128; // Convierte a u32 y de vuelta a i128
1337+
// Negative immediates are emitted as their
1338+
// unsigned interpretation at the function's
1339+
// bitness — masking to u32 truncated x64 values
1340+
// (mov rax, -1 → 0xFFFFFFFF instead of
1341+
// 0xFFFFFFFFFFFFFFFF) (#24).
1342+
let masked_value = mask_to_bitness(s, f.bitness);
13211343
res.push((
13221344
crate::rules::features::Feature::Number(
13231345
crate::rules::features::NumberFeature::new(
@@ -1570,6 +1592,16 @@ pub fn generate_symbols(dll: &Option<String>, symbol: &Option<String>) -> Result
15701592
Ok(res)
15711593
}
15721594

1595+
/// Unsigned interpretation of a negative immediate at the given bitness
1596+
/// (#24): `-1` is `0xFFFFFFFF` on 32-bit and `0xFFFFFFFFFFFFFFFF` on
1597+
/// 64-bit. Previously the mask was always u32, truncating x64 values.
1598+
fn mask_to_bitness(value: i128, bitness: u32) -> i128 {
1599+
match bitness {
1600+
64 => (value as u64) as i128,
1601+
_ => (value as u32) as i128,
1602+
}
1603+
}
1604+
15731605
pub fn derefs(report: &DisassemblyReport<'_>, p: &u64) -> Result<Vec<u64>> {
15741606
let mut res = vec![];
15751607
let mut depth = 0;
@@ -1773,7 +1805,6 @@ pub fn extract_unicode_strings(data: &[u8], min_length: usize) -> Result<Vec<(St
17731805
// regex pattern for UTF-16LE and UTF-16BE
17741806
let re_le = regex::bytes::Regex::new(&format!(r"((?:[\x20-\x7E]\x00){{{},}})", min_length))?;
17751807
let re_be = regex::bytes::Regex::new(&format!(r"((?:\x00[\x20-\x7E]){{{},}})", min_length))?;
1776-
let re_utf8 = regex::bytes::Regex::new(&format!(r"((?:[\x20-\x7E]){{{},}})", min_length))?;
17771808

17781809
// UTF-16LE
17791810
for mat in re_le.find_iter(data) {
@@ -1799,12 +1830,11 @@ pub fn extract_unicode_strings(data: &[u8], min_length: usize) -> Result<Vec<(St
17991830
}
18001831
}
18011832

1802-
// UTF-8
1803-
for mat in re_utf8.find_iter(data) {
1804-
let matched_bytes = mat.as_bytes();
1805-
let decoded_string = String::from_utf8_lossy(matched_bytes).to_string();
1806-
results.push((decoded_string, mat.start() as u64));
1807-
}
1833+
// NOTE (#24): there used to be a third, plain-ASCII pass here
1834+
// (`[\x20-\x7E]{4,}`) — it duplicated `extract_ascii_strings`,
1835+
// which `extract_file_strings` runs alongside this function, so
1836+
// every ASCII string was emitted twice. ASCII stays with
1837+
// `extract_ascii_strings`; this function is UTF-16 only.
18081838

18091839
let cleaned_results = results
18101840
.into_iter()
@@ -2045,4 +2075,54 @@ mod tests {
20452075
);
20462076
eprintln!("upstream parity #2997: verified {sites_checked} self-XOR site(s) in {path}");
20472077
}
2078+
2079+
/// #24: register names (ah/bh/ch/dh) and hex-looking labels must not
2080+
/// parse as numbers; h-suffixed and bare hex literals starting with
2081+
/// a digit still do.
2082+
#[test]
2083+
fn registers_and_labels_are_not_numbers() {
2084+
let data = vec![0u8; 0x10];
2085+
let extractor =
2086+
Extractor::from_buffer(&data, 0x1000, 64, false, false).expect("parse buffer");
2087+
// Pre-#24 these parsed as 0xA / 0xB / 0xC / 0xD via the 'h' strip.
2088+
for reg in ["ah", "bh", "ch", "dh"] {
2089+
assert_eq!(extractor.parse_operand_to_number(reg), None, "{reg}");
2090+
}
2091+
// Hex-looking labels parsed via the bare-hex fallback.
2092+
for label in ["beef", "face", "add"] {
2093+
assert_eq!(extractor.parse_operand_to_number(label), None, "{label}");
2094+
}
2095+
// Legitimate literals keep working.
2096+
assert_eq!(extractor.parse_operand_to_number("0ABh"), Some(0xAB));
2097+
assert_eq!(extractor.parse_operand_to_number("1234h"), Some(0x1234));
2098+
assert_eq!(extractor.parse_operand_to_number("0dead"), Some(0xdead));
2099+
assert_eq!(extractor.parse_operand_to_number("0x1234"), Some(0x1234));
2100+
assert_eq!(extractor.parse_operand_to_number("1234"), Some(1234));
2101+
}
2102+
2103+
/// #24: negative immediates are emitted as their unsigned
2104+
/// interpretation at the function's bitness (was always u32).
2105+
#[test]
2106+
fn negative_immediates_mask_at_bitness() {
2107+
assert_eq!(mask_to_bitness(-1, 32), 0xFFFF_FFFF);
2108+
assert_eq!(mask_to_bitness(-1, 64), 0xFFFF_FFFF_FFFF_FFFF);
2109+
}
2110+
2111+
/// #24: the UTF-16 extractor must not also emit plain ASCII strings —
2112+
/// `extract_file_strings` runs `extract_ascii_strings` alongside it,
2113+
/// so every ASCII string used to appear twice.
2114+
#[test]
2115+
fn unicode_extractor_does_not_duplicate_ascii() {
2116+
let data = b"HELLO WORLD";
2117+
assert!(
2118+
extract_unicode_strings(data, 4)
2119+
.expect("unicode")
2120+
.is_empty(),
2121+
"ASCII string leaked into the UTF-16 extractor"
2122+
);
2123+
assert_eq!(
2124+
extract_ascii_strings(data, 4).expect("ascii")[0].0,
2125+
"HELLO WORLD"
2126+
);
2127+
}
20482128
}

0 commit comments

Comments
 (0)