Skip to main content

autoit/
strings.rs

1//! Raw string extraction over recovered payload bytes.
2
3use crate::Record;
4
5/// String extraction limits.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct Limits {
8    /// Minimum string length in Unicode scalar values.
9    pub min_chars: usize,
10    /// Maximum number of string findings to retain.
11    pub max_findings: usize,
12}
13
14impl Default for Limits {
15    /// Returns the default extraction limits.
16    ///
17    /// # Returns
18    ///
19    /// A [`Limits`] requiring at least 4 characters per string and retaining up to
20    /// 4096 findings.
21    fn default() -> Self {
22        Self {
23            min_chars: 4,
24            max_findings: 4096,
25        }
26    }
27}
28
29/// A raw string found in recovered bytes.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct StringFinding {
32    record_index: usize,
33    offset: usize,
34    encoding: StringEncoding,
35    value: String,
36}
37
38impl StringFinding {
39    /// Returns the source record index.
40    ///
41    /// # Returns
42    ///
43    /// The index of the [`Record`] this string was extracted from.
44    #[must_use]
45    pub const fn record_index(&self) -> usize {
46        self.record_index
47    }
48
49    /// Returns byte offset inside the record payload.
50    ///
51    /// # Returns
52    ///
53    /// The byte offset within the record payload where the string begins.
54    #[must_use]
55    pub const fn offset(&self) -> usize {
56        self.offset
57    }
58
59    /// Returns string encoding used for extraction.
60    ///
61    /// # Returns
62    ///
63    /// The [`StringEncoding`] under which this string was extracted.
64    #[must_use]
65    pub const fn encoding(&self) -> StringEncoding {
66        self.encoding
67    }
68
69    /// Returns extracted string value.
70    ///
71    /// # Returns
72    ///
73    /// The extracted string value.
74    #[must_use]
75    pub fn value(&self) -> &str {
76        self.value.as_str()
77    }
78}
79
80/// String encoding used for a finding.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum StringEncoding {
83    /// Printable ASCII byte string.
84    Ascii,
85    /// UTF-16 little-endian string with printable ASCII-range code units.
86    Utf16Le,
87}
88
89/// Extracts raw strings from every recovered record payload.
90///
91/// Scans each record payload for both printable ASCII and UTF-16LE strings,
92/// stopping early once `limits.max_findings` results are collected.
93///
94/// # Arguments
95///
96/// * `records` - Parsed AU3 records whose payloads are scanned.
97/// * `limits` - Minimum string length and maximum finding count.
98///
99/// # Returns
100///
101/// A vector of [`StringFinding`] values, capped at `limits.max_findings`.
102#[must_use]
103pub fn extract_from_records(records: &[Record], limits: Limits) -> Vec<StringFinding> {
104    let mut findings = Vec::new();
105    for record in records {
106        extract_ascii(record.index(), record.payload_data(), limits, &mut findings);
107        if findings.len() >= limits.max_findings {
108            return findings;
109        }
110        extract_utf16le(record.index(), record.payload_data(), limits, &mut findings);
111        if findings.len() >= limits.max_findings {
112            return findings;
113        }
114    }
115    findings
116}
117
118/// Scans payload bytes for printable ASCII runs and appends findings.
119///
120/// Walks the bytes tracking the start of each printable run and flushes a finding
121/// when a non-printable byte ends the run or the data is exhausted.
122///
123/// # Arguments
124///
125/// * `record_index` - Index of the source record, stored on each finding.
126/// * `data` - Payload bytes to scan.
127/// * `limits` - Minimum string length and maximum finding count.
128/// * `findings` - Output vector that findings are appended to.
129fn extract_ascii(
130    record_index: usize,
131    data: &[u8],
132    limits: Limits,
133    findings: &mut Vec<StringFinding>,
134) {
135    let mut start = None;
136    for (index, byte) in data.iter().enumerate() {
137        if is_ascii_string_byte(*byte) {
138            if start.is_none() {
139                start = Some(index);
140            }
141        } else if let Some(offset) = start {
142            push_ascii(record_index, data, offset, index, limits, findings);
143            start = None;
144        }
145        if findings.len() >= limits.max_findings {
146            return;
147        }
148    }
149    if let Some(offset) = start {
150        push_ascii(record_index, data, offset, data.len(), limits, findings);
151    }
152}
153
154/// Pushes one ASCII finding for the byte range `start..end` when it qualifies.
155///
156/// The candidate is dropped when shorter than `limits.min_chars`, when the
157/// finding cap is already reached, when the length arithmetic underflows, or when
158/// the range is out of bounds.
159///
160/// # Arguments
161///
162/// * `record_index` - Index of the source record, stored on the finding.
163/// * `data` - Payload bytes containing the candidate run.
164/// * `start` - Inclusive start offset of the run.
165/// * `end` - Exclusive end offset of the run.
166/// * `limits` - Minimum string length and maximum finding count.
167/// * `findings` - Output vector that the finding is appended to.
168fn push_ascii(
169    record_index: usize,
170    data: &[u8],
171    start: usize,
172    end: usize,
173    limits: Limits,
174    findings: &mut Vec<StringFinding>,
175) {
176    let Some(len) = end.checked_sub(start) else {
177        return;
178    };
179    if len < limits.min_chars || findings.len() >= limits.max_findings {
180        return;
181    }
182    let Some(bytes) = data.get(start..end) else {
183        return;
184    };
185    let value = String::from_utf8_lossy(bytes).into_owned();
186    findings.push(StringFinding {
187        record_index,
188        offset: start,
189        encoding: StringEncoding::Ascii,
190        value,
191    });
192}
193
194/// Scans payload bytes for printable UTF-16LE runs and appends findings.
195///
196/// Scans at both even and odd byte alignments so strings are found regardless of
197/// their position, flushing a finding when a non-printable code unit ends a run
198/// or the data is exhausted.
199///
200/// # Arguments
201///
202/// * `record_index` - Index of the source record, stored on each finding.
203/// * `data` - Payload bytes to scan.
204/// * `limits` - Minimum string length and maximum finding count.
205/// * `findings` - Output vector that findings are appended to.
206fn extract_utf16le(
207    record_index: usize,
208    data: &[u8],
209    limits: Limits,
210    findings: &mut Vec<StringFinding>,
211) {
212    for alignment in 0..2usize {
213        let mut start = None;
214        let mut cursor = alignment;
215        while cursor.checked_add(1).is_some_and(|end| end < data.len()) {
216            let Some(unit) = read_u16_at(data, cursor) else {
217                return;
218            };
219            if is_utf16_string_unit(unit) {
220                if start.is_none() {
221                    start = Some(cursor);
222                }
223            } else if let Some(offset) = start {
224                push_utf16(record_index, data, offset, cursor, limits, findings);
225                start = None;
226            }
227            if findings.len() >= limits.max_findings {
228                return;
229            }
230            let Some(next) = cursor.checked_add(2) else {
231                return;
232            };
233            cursor = next;
234        }
235        if let Some(offset) = start {
236            push_utf16(record_index, data, offset, cursor, limits, findings);
237        }
238    }
239}
240
241/// Pushes one UTF-16LE finding for the byte range `start..end` when it qualifies.
242///
243/// The candidate is dropped when its code-unit count is below `limits.min_chars`,
244/// when the finding cap is already reached, when the length arithmetic
245/// underflows, when the range is out of bounds, or when the bytes cannot be read
246/// back as 16-bit units.
247///
248/// # Arguments
249///
250/// * `record_index` - Index of the source record, stored on the finding.
251/// * `data` - Payload bytes containing the candidate run.
252/// * `start` - Inclusive start byte offset of the run.
253/// * `end` - Exclusive end byte offset of the run.
254/// * `limits` - Minimum string length and maximum finding count.
255/// * `findings` - Output vector that the finding is appended to.
256fn push_utf16(
257    record_index: usize,
258    data: &[u8],
259    start: usize,
260    end: usize,
261    limits: Limits,
262    findings: &mut Vec<StringFinding>,
263) {
264    let Some(byte_len) = end.checked_sub(start) else {
265        return;
266    };
267    let char_len = byte_len / 2;
268    if char_len < limits.min_chars || findings.len() >= limits.max_findings {
269        return;
270    }
271    let Some(bytes) = data.get(start..end) else {
272        return;
273    };
274    let units: Option<Vec<u16>> = bytes
275        .chunks_exact(2)
276        .map(|chunk| read_u16_at(chunk, 0))
277        .collect();
278    let Some(units) = units else {
279        return;
280    };
281    findings.push(StringFinding {
282        record_index,
283        offset: start,
284        encoding: StringEncoding::Utf16Le,
285        value: String::from_utf16_lossy(units.as_slice()),
286    });
287}
288
289/// Reports whether a byte is treated as part of a printable ASCII string.
290///
291/// # Arguments
292///
293/// * `byte` - The byte to test.
294///
295/// # Returns
296///
297/// `true` for printable ASCII (`0x20..=0x7e`) or tab, `false` otherwise.
298fn is_ascii_string_byte(byte: u8) -> bool {
299    matches!(byte, 0x20..=0x7e | b'\t')
300}
301
302/// Reports whether a UTF-16 code unit is treated as part of a printable string.
303///
304/// # Arguments
305///
306/// * `unit` - The 16-bit code unit to test.
307///
308/// # Returns
309///
310/// `true` for printable ASCII-range units (`0x20..=0x7e`) or tab, `false`
311/// otherwise.
312fn is_utf16_string_unit(unit: u16) -> bool {
313    matches!(unit, 0x20..=0x7e | 0x09)
314}
315
316/// Reads a little-endian `u16` from `data` at the given byte offset.
317///
318/// # Arguments
319///
320/// * `data` - The byte slice to read from.
321/// * `offset` - Byte offset of the first byte of the 16-bit value.
322///
323/// # Returns
324///
325/// `Some` with the decoded `u16`, or `None` when the offset arithmetic overflows
326/// or the two bytes lie outside `data`.
327fn read_u16_at(data: &[u8], offset: usize) -> Option<u16> {
328    let end = offset.checked_add(2)?;
329    let bytes: [u8; 2] = data.get(offset..end)?.try_into().ok()?;
330    Some(u16::from_le_bytes(bytes))
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::au3::{DecodedString, DecompressionStatus, RecordTestParts};
337
338    #[test]
339    fn extracts_ascii_and_utf16_strings() -> Result<(), String> {
340        let mut payload = Vec::from(b"\0abcd\x01\x01".as_slice());
341        for unit in "WXYZ".encode_utf16() {
342            payload.extend_from_slice(&unit.to_le_bytes());
343        }
344        payload.push(0);
345        let record = test_record(payload)?;
346
347        let findings = extract_from_records(
348            &[record],
349            Limits {
350                min_chars: 4,
351                max_findings: 8,
352            },
353        );
354
355        check_eq(findings.len(), 2, "finding count")?;
356        check_eq(
357            findings.first().map(StringFinding::value),
358            Some("abcd"),
359            "ascii",
360        )?;
361        check_eq(
362            findings.get(1).map(StringFinding::encoding),
363            Some(StringEncoding::Utf16Le),
364            "utf16 encoding",
365        )?;
366        check_eq(
367            findings.get(1).map(StringFinding::value),
368            Some("WXYZ"),
369            "utf16",
370        )
371    }
372
373    fn test_record(payload: Vec<u8>) -> Result<Record, String> {
374        let payload_len = u32::try_from(payload.len()).map_err(|err| err.to_string())?;
375        Ok(Record::from_parts_for_test(RecordTestParts {
376            index: 3,
377            offset: 0,
378            subtype: DecodedString::from_text_for_test("artifact"),
379            name: DecodedString::from_text_for_test("artifact.bin"),
380            compressed: false,
381            compressed_size: payload_len,
382            uncompressed_size: payload_len,
383            checksum: 0,
384            checksum_valid: false,
385            creation_time: 0,
386            last_write_time: 0,
387            encrypted_data: payload.clone(),
388            decrypted_data: payload,
389            decompressed_data: None,
390            decompression_status: DecompressionStatus::NotCompressed,
391            profile: crate::RecordProfile {
392                encoding: crate::Encoding::Ea06,
393                encryption: crate::EncryptionProfile::Ea06Lame,
394                compression: crate::CompressionProfile::None,
395            },
396        }))
397    }
398
399    fn check_eq<T>(actual: T, expected: T, context: &str) -> Result<(), String>
400    where
401        T: core::fmt::Debug + PartialEq,
402    {
403        if actual == expected {
404            Ok(())
405        } else {
406            Err(format!("{context}: got {actual:?}, expected {expected:?}"))
407        }
408    }
409}