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}