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/// Which Jump List family a [`JumpList`] was parsed from.
32#[non_exhaustive]
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum JumpListKind {
35    /// `*.automaticDestinations-ms` — a CFB compound file with a `DestList`
36    /// MRU stream and one shell-link sub-stream per entry.
37    Automatic,
38    /// `*.customDestinations-ms` — flat category list of embedded shell links.
39    Custom,
40}
41
42/// A parsed Jump List.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct JumpList {
45    /// Which family the list came from.
46    pub kind: JumpListKind,
47    /// The owning application's `AppID` (lowercase hex), when the caller passed
48    /// the source filename. Resolve a friendly name with
49    /// [`forensicnomicon::jumplist::appid_name`].
50    pub app_id: Option<String>,
51    /// The entries, in stream/category order.
52    pub entries: Vec<JumpListEntry>,
53}
54
55/// One Jump List entry: an embedded shell link plus, for automatic
56/// destinations, its `DestList` MRU metadata.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct JumpListEntry {
59    /// The `DestList` MRU record, present only for automatic destinations.
60    pub destlist: Option<DestListEntry>,
61    /// The embedded `[MS-SHLLINK]` shell link.
62    pub link: ShellLink,
63}
64
65/// A `DestList` stream entry — the per-target MRU metadata that accompanies an
66/// embedded shell link in an automatic-destinations Jump List.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct DestListEntry {
69    /// `DroidVolumeIdentifier` GUID (NTFS object-id volume), canonical form.
70    pub droid_volume_guid: String,
71    /// `DroidFileIdentifier` GUID (NTFS object-id file), canonical form.
72    pub droid_file_guid: String,
73    /// `BirthDroidVolumeIdentifier` GUID (at creation), canonical form.
74    pub birth_droid_volume_guid: String,
75    /// `BirthDroidFileIdentifier` GUID (at creation), canonical form.
76    pub birth_droid_file_guid: String,
77    /// Origin hostname / NetBIOS name (ASCII, NUL padding trimmed).
78    pub hostname: String,
79    /// Entry number — also the name of the LNK sub-stream (lowercase hex).
80    pub entry_number: u32,
81    /// Last-access time, Unix epoch seconds (0 when the FILETIME was 0).
82    pub last_access: i64,
83    /// Whether the entry is pinned (`PinStatus >= 0`).
84    pub pinned: bool,
85    /// Access count — present only in the v2+ (Windows 10/11) layout.
86    pub access_count: Option<u32>,
87    /// The target path recorded in the `DestList` (UTF-16, decoded lossily).
88    pub path: String,
89}
90
91// ── Bounds-checked little-endian readers (never panic on short input) ─────────
92
93fn le_u16(data: &[u8], off: usize) -> u16 {
94    let mut b = [0u8; 2];
95    if let Some(s) = data.get(off..off + 2) {
96        b.copy_from_slice(s);
97    }
98    u16::from_le_bytes(b)
99}
100
101fn le_u32(data: &[u8], off: usize) -> u32 {
102    let mut b = [0u8; 4];
103    if let Some(s) = data.get(off..off + 4) {
104        b.copy_from_slice(s);
105    }
106    u32::from_le_bytes(b)
107}
108
109fn le_i32(data: &[u8], off: usize) -> i32 {
110    le_u32(data, off) as i32
111}
112
113fn le_u64(data: &[u8], off: usize) -> u64 {
114    let mut b = [0u8; 8];
115    if let Some(s) = data.get(off..off + 8) {
116        b.copy_from_slice(s);
117    }
118    u64::from_le_bytes(b)
119}
120
121/// Decode `count` UTF-16LE code units starting at `off`, **lossily** (a DestList
122/// path may carry unpaired surrogates). Returns the decoded string and the
123/// offset just past the consumed bytes.
124fn utf16le_lossy(data: &[u8], off: usize, count: usize) -> (String, usize) {
125    let byte_len = count.saturating_mul(2);
126    let end = off.saturating_add(byte_len);
127    let units: Vec<u16> = data
128        .get(off..end)
129        .unwrap_or_default()
130        .chunks_exact(2)
131        .map(|c| u16::from_le_bytes([c[0], c[1]]))
132        .collect();
133    (String::from_utf16_lossy(&units), end)
134}
135
136/// Extract the `AppID` (lowercase hex) from a Jump List filename such as
137/// `1b4dd67f29cb1962.automaticDestinations-ms`.
138fn appid_from_filename(name: &str) -> Option<String> {
139    let stem = name
140        .rsplit('/')
141        .next()
142        .unwrap_or(name)
143        .rsplit('\\')
144        .next()
145        .unwrap_or(name);
146    let id = stem.split('.').next().unwrap_or(stem);
147    if !id.is_empty() && id.chars().all(|c| c.is_ascii_hexdigit()) {
148        Some(id.to_ascii_lowercase())
149    } else {
150        None
151    }
152}
153
154/// Parse a `*.automaticDestinations-ms` Jump List from its bytes.
155///
156/// Opens the bytes as a CFB compound file, reads the `DestList` MRU stream, and
157/// for each entry opens the matching hex-named shell-link sub-stream and decodes
158/// it with [`parse_shell_link`]. `app_id` is taken from `filename` when given
159/// (e.g. `"1b4dd67f29cb1962.automaticDestinations-ms"`).
160///
161/// Returns [`None`] when the bytes are not a valid CFB compound file or carry no
162/// `DestList` stream. Never panics on hostile input.
163#[must_use]
164pub fn parse_automatic_destinations(data: &[u8], filename: Option<&str>) -> Option<JumpList> {
165    let mut comp = cfb::CompoundFile::open(Cursor::new(data)).ok()?;
166
167    // Read the whole DestList stream into memory (bounded by the CFB layer).
168    let destlist = {
169        let mut stream = comp.open_stream("DestList").ok()?;
170        let mut buf = Vec::new();
171        stream.read_to_end(&mut buf).ok()?;
172        buf
173    };
174
175    let format_version = le_u32(&destlist, jl::DESTLIST_HEADER_FORMAT_VERSION_OFFSET);
176    let extended = format_version >= 2;
177
178    let mut entries = Vec::new();
179    let mut off = jl::DESTLIST_HEADER_SIZE;
180    // Bound the walk by the buffer; each iteration must make forward progress.
181    while off + jl::DESTLIST_ENTRY_PIN_STATUS_OFFSET + 4 <= destlist.len() {
182        let (destlist_entry, next) = parse_destlist_entry(&destlist, off, extended);
183        if next <= off {
184            break; // cov:unreachable: parse_destlist_entry always advances past the path
185        }
186        off = next;
187
188        // The LNK sub-stream is named by the entry number in lowercase hex.
189        let stream_name = format!("{:x}", destlist_entry.entry_number);
190        let mut lnk = Vec::new();
191        if let Ok(mut stream) = comp.open_stream(&stream_name) {
192            // read_to_end into a Vec from an opened CFB stream is infallible in
193            // practice; ignore the Result so there is no unreachable error arm.
194            let _ = stream.read_to_end(&mut lnk);
195        }
196        if let Some(link) = parse_shell_link(&lnk) {
197            entries.push(JumpListEntry {
198                destlist: Some(destlist_entry),
199                link,
200            });
201        }
202    }
203
204    Some(JumpList {
205        kind: JumpListKind::Automatic,
206        app_id: filename.and_then(appid_from_filename),
207        entries,
208    })
209}
210
211/// Parse one `DestList` entry anchored at `base`. Returns the decoded entry and
212/// the offset of the next entry (past the path and, for v2+, the alignment).
213fn parse_destlist_entry(data: &[u8], base: usize, extended: bool) -> (DestListEntry, usize) {
214    let guid_at = |field_off: usize| -> String {
215        data.get(base + field_off..base + field_off + 16)
216            .and_then(guid_string)
217            .unwrap_or_default()
218    };
219
220    let droid_volume_guid = guid_at(jl::DESTLIST_ENTRY_DROID_VOLUME_GUID_OFFSET);
221    let droid_file_guid = guid_at(jl::DESTLIST_ENTRY_DROID_FILE_GUID_OFFSET);
222    let birth_droid_volume_guid = guid_at(jl::DESTLIST_ENTRY_BIRTH_DROID_VOLUME_GUID_OFFSET);
223    let birth_droid_file_guid = guid_at(jl::DESTLIST_ENTRY_BIRTH_DROID_FILE_GUID_OFFSET);
224
225    let hostname = {
226        let start = base + jl::DESTLIST_ENTRY_HOSTNAME_OFFSET;
227        let raw = data
228            .get(start..start + jl::DESTLIST_ENTRY_HOSTNAME_SIZE)
229            .unwrap_or_default();
230        let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len());
231        String::from_utf8_lossy(&raw[..end]).into_owned()
232    };
233
234    let entry_number = le_u32(data, base + jl::DESTLIST_ENTRY_ENTRY_NUMBER_OFFSET);
235    let last_access = filetime_to_unix(le_u64(
236        data,
237        base + jl::DESTLIST_ENTRY_LAST_ACCESS_FILETIME_OFFSET,
238    ));
239    let pin_status = le_i32(data, base + jl::DESTLIST_ENTRY_PIN_STATUS_OFFSET);
240    let pinned = pin_status >= 0;
241
242    let (access_count, path_size_off, path_off, trailing) = if extended {
243        (
244            Some(le_u32(
245                data,
246                base + jl::DESTLIST_ENTRY_V2_ACCESS_COUNT_OFFSET,
247            )),
248            jl::DESTLIST_ENTRY_V2_PATH_SIZE_OFFSET,
249            jl::DESTLIST_ENTRY_V2_PATH_OFFSET,
250            jl::DESTLIST_ENTRY_V2_TRAILING_ALIGNMENT,
251        )
252    } else {
253        (
254            None,
255            jl::DESTLIST_ENTRY_V1_PATH_SIZE_OFFSET,
256            jl::DESTLIST_ENTRY_V1_PATH_OFFSET,
257            0,
258        )
259    };
260
261    let path_chars = le_u16(data, base + path_size_off) as usize;
262    let (path, after_path) = utf16le_lossy(data, base + path_off, path_chars);
263    let next = after_path.saturating_add(trailing);
264
265    (
266        DestListEntry {
267            droid_volume_guid,
268            droid_file_guid,
269            birth_droid_volume_guid,
270            birth_droid_file_guid,
271            hostname,
272            entry_number,
273            last_access,
274            pinned,
275            access_count,
276            path,
277        },
278        next,
279    )
280}
281
282/// Parse a `*.customDestinations-ms` Jump List from its bytes.
283///
284/// Validates the flat header (`FormatVersion == 2`), then splits the embedded
285/// shell links by scanning for the `[MS-SHLLINK]` CLSID and the `0xBABFFBAB`
286/// footer — declared sizes are unreliable — and structurally decodes each LNK
287/// with [`parse_shell_link`]. Category boundaries are not preserved in v0.2; the
288/// entries are returned flat in file order.
289///
290/// Returns [`None`] when the header format version is not `2`.
291#[must_use]
292pub fn parse_custom_destinations(data: &[u8], filename: Option<&str>) -> Option<JumpList> {
293    if le_u32(data, 0) != jl::CUSTOM_DESTINATIONS_FORMAT_VERSION {
294        return None;
295    }
296
297    // The 16-byte LNK CLSID, in little-endian wire order, prefixes every
298    // shell-object entry. NB: the embedded LNK's *own* header also carries this
299    // CLSID at its byte +4 — so a position only marks a shell-object boundary
300    // when the bytes right after it begin a valid LNK header (HeaderSize 0x4C +
301    // a second CLSID copy). That structural test rejects the internal header.
302    let clsid_bytes = clsid_wire_bytes();
303    let is_entry_prefix = |p: usize| -> bool {
304        // p..p+16 is the prefix CLSID; the LNK starts at p+16 and must open with
305        // HeaderSize 0x4C and the LinkCLSID at p+20.
306        data.get(p..p + 16) == Some(&clsid_bytes[..])
307            && le_u32(data, p + 16) == shlink::HEADER_SIZE
308            && data.get(p + 20..p + 36) == Some(&clsid_bytes[..])
309    };
310
311    // Find each shell-object-entry boundary (skip 0x4C past a match so the LNK's
312    // own internal CLSID copy is never mistaken for the next entry).
313    let mut starts = Vec::new();
314    let mut i = 12; // past the 12-byte file header
315    while i + 36 <= data.len() {
316        if is_entry_prefix(i) {
317            starts.push(i);
318            i += 16 + shlink::HEADER_SIZE as usize;
319        } else {
320            i += 1;
321        }
322    }
323
324    let mut entries = Vec::new();
325    for (idx, &prefix) in starts.iter().enumerate() {
326        // The LNK data begins right after the 16-byte CLSID prefix and runs to
327        // the next entry prefix, the footer signature, or end-of-buffer.
328        let lnk_start = prefix + 16;
329        let hard_end = starts.get(idx + 1).copied().unwrap_or(data.len());
330        let end = footer_before(data, lnk_start, hard_end).unwrap_or(hard_end);
331        // lnk_start = prefix + 16 and end <= data.len(), and is_entry_prefix
332        // already proved prefix + 36 <= data.len(), so this range never falls
333        // out of bounds — get() degrades to an empty slice rather than panic.
334        let slice = data.get(lnk_start..end).unwrap_or_default();
335        if let Some(link) = parse_shell_link(slice) {
336            entries.push(JumpListEntry {
337                destlist: None,
338                link,
339            });
340        }
341    }
342
343    Some(JumpList {
344        kind: JumpListKind::Custom,
345        app_id: filename.and_then(appid_from_filename),
346        entries,
347    })
348}
349
350/// Find the `0xBABFFBAB` footer signature within `start..hard_end`, returning
351/// the byte offset where it begins (so the shell-link slice ends there).
352fn footer_before(data: &[u8], start: usize, hard_end: usize) -> Option<usize> {
353    let sig = jl::CUSTOM_DESTINATIONS_FOOTER_SIGNATURE.to_le_bytes();
354    let region = data.get(start..hard_end)?;
355    region.windows(4).position(|w| w == sig).map(|p| start + p)
356}
357
358/// The `[MS-SHLLINK]` CLSID rendered as its 16 little-endian wire bytes — the
359/// byte sequence that prefixes each embedded shell link in a custom
360/// destinations file. Derived from [`forensicnomicon::jumplist::LNK_CLSID`].
361fn clsid_wire_bytes() -> [u8; 16] {
362    // 00021401-0000-0000-C000-000000000046: Data1/2/3 little-endian, Data4 BE.
363    let hex: String = jl::LNK_CLSID.chars().filter(|c| *c != '-').collect();
364    let mut raw = [0u8; 16];
365    for (i, slot) in raw.iter_mut().enumerate() {
366        *slot = u8::from_str_radix(hex.get(i * 2..i * 2 + 2).unwrap_or("00"), 16).unwrap_or(0);
367    }
368    [
369        raw[3], raw[2], raw[1], raw[0], // Data1 LE
370        raw[5], raw[4], // Data2 LE
371        raw[7], raw[6], // Data3 LE
372        raw[8], raw[9], raw[10], raw[11], raw[12], raw[13], raw[14], raw[15], // Data4 BE
373    ]
374}