Skip to main content

lnk_core/
jumplist.rs

1//! Windows Jump List reader — `*.automaticDestinations-ms` (an OLE/CFB compound
2//! file holding a `DestList` MRU stream plus one embedded `[MS-SHLLINK]` shell
3//! link per entry) and `*.customDestinations-ms` (a flat sequence of categories,
4//! each a run of concatenated shell links).
5//!
6//! Forensic value: a Jump List ties a per-application MRU history (recency, pin
7//! state, access count, origin hostname) to the full target evidence of each
8//! embedded `.lnk` (path, volume serial, droid GUIDs). The offset tables and the
9//! `0xBABFFBAB` footer / CLSID boundary live in
10//! [`forensicnomicon::jumplist`] (knowledge-only); the parsing is here.
11//!
12//! Input is attacker-controllable evidence: every read is bounds-checked, the
13//! CFB layer is the mature `cfb` crate, declared shell-link sizes are treated as
14//! unreliable (the custom-destinations splitter scans for the CLSID/footer
15//! rather than trusting a length), and the path string is decoded **lossily**
16//! because a `DestList` path may carry unpaired surrogates. Malformed input
17//! yields [`None`] or an empty entry list, never a panic.
18//!
19//! # Authoritative source
20//!
21//! libyal `dtformats`, *Jump lists format*:
22//! <https://github.com/libyal/dtformats/blob/main/documentation/Jump%20lists%20format.asciidoc>
23
24use std::io::{Cursor, Read};
25
26use forensicnomicon::jumplist as jl;
27use forensicnomicon::shlink;
28
29use crate::{filetime_to_unix, guid_string, parse_shell_link, ShellLink};
30
31/// The structural reason a byte buffer could not be parsed as a Jump List.
32///
33/// The input is an in-memory `&[u8]`, so there is no device I/O to fail — every
34/// failure here is structural ("not a valid CFB", "no DestList stream", "wrong
35/// custom-destinations version"). Each variant carries the offending value so an
36/// investigator who feeds a corrupt `.automaticDestinations-ms` /
37/// `.customDestinations-ms` learns *why* it did not parse instead of a silent
38/// [`None`].
39#[non_exhaustive]
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum JumplistError {
42    /// The bytes are not an OLE/CFB compound file. Automatic-destinations Jump
43    /// Lists are CFB; the CFB magic is `D0 CF 11 E0 A1 B1 1A E1`. `found_magic`
44    /// is the first 8 bytes that *were* present (zero-padded if shorter).
45    NotCompoundFile {
46        /// The first 8 bytes of the input (the CFB magic position).
47        found_magic: [u8; 8],
48    },
49    /// The bytes are a valid CFB compound file, but it carries no `DestList`
50    /// stream — so it is not an automatic-destinations Jump List.
51    MissingDestListStream,
52    /// The bytes are not a `customDestinations-ms` file: the 4-byte header format
53    /// version is not [`forensicnomicon::jumplist::CUSTOM_DESTINATIONS_FORMAT_VERSION`].
54    /// `found` is the version value that was actually read.
55    BadCustomFormatVersion {
56        /// The format-version value read from the header.
57        found: u32,
58    },
59}
60
61impl std::fmt::Display for JumplistError {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            Self::NotCompoundFile { found_magic } => write!(
65                f,
66                "not an OLE/CFB compound file (expected magic D0CF11E0A1B11AE1, \
67                 found {found_magic:02X?})"
68            ),
69            Self::MissingDestListStream => {
70                write!(f, "CFB compound file has no DestList stream")
71            }
72            Self::BadCustomFormatVersion { found } => write!(
73                f,
74                "customDestinations header format version is {found}, expected {}",
75                jl::CUSTOM_DESTINATIONS_FORMAT_VERSION
76            ),
77        }
78    }
79}
80
81impl std::error::Error for JumplistError {}
82
83/// Which Jump List family a [`JumpList`] was parsed from.
84#[non_exhaustive]
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum JumpListKind {
87    /// `*.automaticDestinations-ms` — a CFB compound file with a `DestList`
88    /// MRU stream and one shell-link sub-stream per entry.
89    Automatic,
90    /// `*.customDestinations-ms` — flat category list of embedded shell links.
91    Custom,
92}
93
94/// A parsed Jump List.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct JumpList {
97    /// Which family the list came from.
98    pub kind: JumpListKind,
99    /// The owning application's `AppID` (lowercase hex), when the caller passed
100    /// the source filename. Resolve a friendly name with
101    /// [`forensicnomicon::jumplist::appid_name`].
102    pub app_id: Option<String>,
103    /// The entries, in stream/category order.
104    pub entries: Vec<JumpListEntry>,
105}
106
107/// One Jump List entry: an embedded shell link plus, for automatic
108/// destinations, its `DestList` MRU metadata.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct JumpListEntry {
111    /// The `DestList` MRU record, present only for automatic destinations.
112    pub destlist: Option<DestListEntry>,
113    /// The embedded `[MS-SHLLINK]` shell link.
114    pub link: ShellLink,
115}
116
117/// A `DestList` stream entry — the per-target MRU metadata that accompanies an
118/// embedded shell link in an automatic-destinations Jump List.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct DestListEntry {
121    /// `DroidVolumeIdentifier` GUID (NTFS object-id volume), canonical form.
122    pub droid_volume_guid: String,
123    /// `DroidFileIdentifier` GUID (NTFS object-id file), canonical form.
124    pub droid_file_guid: String,
125    /// `BirthDroidVolumeIdentifier` GUID (at creation), canonical form.
126    pub birth_droid_volume_guid: String,
127    /// `BirthDroidFileIdentifier` GUID (at creation), canonical form.
128    pub birth_droid_file_guid: String,
129    /// Origin hostname / NetBIOS name (ASCII, NUL padding trimmed).
130    pub hostname: String,
131    /// Entry number — also the name of the LNK sub-stream (lowercase hex).
132    pub entry_number: u32,
133    /// Last-access time, Unix epoch seconds (0 when the FILETIME was 0).
134    pub last_access: i64,
135    /// Whether the entry is pinned (`PinStatus >= 0`).
136    pub pinned: bool,
137    /// Access count — present only in the v2+ (Windows 10/11) layout.
138    pub access_count: Option<u32>,
139    /// The target path recorded in the `DestList` (UTF-16, decoded lossily).
140    pub path: String,
141}
142
143// ── Bounds-checked little-endian readers (never panic on short input) ─────────
144
145fn le_u16(data: &[u8], off: usize) -> u16 {
146    let mut b = [0u8; 2];
147    if let Some(s) = data.get(off..off + 2) {
148        b.copy_from_slice(s);
149    }
150    u16::from_le_bytes(b)
151}
152
153fn le_u32(data: &[u8], off: usize) -> u32 {
154    let mut b = [0u8; 4];
155    if let Some(s) = data.get(off..off + 4) {
156        b.copy_from_slice(s);
157    }
158    u32::from_le_bytes(b)
159}
160
161fn le_i32(data: &[u8], off: usize) -> i32 {
162    le_u32(data, off) as i32
163}
164
165fn le_u64(data: &[u8], off: usize) -> u64 {
166    let mut b = [0u8; 8];
167    if let Some(s) = data.get(off..off + 8) {
168        b.copy_from_slice(s);
169    }
170    u64::from_le_bytes(b)
171}
172
173/// Decode `count` UTF-16LE code units starting at `off`, **lossily** (a DestList
174/// path may carry unpaired surrogates). Returns the decoded string and the
175/// offset just past the consumed bytes.
176fn utf16le_lossy(data: &[u8], off: usize, count: usize) -> (String, usize) {
177    let byte_len = count.saturating_mul(2);
178    let end = off.saturating_add(byte_len);
179    let units: Vec<u16> = data
180        .get(off..end)
181        .unwrap_or_default()
182        .chunks_exact(2)
183        .map(|c| u16::from_le_bytes([c[0], c[1]]))
184        .collect();
185    (String::from_utf16_lossy(&units), end)
186}
187
188/// Extract the `AppID` (lowercase hex) from a Jump List filename such as
189/// `1b4dd67f29cb1962.automaticDestinations-ms`.
190fn appid_from_filename(name: &str) -> Option<String> {
191    let stem = name
192        .rsplit('/')
193        .next()
194        .unwrap_or(name)
195        .rsplit('\\')
196        .next()
197        .unwrap_or(name);
198    let id = stem.split('.').next().unwrap_or(stem);
199    if !id.is_empty() && id.chars().all(|c| c.is_ascii_hexdigit()) {
200        Some(id.to_ascii_lowercase())
201    } else {
202        None
203    }
204}
205
206/// Parse a `*.automaticDestinations-ms` Jump List from its bytes.
207///
208/// Opens the bytes as a CFB compound file, reads the `DestList` MRU stream, and
209/// for each entry opens the matching hex-named shell-link sub-stream and decodes
210/// it with [`parse_shell_link`]. `app_id` is taken from `filename` when given
211/// (e.g. `"1b4dd67f29cb1962.automaticDestinations-ms"`).
212///
213/// Returns [`None`] when the bytes are not a valid CFB compound file or carry no
214/// `DestList` stream. Never panics on hostile input.
215///
216/// Lenient wrapper over [`parse_automatic_destinations_checked`]: the structural
217/// reason for a failure is discarded. Use the checked variant to learn *why* a
218/// file did not parse (bad CFB header vs missing `DestList` stream).
219#[must_use]
220pub fn parse_automatic_destinations(data: &[u8], filename: Option<&str>) -> Option<JumpList> {
221    parse_automatic_destinations_checked(data, filename).ok()
222}
223
224/// Parse a `*.automaticDestinations-ms` Jump List, surfacing the structural
225/// reason for any failure.
226///
227/// - `Err(JumplistError::NotCompoundFile { found_magic })` — the bytes are not a
228///   CFB compound file; `found_magic` carries the first 8 bytes that were there.
229/// - `Err(JumplistError::MissingDestListStream)` — a valid CFB without a
230///   `DestList` stream (so not an automatic-destinations Jump List).
231/// - `Ok(JumpList)` — a parsed Jump List (its `entries` may be empty if no
232///   embedded shell link decoded, but the `DestList` was present).
233///
234/// `app_id` is taken from `filename` when given. Never panics on hostile input.
235pub fn parse_automatic_destinations_checked(
236    data: &[u8],
237    filename: Option<&str>,
238) -> Result<JumpList, JumplistError> {
239    let mut comp = cfb::CompoundFile::open(Cursor::new(data)).map_err(|_| {
240        let mut found_magic = [0u8; 8];
241        let n = data.len().min(8);
242        found_magic[..n].copy_from_slice(&data[..n]);
243        JumplistError::NotCompoundFile { found_magic }
244    })?;
245
246    // Read the whole DestList stream into memory (bounded by the CFB layer).
247    let destlist = {
248        let mut stream = comp
249            .open_stream("DestList")
250            .map_err(|_| JumplistError::MissingDestListStream)?;
251        let mut buf = Vec::new();
252        // read_to_end into a Vec from an opened CFB stream is infallible in
253        // practice (the bytes are already in memory); ignore the Result so there
254        // is no unreachable error arm to cover.
255        let _ = stream.read_to_end(&mut buf);
256        buf
257    };
258
259    let format_version = le_u32(&destlist, jl::DESTLIST_HEADER_FORMAT_VERSION_OFFSET);
260    let extended = format_version >= 2;
261
262    let mut entries = Vec::new();
263    let mut off = jl::DESTLIST_HEADER_SIZE;
264    // Bound the walk by the buffer; each iteration must make forward progress.
265    while off + jl::DESTLIST_ENTRY_PIN_STATUS_OFFSET + 4 <= destlist.len() {
266        let (destlist_entry, next) = parse_destlist_entry(&destlist, off, extended);
267        if next <= off {
268            break; // cov:unreachable: parse_destlist_entry always advances past the path
269        }
270        off = next;
271
272        // The LNK sub-stream is named by the entry number in lowercase hex.
273        let stream_name = format!("{:x}", destlist_entry.entry_number);
274        let mut lnk = Vec::new();
275        if let Ok(mut stream) = comp.open_stream(&stream_name) {
276            // read_to_end into a Vec from an opened CFB stream is infallible in
277            // practice; ignore the Result so there is no unreachable error arm.
278            let _ = stream.read_to_end(&mut lnk);
279        }
280        if let Some(link) = parse_shell_link(&lnk) {
281            entries.push(JumpListEntry {
282                destlist: Some(destlist_entry),
283                link,
284            });
285        }
286    }
287
288    Ok(JumpList {
289        kind: JumpListKind::Automatic,
290        app_id: filename.and_then(appid_from_filename),
291        entries,
292    })
293}
294
295/// Parse one `DestList` entry anchored at `base`. Returns the decoded entry and
296/// the offset of the next entry (past the path and, for v2+, the alignment).
297fn parse_destlist_entry(data: &[u8], base: usize, extended: bool) -> (DestListEntry, usize) {
298    let guid_at = |field_off: usize| -> String {
299        data.get(base + field_off..base + field_off + 16)
300            .and_then(guid_string)
301            .unwrap_or_default()
302    };
303
304    let droid_volume_guid = guid_at(jl::DESTLIST_ENTRY_DROID_VOLUME_GUID_OFFSET);
305    let droid_file_guid = guid_at(jl::DESTLIST_ENTRY_DROID_FILE_GUID_OFFSET);
306    let birth_droid_volume_guid = guid_at(jl::DESTLIST_ENTRY_BIRTH_DROID_VOLUME_GUID_OFFSET);
307    let birth_droid_file_guid = guid_at(jl::DESTLIST_ENTRY_BIRTH_DROID_FILE_GUID_OFFSET);
308
309    let hostname = {
310        let start = base + jl::DESTLIST_ENTRY_HOSTNAME_OFFSET;
311        let raw = data
312            .get(start..start + jl::DESTLIST_ENTRY_HOSTNAME_SIZE)
313            .unwrap_or_default();
314        let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len());
315        String::from_utf8_lossy(&raw[..end]).into_owned()
316    };
317
318    let entry_number = le_u32(data, base + jl::DESTLIST_ENTRY_ENTRY_NUMBER_OFFSET);
319    let last_access = filetime_to_unix(le_u64(
320        data,
321        base + jl::DESTLIST_ENTRY_LAST_ACCESS_FILETIME_OFFSET,
322    ));
323    let pin_status = le_i32(data, base + jl::DESTLIST_ENTRY_PIN_STATUS_OFFSET);
324    let pinned = pin_status >= 0;
325
326    let (access_count, path_size_off, path_off, trailing) = if extended {
327        (
328            Some(le_u32(
329                data,
330                base + jl::DESTLIST_ENTRY_V2_ACCESS_COUNT_OFFSET,
331            )),
332            jl::DESTLIST_ENTRY_V2_PATH_SIZE_OFFSET,
333            jl::DESTLIST_ENTRY_V2_PATH_OFFSET,
334            jl::DESTLIST_ENTRY_V2_TRAILING_ALIGNMENT,
335        )
336    } else {
337        (
338            None,
339            jl::DESTLIST_ENTRY_V1_PATH_SIZE_OFFSET,
340            jl::DESTLIST_ENTRY_V1_PATH_OFFSET,
341            0,
342        )
343    };
344
345    let path_chars = le_u16(data, base + path_size_off) as usize;
346    let (path, after_path) = utf16le_lossy(data, base + path_off, path_chars);
347    let next = after_path.saturating_add(trailing);
348
349    (
350        DestListEntry {
351            droid_volume_guid,
352            droid_file_guid,
353            birth_droid_volume_guid,
354            birth_droid_file_guid,
355            hostname,
356            entry_number,
357            last_access,
358            pinned,
359            access_count,
360            path,
361        },
362        next,
363    )
364}
365
366/// Parse a `*.customDestinations-ms` Jump List from its bytes.
367///
368/// Validates the flat header (`FormatVersion == 2`), then splits the embedded
369/// shell links by scanning for the `[MS-SHLLINK]` CLSID and the `0xBABFFBAB`
370/// footer — declared sizes are unreliable — and structurally decodes each LNK
371/// with [`parse_shell_link`]. Category boundaries are not preserved in v0.2; the
372/// entries are returned flat in file order.
373///
374/// Returns [`None`] when the header format version is not `2`.
375///
376/// Lenient wrapper over [`parse_custom_destinations_checked`]: the structural
377/// reason for a failure is discarded.
378#[must_use]
379pub fn parse_custom_destinations(data: &[u8], filename: Option<&str>) -> Option<JumpList> {
380    parse_custom_destinations_checked(data, filename).ok()
381}
382
383/// Parse a `*.customDestinations-ms` Jump List, surfacing the structural reason
384/// for a failure.
385///
386/// - `Err(JumplistError::BadCustomFormatVersion { found })` — the 4-byte header
387///   format version is not the expected value; `found` is what was read.
388/// - `Ok(JumpList)` — a parsed Jump List (entries in flat file order).
389pub fn parse_custom_destinations_checked(
390    data: &[u8],
391    filename: Option<&str>,
392) -> Result<JumpList, JumplistError> {
393    let format_version = le_u32(data, 0);
394    if format_version != jl::CUSTOM_DESTINATIONS_FORMAT_VERSION {
395        return Err(JumplistError::BadCustomFormatVersion {
396            found: format_version,
397        });
398    }
399
400    // The 16-byte LNK CLSID, in little-endian wire order, prefixes every
401    // shell-object entry. NB: the embedded LNK's *own* header also carries this
402    // CLSID at its byte +4 — so a position only marks a shell-object boundary
403    // when the bytes right after it begin a valid LNK header (HeaderSize 0x4C +
404    // a second CLSID copy). That structural test rejects the internal header.
405    let clsid_bytes = clsid_wire_bytes();
406    let is_entry_prefix = |p: usize| -> bool {
407        // p..p+16 is the prefix CLSID; the LNK starts at p+16 and must open with
408        // HeaderSize 0x4C and the LinkCLSID at p+20.
409        data.get(p..p + 16) == Some(&clsid_bytes[..])
410            && le_u32(data, p + 16) == shlink::HEADER_SIZE
411            && data.get(p + 20..p + 36) == Some(&clsid_bytes[..])
412    };
413
414    // Find each shell-object-entry boundary (skip 0x4C past a match so the LNK's
415    // own internal CLSID copy is never mistaken for the next entry).
416    let mut starts = Vec::new();
417    let mut i = 12; // past the 12-byte file header
418    while i + 36 <= data.len() {
419        if is_entry_prefix(i) {
420            starts.push(i);
421            i += 16 + shlink::HEADER_SIZE as usize;
422        } else {
423            i += 1;
424        }
425    }
426
427    let mut entries = Vec::new();
428    for (idx, &prefix) in starts.iter().enumerate() {
429        // The LNK data begins right after the 16-byte CLSID prefix and runs to
430        // the next entry prefix, the footer signature, or end-of-buffer.
431        let lnk_start = prefix + 16;
432        let hard_end = starts.get(idx + 1).copied().unwrap_or(data.len());
433        let end = footer_before(data, lnk_start, hard_end).unwrap_or(hard_end);
434        // lnk_start = prefix + 16 and end <= data.len(), and is_entry_prefix
435        // already proved prefix + 36 <= data.len(), so this range never falls
436        // out of bounds — get() degrades to an empty slice rather than panic.
437        let slice = data.get(lnk_start..end).unwrap_or_default();
438        if let Some(link) = parse_shell_link(slice) {
439            entries.push(JumpListEntry {
440                destlist: None,
441                link,
442            });
443        }
444    }
445
446    Ok(JumpList {
447        kind: JumpListKind::Custom,
448        app_id: filename.and_then(appid_from_filename),
449        entries,
450    })
451}
452
453/// Find the `0xBABFFBAB` footer signature within `start..hard_end`, returning
454/// the byte offset where it begins (so the shell-link slice ends there).
455fn footer_before(data: &[u8], start: usize, hard_end: usize) -> Option<usize> {
456    let sig = jl::CUSTOM_DESTINATIONS_FOOTER_SIGNATURE.to_le_bytes();
457    let region = data.get(start..hard_end)?;
458    region.windows(4).position(|w| w == sig).map(|p| start + p)
459}
460
461/// The `[MS-SHLLINK]` CLSID rendered as its 16 little-endian wire bytes — the
462/// byte sequence that prefixes each embedded shell link in a custom
463/// destinations file. Derived from [`forensicnomicon::jumplist::LNK_CLSID`].
464fn clsid_wire_bytes() -> [u8; 16] {
465    // 00021401-0000-0000-C000-000000000046: Data1/2/3 little-endian, Data4 BE.
466    let hex: String = jl::LNK_CLSID.chars().filter(|c| *c != '-').collect();
467    let mut raw = [0u8; 16];
468    for (i, slot) in raw.iter_mut().enumerate() {
469        *slot = u8::from_str_radix(hex.get(i * 2..i * 2 + 2).unwrap_or("00"), 16).unwrap_or(0);
470    }
471    [
472        raw[3], raw[2], raw[1], raw[0], // Data1 LE
473        raw[5], raw[4], // Data2 LE
474        raw[7], raw[6], // Data3 LE
475        raw[8], raw[9], raw[10], raw[11], raw[12], raw[13], raw[14], raw[15], // Data4 BE
476    ]
477}