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