oletools_rs 0.1.0

Rust port of oletools — analysis tools for Microsoft Office files (VBA macros, DDE, OLE objects, RTF exploits)
Documentation
//! DDE detection in Excel binary (.xls) files.
//!
//! Scans for SupBook records (record type 0x01AE) that indicate
//! DDE links to external applications.

use crate::error::Result;
use crate::msodde::field_parser::DdeField;
use crate::ole::container::OleFile;

/// SupBook record type in BIFF8 format.
const SUPBOOK_RECORD_TYPE: u16 = 0x01AE;

/// Scan an Excel binary (.xls) file for DDE links via SupBook records.
pub fn process_xls(data: &[u8]) -> Result<Vec<DdeField>> {
    let mut ole = OleFile::from_bytes(data)?;
    let streams = ole.list_streams();
    let mut fields = Vec::new();

    // Find Workbook or Book stream
    let workbook_stream = streams
        .iter()
        .find(|s| {
            let lower = s.to_lowercase();
            lower.ends_with("workbook") || lower.ends_with("book")
        })
        .cloned();

    if let Some(stream_path) = workbook_stream
        && let Ok(stream_data) = ole.open_stream(&stream_path) {
            let extracted = scan_supbook_records(&stream_data);
            fields.extend(extracted);
        }

    Ok(fields)
}

/// Scan binary stream for SupBook records with DDE links.
fn scan_supbook_records(data: &[u8]) -> Vec<DdeField> {
    let mut fields = Vec::new();
    let mut pos = 0;
    let len = data.len();

    while pos + 4 <= len {
        let record_type = u16::from_le_bytes([data[pos], data[pos + 1]]);
        let record_size = u16::from_le_bytes([data[pos + 2], data[pos + 3]]) as usize;
        pos += 4;

        if pos + record_size > len {
            break;
        }

        if record_type == SUPBOOK_RECORD_TYPE && record_size >= 4 {
            let record_data = &data[pos..pos + record_size];

            // SupBook structure:
            // ctab (2 bytes): number of sheets
            // cch (2 bytes): length of virtual path
            // virtPath: encoded string
            let ctab = u16::from_le_bytes([record_data[0], record_data[1]]);
            let cch = u16::from_le_bytes([record_data[2], record_data[3]]);

            // DDE link: cch > 0 and ctab = 0, or special encoding
            // Virtual path starting with 0x01 indicates a DDE link
            if cch > 0 && record_size > 4 {
                let path_start = 4;
                let is_dde = if path_start < record_data.len() {
                    record_data[path_start] == 0x01
                } else {
                    false
                };

                if is_dde {
                    // Extract DDE application name
                    let app_name = extract_dde_app_name(record_data, path_start + 1, cch as usize);

                    fields.push(DdeField {
                        field_instruction: format!(
                            "SupBook DDE link (ctab={}, cch={})",
                            ctab, cch
                        ),
                        command: app_name.clone(),
                        source: app_name,
                        quote_decoded: None,
                    });
                }
            }
        }

        pos += record_size;
    }

    fields
}

/// Extract DDE application name from SupBook record data.
fn extract_dde_app_name(data: &[u8], start: usize, max_len: usize) -> String {
    let end = std::cmp::min(start + max_len, data.len());
    if start >= end {
        return String::new();
    }

    // Try to read as byte string (Latin1)
    let bytes = &data[start..end];
    let name: String = bytes
        .iter()
        .take_while(|&&b| b != 0 && b != 0x01)
        .map(|&b| b as char)
        .collect();

    name
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_scan_supbook_dde() {
        // Build a minimal SupBook record with DDE link
        let mut data = Vec::new();

        // Record type: 0x01AE (SupBook)
        data.extend_from_slice(&SUPBOOK_RECORD_TYPE.to_le_bytes());

        // Record body
        let mut body = Vec::new();
        body.extend_from_slice(&0u16.to_le_bytes()); // ctab = 0
        body.extend_from_slice(&4u16.to_le_bytes()); // cch = 4
        body.push(0x01); // DDE flag
        body.extend_from_slice(b"cmd"); // app name

        // Record size
        data.extend_from_slice(&(body.len() as u16).to_le_bytes());
        data.extend_from_slice(&body);

        let fields = scan_supbook_records(&data);
        assert_eq!(fields.len(), 1);
        assert_eq!(fields[0].source, "cmd");
    }

    #[test]
    fn test_scan_supbook_non_dde() {
        // SupBook record without DDE flag
        let mut data = Vec::new();
        data.extend_from_slice(&SUPBOOK_RECORD_TYPE.to_le_bytes());

        let mut body = Vec::new();
        body.extend_from_slice(&1u16.to_le_bytes()); // ctab = 1
        body.extend_from_slice(&5u16.to_le_bytes()); // cch = 5
        body.push(0x00); // NOT a DDE flag
        body.extend_from_slice(b"test");

        data.extend_from_slice(&(body.len() as u16).to_le_bytes());
        data.extend_from_slice(&body);

        let fields = scan_supbook_records(&data);
        assert!(fields.is_empty());
    }

    #[test]
    fn test_scan_empty() {
        let fields = scan_supbook_records(&[]);
        assert!(fields.is_empty());
    }

    #[test]
    fn test_scan_truncated() {
        let fields = scan_supbook_records(&[0x01, 0x02]);
        assert!(fields.is_empty());
    }
}