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