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 Word binary (.doc) files.
//!
//! Scans OLE streams for field markers:
//! - 0x13: field begin
//! - 0x14: field separator
//! - 0x15: field end

use crate::error::Result;
use crate::msodde::field_parser::{self, DdeField};
use crate::ole::container::OleFile;

/// Field marker bytes in Word binary format.
const FIELD_BEGIN: u8 = 0x13;
const FIELD_SEP: u8 = 0x14;
const FIELD_END: u8 = 0x15;

/// Scan a Word binary (.doc) file for DDE fields.
pub fn process_doc(data: &[u8]) -> Result<Vec<DdeField>> {
    let mut ole = OleFile::from_bytes(data)?;
    let streams = ole.list_streams();
    let mut fields = Vec::new();

    // Check main document streams
    let target_streams: Vec<_> = streams
        .iter()
        .filter(|s| {
            let lower = s.to_lowercase();
            lower.contains("worddocument")
                || lower.contains("1table")
                || lower.contains("0table")
                || lower.contains("data")
        })
        .cloned()
        .collect();

    for stream_path in &target_streams {
        if let Ok(stream_data) = ole.open_stream(stream_path) {
            let extracted = extract_fields_from_binary(&stream_data);
            fields.extend(extracted);
        }
    }

    Ok(fields)
}

/// Extract DDE fields from binary stream data using field markers.
fn extract_fields_from_binary(data: &[u8]) -> Vec<DdeField> {
    let mut fields = Vec::new();
    let mut i = 0;
    let len = data.len();

    while i < len {
        if data[i] == FIELD_BEGIN {
            // Found field begin — collect instruction text until separator or end
            let mut instruction = String::new();
            i += 1;

            while i < len && data[i] != FIELD_SEP && data[i] != FIELD_END {
                if data[i] == FIELD_BEGIN {
                    // Nested field — skip it
                    let mut depth = 1;
                    i += 1;
                    while i < len && depth > 0 {
                        if data[i] == FIELD_BEGIN {
                            depth += 1;
                        } else if data[i] == FIELD_END {
                            depth -= 1;
                        }
                        i += 1;
                    }
                    continue;
                }

                // Collect printable ASCII characters
                if data[i].is_ascii_graphic() || data[i] == b' ' {
                    instruction.push(data[i] as char);
                }
                i += 1;
            }

            // Skip to field end
            while i < len && data[i] != FIELD_END {
                i += 1;
            }

            // Check if this is a DDE field
            let instruction = instruction.trim().to_string();
            if field_parser::is_dde_field(&instruction)
                && let Some(dde) = field_parser::parse_dde_field(&instruction) {
                    fields.push(dde);
                }

            // Also check for QUOTE-encoded DDE
            if let Some(decoded) = field_parser::decode_quote_field(&instruction)
                && field_parser::is_dde_field(&decoded)
                    && let Some(mut dde) = field_parser::parse_dde_field(&decoded) {
                        dde.quote_decoded = Some(decoded);
                        fields.push(dde);
                    }
        }

        i += 1;
    }

    fields
}

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

    #[test]
    fn test_extract_fields_with_dde() {
        // Simulate binary stream with DDE field
        let mut data = Vec::new();
        data.push(FIELD_BEGIN);
        data.extend_from_slice(b" DDEAUTO cmd.exe /c calc ");
        data.push(FIELD_SEP);
        data.extend_from_slice(b"result");
        data.push(FIELD_END);

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

    #[test]
    fn test_extract_fields_no_dde() {
        let mut data = Vec::new();
        data.push(FIELD_BEGIN);
        data.extend_from_slice(b" DATE \\@ \"yyyy-MM-dd\" ");
        data.push(FIELD_END);

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

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

    #[test]
    fn test_extract_fields_nested() {
        // Nested field: begin, begin, "PAGE", end, "DDE cmd", end
        let mut data = Vec::new();
        data.push(FIELD_BEGIN);
        // Nested field
        data.push(FIELD_BEGIN);
        data.extend_from_slice(b"PAGE");
        data.push(FIELD_END);
        data.extend_from_slice(b" DDEAUTO cmd.exe /c whoami ");
        data.push(FIELD_END);

        let fields = extract_fields_from_binary(&data);
        assert_eq!(fields.len(), 1);
    }

    #[test]
    fn test_extract_quote_encoded_non_dde() {
        // QUOTE 68 65 84 69 = "DATE" — a safe field, not DDE
        let mut data = Vec::new();
        data.push(FIELD_BEGIN);
        data.extend_from_slice(b" QUOTE 68 65 84 69 ");
        data.push(FIELD_END);

        let fields = extract_fields_from_binary(&data);
        assert!(fields.is_empty(), "DATE is a safe field, should not trigger DDE");
    }

    #[test]
    fn test_extract_quote_encoded_dde() {
        // QUOTE 68 68 69 = "DDE" — should be detected
        let mut data = Vec::new();
        data.push(FIELD_BEGIN);
        data.extend_from_slice(b" QUOTE 68 68 69 ");
        data.push(FIELD_END);

        let fields = extract_fields_from_binary(&data);
        assert_eq!(fields.len(), 1, "QUOTE encoding 'DDE' should be detected");
    }
}