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}