Skip to main content

ntfs_core/
attribute.rs

1//! MFT attribute walking: the common attribute header plus the resident and
2//! non-resident bodies.
3//!
4//! After the record header, attributes are laid out back-to-back, each starting
5//! with a common header (type, length, resident flag, optional name, flags),
6//! terminated by an end marker (`0xFFFF_FFFF`). Resident attributes store their
7//! value inline; non-resident attributes store a *runlist* mapping the file's
8//! virtual clusters to on-disk clusters.
9//!
10//! Every field is bounds-checked against the record and the attribute's own
11//! declared length: a crafted record can never drive an out-of-bounds read or
12//! an unbounded loop.
13//!
14//! Type codes, names, attribute-header field offsets, and flags all come from
15//! the [`forensicnomicon::ntfs`] KNOWLEDGE layer.
16
17use forensicnomicon::ntfs::{
18    attr_flags as flag, attr_offsets as o, attr_types, attribute_type_name,
19};
20
21use crate::bytes::{le_u16, le_u32, le_u64};
22use crate::error::{NtfsError, Result};
23
24/// Minimum bytes of a common attribute header (through attribute id).
25const HEADER_MIN: usize = 0x10;
26/// Minimum bytes of a resident attribute header (through content offset + pad).
27const RESIDENT_MIN: usize = 0x18;
28/// Minimum bytes of a non-resident attribute header (through initialized size).
29const NONRESIDENT_MIN: usize = 0x40;
30/// Hard cap on attributes per record — belt-and-suspenders against crafted input.
31const MAX_ATTRIBUTES: usize = 4096;
32
33/// A parsed MFT attribute (common header + body discriminant).
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Attribute {
36    /// Attribute type code (e.g. `0x80` for `$DATA`).
37    pub type_code: u32,
38    /// Total on-disk length of this attribute, including its header.
39    pub length: u32,
40    /// `true` when the value is stored out-of-line via a runlist.
41    pub non_resident: bool,
42    /// Decoded attribute name (e.g. an ADS stream name), or `None` when unnamed.
43    pub name: Option<String>,
44    /// Attribute flags (compressed / encrypted / sparse).
45    pub flags: u16,
46    /// Attribute id, unique within the record.
47    pub attribute_id: u16,
48    /// Byte offset of this attribute within the record.
49    pub offset: usize,
50    /// Resident or non-resident body.
51    pub body: AttributeBody,
52}
53
54/// The resident/non-resident discriminant of an attribute.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum AttributeBody {
57    /// The value is stored inline within the record.
58    Resident {
59        content_offset: u16,
60        content_length: u32,
61    },
62    /// The value is stored in clusters described by a runlist.
63    NonResident {
64        start_vcn: u64,
65        last_vcn: u64,
66        runs_offset: u16,
67        compression_unit: u16,
68        allocated_size: u64,
69        real_size: u64,
70        initialized_size: u64,
71    },
72}
73
74impl Attribute {
75    /// `true` if the attribute is compressed.
76    #[must_use]
77    pub fn is_compressed(&self) -> bool {
78        self.flags & flag::COMPRESSED != 0
79    }
80
81    /// The compression-unit size as a power of two clusters (NTFS `2^n`; `0`
82    /// means no compression unit / resident). A compressed `$DATA` stores its
83    /// data in `2^compression_unit`-cluster units.
84    #[must_use]
85    pub fn compression_unit(&self) -> u16 {
86        match self.body {
87            AttributeBody::NonResident {
88                compression_unit, ..
89            } => compression_unit,
90            AttributeBody::Resident { .. } => 0,
91        }
92    }
93
94    /// `true` if the attribute is encrypted (EFS).
95    #[must_use]
96    pub fn is_encrypted(&self) -> bool {
97        self.flags & flag::ENCRYPTED != 0
98    }
99
100    /// `true` if the attribute is sparse.
101    #[must_use]
102    pub fn is_sparse(&self) -> bool {
103        self.flags & flag::SPARSE != 0
104    }
105
106    /// Canonical `$NAME` of this attribute type, if known.
107    #[must_use]
108    pub fn type_name(&self) -> Option<&'static str> {
109        attribute_type_name(self.type_code)
110    }
111
112    /// For a resident attribute, its value bytes within `record`. Returns `None`
113    /// for non-resident attributes or if the slice is out of bounds.
114    #[must_use]
115    pub fn resident_content<'a>(&self, record: &'a [u8]) -> Option<&'a [u8]> {
116        if let AttributeBody::Resident {
117            content_offset,
118            content_length,
119        } = self.body
120        {
121            let start = self.offset.checked_add(content_offset as usize)?;
122            let end = start.checked_add(content_length as usize)?;
123            record.get(start..end)
124        } else {
125            None
126        }
127    }
128}
129
130/// Walk the attribute chain of a (fixed-up) record, starting at
131/// `first_attr_offset`, until the end marker.
132///
133/// # Errors
134///
135/// [`NtfsError::BadAttribute`] for any attribute that is undersized, declares a
136/// length that wouldn't advance the cursor, or whose name/body would read
137/// outside the record.
138pub fn parse_attributes(record: &[u8], first_attr_offset: usize) -> Result<Vec<Attribute>> {
139    let mut attrs = Vec::new();
140    let mut pos = first_attr_offset;
141
142    let bad = |offset: usize, detail: &'static str| NtfsError::BadAttribute { offset, detail };
143
144    for _ in 0..MAX_ATTRIBUTES {
145        // Need 4 bytes to read the type / end marker; run-off-the-end stops cleanly.
146        if record.get(pos + o::TYPE..pos + o::TYPE + 4).is_none() {
147            break;
148        }
149        let type_code = le_u32(record, pos + o::TYPE);
150        if type_code == attr_types::END {
151            break;
152        }
153
154        if pos + HEADER_MIN > record.len() {
155            return Err(bad(pos, "header runs past record"));
156        }
157
158        let length = le_u32(record, pos + o::LENGTH);
159        if (length as usize) < HEADER_MIN {
160            return Err(bad(pos, "length below header minimum"));
161        }
162        let end = pos
163            .checked_add(length as usize)
164            .ok_or_else(|| bad(pos, "length overflow"))?;
165        if end > record.len() {
166            return Err(bad(pos, "attribute extends past record"));
167        }
168
169        let non_resident = record[pos + o::NON_RESIDENT] != 0;
170        let name_length = record[pos + o::NAME_LENGTH] as usize;
171        let name_offset = le_u16(record, pos + o::NAME_OFFSET) as usize;
172        let flags = le_u16(record, pos + o::FLAGS);
173        let attribute_id = le_u16(record, pos + o::ATTRIBUTE_ID);
174
175        // Optional name, bounded by both the attribute's length and the record.
176        let name = if name_length == 0 {
177            None
178        } else {
179            let nbytes = name_length
180                .checked_mul(2)
181                .ok_or_else(|| bad(pos, "name length overflow"))?;
182            let nstart = pos
183                .checked_add(name_offset)
184                .ok_or_else(|| bad(pos, "name offset overflow"))?;
185            let nend = nstart
186                .checked_add(nbytes)
187                .ok_or_else(|| bad(pos, "name overflow"))?;
188            if nend > end || nend > record.len() {
189                return Err(bad(pos, "name out of bounds"));
190            }
191            let units: Vec<u16> = record[nstart..nend]
192                .chunks_exact(2)
193                .map(|c| u16::from_le_bytes([c[0], c[1]]))
194                .collect();
195            Some(
196                char::decode_utf16(units)
197                    .map(|r| r.unwrap_or('\u{FFFD}'))
198                    .collect(),
199            )
200        };
201
202        let body = if non_resident {
203            if pos + NONRESIDENT_MIN > end {
204                return Err(bad(pos, "non-resident header runs past attribute"));
205            }
206            let u64at = |rel: usize| le_u64(record, pos + rel);
207            let u16at = |rel: usize| le_u16(record, pos + rel);
208            AttributeBody::NonResident {
209                start_vcn: u64at(o::NR_START_VCN),
210                last_vcn: u64at(o::NR_LAST_VCN),
211                runs_offset: u16at(o::NR_RUNS_OFFSET),
212                compression_unit: u16at(o::NR_COMPRESSION_UNIT),
213                allocated_size: u64at(o::NR_ALLOCATED_SIZE),
214                real_size: u64at(o::NR_REAL_SIZE),
215                initialized_size: u64at(o::NR_INITIALIZED_SIZE),
216            }
217        } else {
218            if pos + RESIDENT_MIN > end {
219                return Err(bad(pos, "resident header runs past attribute"));
220            }
221            let content_length = le_u32(record, pos + o::RES_CONTENT_LENGTH);
222            let content_offset = le_u16(record, pos + o::RES_CONTENT_OFFSET);
223            let cstart = pos
224                .checked_add(content_offset as usize)
225                .ok_or_else(|| bad(pos, "content offset overflow"))?;
226            let cend = cstart
227                .checked_add(content_length as usize)
228                .ok_or_else(|| bad(pos, "content overflow"))?;
229            if cend > end || cend > record.len() {
230                return Err(bad(pos, "resident content out of bounds"));
231            }
232            AttributeBody::Resident {
233                content_offset,
234                content_length,
235            }
236        };
237
238        attrs.push(Attribute {
239            type_code,
240            length,
241            non_resident,
242            name,
243            flags,
244            attribute_id,
245            offset: pos,
246            body,
247        });
248
249        pos = end;
250    }
251
252    Ok(attrs)
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    fn align8(n: usize) -> usize {
260        (n + 7) & !7
261    }
262
263    /// Build one resident attribute.
264    fn resident(type_code: u32, name: Option<&str>, flags: u16, content: &[u8]) -> Vec<u8> {
265        let name_chars: Vec<u16> = name.map(|n| n.encode_utf16().collect()).unwrap_or_default();
266        let name_offset = RESIDENT_MIN;
267        let content_offset = align8(name_offset + name_chars.len() * 2);
268        let length = align8(content_offset + content.len());
269        let mut a = vec![0u8; length];
270        a[o::TYPE..o::TYPE + 4].copy_from_slice(&type_code.to_le_bytes());
271        a[o::LENGTH..o::LENGTH + 4].copy_from_slice(&(length as u32).to_le_bytes());
272        a[o::NON_RESIDENT] = 0;
273        a[o::NAME_LENGTH] = name_chars.len() as u8;
274        a[o::NAME_OFFSET..o::NAME_OFFSET + 2].copy_from_slice(&(name_offset as u16).to_le_bytes());
275        a[o::FLAGS..o::FLAGS + 2].copy_from_slice(&flags.to_le_bytes());
276        a[o::ATTRIBUTE_ID..o::ATTRIBUTE_ID + 2].copy_from_slice(&1u16.to_le_bytes());
277        a[o::RES_CONTENT_LENGTH..o::RES_CONTENT_LENGTH + 4]
278            .copy_from_slice(&(content.len() as u32).to_le_bytes());
279        a[o::RES_CONTENT_OFFSET..o::RES_CONTENT_OFFSET + 2]
280            .copy_from_slice(&(content_offset as u16).to_le_bytes());
281        for (i, ch) in name_chars.iter().enumerate() {
282            let p = name_offset + i * 2;
283            a[p..p + 2].copy_from_slice(&ch.to_le_bytes());
284        }
285        a[content_offset..content_offset + content.len()].copy_from_slice(content);
286        a
287    }
288
289    /// Build one non-resident attribute with the given runlist bytes.
290    #[allow(clippy::too_many_arguments)]
291    fn nonresident(
292        type_code: u32,
293        name: Option<&str>,
294        flags: u16,
295        start_vcn: u64,
296        last_vcn: u64,
297        allocated: u64,
298        real: u64,
299        initialized: u64,
300        runs: &[u8],
301    ) -> Vec<u8> {
302        let name_chars: Vec<u16> = name.map(|n| n.encode_utf16().collect()).unwrap_or_default();
303        let name_offset = NONRESIDENT_MIN;
304        let runs_offset = align8(name_offset + name_chars.len() * 2);
305        let length = align8(runs_offset + runs.len());
306        let mut a = vec![0u8; length];
307        a[o::TYPE..o::TYPE + 4].copy_from_slice(&type_code.to_le_bytes());
308        a[o::LENGTH..o::LENGTH + 4].copy_from_slice(&(length as u32).to_le_bytes());
309        a[o::NON_RESIDENT] = 1;
310        a[o::NAME_LENGTH] = name_chars.len() as u8;
311        a[o::NAME_OFFSET..o::NAME_OFFSET + 2].copy_from_slice(&(name_offset as u16).to_le_bytes());
312        a[o::FLAGS..o::FLAGS + 2].copy_from_slice(&flags.to_le_bytes());
313        a[o::ATTRIBUTE_ID..o::ATTRIBUTE_ID + 2].copy_from_slice(&2u16.to_le_bytes());
314        a[o::NR_START_VCN..o::NR_START_VCN + 8].copy_from_slice(&start_vcn.to_le_bytes());
315        a[o::NR_LAST_VCN..o::NR_LAST_VCN + 8].copy_from_slice(&last_vcn.to_le_bytes());
316        a[o::NR_RUNS_OFFSET..o::NR_RUNS_OFFSET + 2]
317            .copy_from_slice(&(runs_offset as u16).to_le_bytes());
318        a[o::NR_COMPRESSION_UNIT..o::NR_COMPRESSION_UNIT + 2].copy_from_slice(&0u16.to_le_bytes());
319        a[o::NR_ALLOCATED_SIZE..o::NR_ALLOCATED_SIZE + 8].copy_from_slice(&allocated.to_le_bytes());
320        a[o::NR_REAL_SIZE..o::NR_REAL_SIZE + 8].copy_from_slice(&real.to_le_bytes());
321        a[o::NR_INITIALIZED_SIZE..o::NR_INITIALIZED_SIZE + 8]
322            .copy_from_slice(&initialized.to_le_bytes());
323        for (i, ch) in name_chars.iter().enumerate() {
324            let p = name_offset + i * 2;
325            a[p..p + 2].copy_from_slice(&ch.to_le_bytes());
326        }
327        a[runs_offset..runs_offset + runs.len()].copy_from_slice(runs);
328        a
329    }
330
331    /// Assemble a record: zeroed up to `first`, the attributes, then the end marker.
332    fn record_with(first: usize, attrs: &[Vec<u8>]) -> Vec<u8> {
333        let mut rec = vec![0u8; first];
334        for a in attrs {
335            rec.extend_from_slice(a);
336        }
337        rec.extend_from_slice(&attr_types::END.to_le_bytes());
338        rec
339    }
340
341    #[test]
342    fn parses_resident_attribute() {
343        let content = b"\x10\x00\x00\x00hello"; // arbitrary $STANDARD_INFORMATION-ish bytes
344        let attr = resident(attr_types::STANDARD_INFORMATION, None, 0, content);
345        let rec = record_with(0x38, &[attr]);
346        let attrs = parse_attributes(&rec, 0x38).expect("walk");
347        assert_eq!(attrs.len(), 1);
348        let a = &attrs[0];
349        assert_eq!(a.type_code, attr_types::STANDARD_INFORMATION);
350        assert!(!a.non_resident);
351        assert_eq!(a.name, None);
352        assert_eq!(a.type_name(), Some("$STANDARD_INFORMATION"));
353        assert_eq!(a.resident_content(&rec), Some(&content[..]));
354    }
355
356    #[test]
357    fn parses_nonresident_attribute() {
358        // A trivial single-run runlist: header 0x21, 1 length byte, 2 offset bytes.
359        // Named, so the name-encoding path is exercised too.
360        let runs = [0x21u8, 0x08, 0x00, 0x10, 0x00];
361        let attr = nonresident(
362            attr_types::DATA,
363            Some("ads"),
364            0,
365            0,
366            7,
367            0x8000,
368            0x7A00,
369            0x7A00,
370            &runs,
371        );
372        let rec = record_with(0x38, &[attr]);
373        let attrs = parse_attributes(&rec, 0x38).unwrap();
374        let a = &attrs[0];
375        assert!(a.non_resident);
376        assert_eq!(a.name.as_deref(), Some("ads"));
377        assert_eq!(
378            a.body,
379            AttributeBody::NonResident {
380                start_vcn: 0,
381                last_vcn: 7,
382                runs_offset: 0x48,
383                compression_unit: 0,
384                allocated_size: 0x8000,
385                real_size: 0x7A00,
386                initialized_size: 0x7A00,
387            }
388        );
389    }
390
391    /// A 16-byte header with a custom declared `length` and resident flag, used
392    /// to drive the bounds-check error branches.
393    fn header(type_code: u32, length: u32, non_resident: bool) -> Vec<u8> {
394        let mut a = vec![0u8; length.max(HEADER_MIN as u32) as usize];
395        a[o::TYPE..o::TYPE + 4].copy_from_slice(&type_code.to_le_bytes());
396        a[o::LENGTH..o::LENGTH + 4].copy_from_slice(&length.to_le_bytes());
397        a[o::NON_RESIDENT] = u8::from(non_resident);
398        a
399    }
400
401    #[test]
402    fn resident_content_is_none_for_non_resident() {
403        let runs = [0x21u8, 0x08, 0x00, 0x10, 0x00];
404        let attr = nonresident(
405            attr_types::DATA,
406            None,
407            0,
408            0,
409            7,
410            0x8000,
411            0x7A00,
412            0x7A00,
413            &runs,
414        );
415        let rec = record_with(0x38, &[attr]);
416        let attrs = parse_attributes(&rec, 0x38).unwrap();
417        assert_eq!(attrs[0].resident_content(&rec), None);
418    }
419
420    #[test]
421    fn rejects_header_running_past_record() {
422        // 4 valid type bytes (DATA), but no room for the rest of the header.
423        let rec = attr_types::DATA.to_le_bytes().to_vec();
424        assert!(matches!(
425            parse_attributes(&rec, 0),
426            Err(NtfsError::BadAttribute { detail, .. }) if detail == "header runs past record"
427        ));
428    }
429
430    #[test]
431    fn rejects_nonresident_header_past_attribute() {
432        // non-resident flag set but length < NONRESIDENT_MIN.
433        let attr = header(attr_types::DATA, 0x20, true);
434        let rec = record_with(0, &[attr]);
435        assert!(matches!(
436            parse_attributes(&rec, 0),
437            Err(NtfsError::BadAttribute { detail, .. })
438                if detail == "non-resident header runs past attribute"
439        ));
440    }
441
442    #[test]
443    fn rejects_resident_header_past_attribute() {
444        // resident, length in [HEADER_MIN, RESIDENT_MIN).
445        let attr = header(attr_types::DATA, 0x10, false);
446        let rec = record_with(0, &[attr]);
447        assert!(matches!(
448            parse_attributes(&rec, 0),
449            Err(NtfsError::BadAttribute { detail, .. })
450                if detail == "resident header runs past attribute"
451        ));
452    }
453
454    #[test]
455    fn rejects_resident_content_out_of_bounds() {
456        // resident header, but content_offset + content_length exceeds the attr.
457        let mut attr = header(attr_types::DATA, 0x18, false);
458        attr[o::RES_CONTENT_LENGTH..o::RES_CONTENT_LENGTH + 4]
459            .copy_from_slice(&0xFFFFu32.to_le_bytes());
460        attr[o::RES_CONTENT_OFFSET..o::RES_CONTENT_OFFSET + 2]
461            .copy_from_slice(&0x18u16.to_le_bytes());
462        let rec = record_with(0, &[attr]);
463        assert!(matches!(
464            parse_attributes(&rec, 0),
465            Err(NtfsError::BadAttribute { detail, .. })
466                if detail == "resident content out of bounds"
467        ));
468    }
469
470    #[test]
471    fn decodes_named_ads_attribute() {
472        let attr = resident(
473            attr_types::DATA,
474            Some("Zone.Identifier"),
475            0,
476            b"[ZoneTransfer]",
477        );
478        let rec = record_with(0x38, &[attr]);
479        let attrs = parse_attributes(&rec, 0x38).unwrap();
480        assert_eq!(attrs[0].name.as_deref(), Some("Zone.Identifier"));
481    }
482
483    #[test]
484    fn walks_multiple_attributes_until_end() {
485        let si = resident(attr_types::STANDARD_INFORMATION, None, 0, &[0u8; 48]);
486        let fname = resident(attr_types::FILE_NAME, None, 0, &[0u8; 66]);
487        let data = resident(attr_types::DATA, None, 0, b"file contents");
488        let rec = record_with(0x38, &[si, fname, data]);
489        let attrs = parse_attributes(&rec, 0x38).unwrap();
490        assert_eq!(attrs.len(), 3);
491        assert_eq!(attrs[0].type_code, attr_types::STANDARD_INFORMATION);
492        assert_eq!(attrs[1].type_code, attr_types::FILE_NAME);
493        assert_eq!(attrs[2].type_code, attr_types::DATA);
494    }
495
496    #[test]
497    fn detects_compressed_and_sparse_flags() {
498        let attr = nonresident(
499            attr_types::DATA,
500            None,
501            flag::COMPRESSED | flag::SPARSE,
502            0,
503            0,
504            0x1000,
505            0x800,
506            0x800,
507            &[0x00],
508        );
509        let rec = record_with(0x38, &[attr]);
510        let a = &parse_attributes(&rec, 0x38).unwrap()[0];
511        assert!(a.is_compressed());
512        assert!(a.is_sparse());
513        assert!(!a.is_encrypted());
514    }
515
516    #[test]
517    fn end_marker_at_start_yields_no_attributes() {
518        let rec = record_with(0x38, &[]);
519        assert!(parse_attributes(&rec, 0x38).unwrap().is_empty());
520    }
521
522    // ── Hardening against crafted records ─────────────────────────────────────
523
524    #[test]
525    fn rejects_zero_length_attribute() {
526        // length = 0 would never advance the cursor — must be rejected, not loop.
527        let mut rec = vec![0u8; 0x40];
528        rec[0x00..0x04].copy_from_slice(&attr_types::DATA.to_le_bytes());
529        rec[0x04..0x08].copy_from_slice(&0u32.to_le_bytes()); // length 0
530        assert!(matches!(
531            parse_attributes(&rec, 0x00),
532            Err(NtfsError::BadAttribute { .. })
533        ));
534    }
535
536    #[test]
537    fn rejects_length_below_header_min() {
538        let mut rec = vec![0u8; 0x40];
539        rec[0x00..0x04].copy_from_slice(&attr_types::DATA.to_le_bytes());
540        rec[0x04..0x08].copy_from_slice(&8u32.to_le_bytes()); // < HEADER_MIN
541        assert!(matches!(
542            parse_attributes(&rec, 0x00),
543            Err(NtfsError::BadAttribute { .. })
544        ));
545    }
546
547    #[test]
548    fn rejects_attribute_past_record_end() {
549        let mut rec = vec![0u8; 0x20];
550        rec[0x00..0x04].copy_from_slice(&attr_types::DATA.to_le_bytes());
551        rec[0x04..0x08].copy_from_slice(&0x1000u32.to_le_bytes()); // way past the 0x20 record
552        rec[0x08] = 0;
553        assert!(matches!(
554            parse_attributes(&rec, 0x00),
555            Err(NtfsError::BadAttribute { .. })
556        ));
557    }
558
559    #[test]
560    fn rejects_name_out_of_bounds() {
561        // A resident attr claiming a long name that runs past its own length.
562        let mut attr = resident(attr_types::DATA, None, 0, b"x");
563        attr[o::NAME_LENGTH] = 200; // 200 u16 chars = 400 bytes, far past the attr
564        attr[o::NAME_OFFSET..o::NAME_OFFSET + 2]
565            .copy_from_slice(&(RESIDENT_MIN as u16).to_le_bytes());
566        let rec = record_with(0x00, &[attr]);
567        assert!(matches!(
568            parse_attributes(&rec, 0x00),
569            Err(NtfsError::BadAttribute { .. })
570        ));
571    }
572
573    #[test]
574    fn missing_end_marker_does_not_overrun() {
575        // A single attribute that fills the record with no end marker: the walk
576        // must stop at the record boundary, not read past it.
577        let attr = resident(attr_types::DATA, None, 0, b"data");
578        let mut rec = vec![0u8; 0];
579        rec.extend_from_slice(&attr);
580        // no end marker appended
581        let attrs = parse_attributes(&rec, 0).unwrap();
582        assert_eq!(attrs.len(), 1);
583    }
584}