use forensicnomicon::ntfs::{
attr_flags as flag, attr_offsets as o, attr_types, attribute_type_name,
};
use crate::error::{NtfsError, Result};
const HEADER_MIN: usize = 0x10;
const RESIDENT_MIN: usize = 0x18;
const NONRESIDENT_MIN: usize = 0x40;
const MAX_ATTRIBUTES: usize = 4096;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attribute {
pub type_code: u32,
pub length: u32,
pub non_resident: bool,
pub name: Option<String>,
pub flags: u16,
pub attribute_id: u16,
pub offset: usize,
pub body: AttributeBody,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttributeBody {
Resident {
content_offset: u16,
content_length: u32,
},
NonResident {
start_vcn: u64,
last_vcn: u64,
runs_offset: u16,
compression_unit: u16,
allocated_size: u64,
real_size: u64,
initialized_size: u64,
},
}
impl Attribute {
#[must_use]
pub fn is_compressed(&self) -> bool {
self.flags & flag::COMPRESSED != 0
}
#[must_use]
pub fn is_encrypted(&self) -> bool {
self.flags & flag::ENCRYPTED != 0
}
#[must_use]
pub fn is_sparse(&self) -> bool {
self.flags & flag::SPARSE != 0
}
#[must_use]
pub fn type_name(&self) -> Option<&'static str> {
attribute_type_name(self.type_code)
}
#[must_use]
pub fn resident_content<'a>(&self, record: &'a [u8]) -> Option<&'a [u8]> {
if let AttributeBody::Resident {
content_offset,
content_length,
} = self.body
{
let start = self.offset.checked_add(content_offset as usize)?;
let end = start.checked_add(content_length as usize)?;
record.get(start..end)
} else {
None
}
}
}
pub fn parse_attributes(record: &[u8], first_attr_offset: usize) -> Result<Vec<Attribute>> {
let mut attrs = Vec::new();
let mut pos = first_attr_offset;
let bad = |offset: usize, detail: &'static str| NtfsError::BadAttribute { offset, detail };
for _ in 0..MAX_ATTRIBUTES {
let Some(type_bytes) = record.get(pos + o::TYPE..pos + o::TYPE + 4) else {
break;
};
let type_code = u32::from_le_bytes(type_bytes.try_into().unwrap());
if type_code == attr_types::END {
break;
}
if pos + HEADER_MIN > record.len() {
return Err(bad(pos, "header runs past record"));
}
let length = u32::from_le_bytes(
record[pos + o::LENGTH..pos + o::LENGTH + 4]
.try_into()
.unwrap(),
);
if (length as usize) < HEADER_MIN {
return Err(bad(pos, "length below header minimum"));
}
let end = pos
.checked_add(length as usize)
.ok_or_else(|| bad(pos, "length overflow"))?;
if end > record.len() {
return Err(bad(pos, "attribute extends past record"));
}
let non_resident = record[pos + o::NON_RESIDENT] != 0;
let name_length = record[pos + o::NAME_LENGTH] as usize;
let name_offset = u16::from_le_bytes(
record[pos + o::NAME_OFFSET..pos + o::NAME_OFFSET + 2]
.try_into()
.unwrap(),
) as usize;
let flags = u16::from_le_bytes(
record[pos + o::FLAGS..pos + o::FLAGS + 2]
.try_into()
.unwrap(),
);
let attribute_id = u16::from_le_bytes(
record[pos + o::ATTRIBUTE_ID..pos + o::ATTRIBUTE_ID + 2]
.try_into()
.unwrap(),
);
let name = if name_length == 0 {
None
} else {
let nbytes = name_length
.checked_mul(2)
.ok_or_else(|| bad(pos, "name length overflow"))?;
let nstart = pos
.checked_add(name_offset)
.ok_or_else(|| bad(pos, "name offset overflow"))?;
let nend = nstart
.checked_add(nbytes)
.ok_or_else(|| bad(pos, "name overflow"))?;
if nend > end || nend > record.len() {
return Err(bad(pos, "name out of bounds"));
}
let units: Vec<u16> = record[nstart..nend]
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
Some(
char::decode_utf16(units)
.map(|r| r.unwrap_or('\u{FFFD}'))
.collect(),
)
};
let body = if non_resident {
if pos + NONRESIDENT_MIN > end {
return Err(bad(pos, "non-resident header runs past attribute"));
}
let u64at = |rel: usize| {
u64::from_le_bytes(record[pos + rel..pos + rel + 8].try_into().unwrap())
};
let u16at = |rel: usize| {
u16::from_le_bytes(record[pos + rel..pos + rel + 2].try_into().unwrap())
};
AttributeBody::NonResident {
start_vcn: u64at(o::NR_START_VCN),
last_vcn: u64at(o::NR_LAST_VCN),
runs_offset: u16at(o::NR_RUNS_OFFSET),
compression_unit: u16at(o::NR_COMPRESSION_UNIT),
allocated_size: u64at(o::NR_ALLOCATED_SIZE),
real_size: u64at(o::NR_REAL_SIZE),
initialized_size: u64at(o::NR_INITIALIZED_SIZE),
}
} else {
if pos + RESIDENT_MIN > end {
return Err(bad(pos, "resident header runs past attribute"));
}
let content_length = u32::from_le_bytes(
record[pos + o::RES_CONTENT_LENGTH..pos + o::RES_CONTENT_LENGTH + 4]
.try_into()
.unwrap(),
);
let content_offset = u16::from_le_bytes(
record[pos + o::RES_CONTENT_OFFSET..pos + o::RES_CONTENT_OFFSET + 2]
.try_into()
.unwrap(),
);
let cstart = pos
.checked_add(content_offset as usize)
.ok_or_else(|| bad(pos, "content offset overflow"))?;
let cend = cstart
.checked_add(content_length as usize)
.ok_or_else(|| bad(pos, "content overflow"))?;
if cend > end || cend > record.len() {
return Err(bad(pos, "resident content out of bounds"));
}
AttributeBody::Resident {
content_offset,
content_length,
}
};
attrs.push(Attribute {
type_code,
length,
non_resident,
name,
flags,
attribute_id,
offset: pos,
body,
});
pos = end;
}
Ok(attrs)
}
#[cfg(test)]
mod tests {
use super::*;
fn align8(n: usize) -> usize {
(n + 7) & !7
}
fn resident(type_code: u32, name: Option<&str>, flags: u16, content: &[u8]) -> Vec<u8> {
let name_chars: Vec<u16> = name.map(|n| n.encode_utf16().collect()).unwrap_or_default();
let name_offset = RESIDENT_MIN;
let content_offset = align8(name_offset + name_chars.len() * 2);
let length = align8(content_offset + content.len());
let mut a = vec![0u8; length];
a[o::TYPE..o::TYPE + 4].copy_from_slice(&type_code.to_le_bytes());
a[o::LENGTH..o::LENGTH + 4].copy_from_slice(&(length as u32).to_le_bytes());
a[o::NON_RESIDENT] = 0;
a[o::NAME_LENGTH] = name_chars.len() as u8;
a[o::NAME_OFFSET..o::NAME_OFFSET + 2].copy_from_slice(&(name_offset as u16).to_le_bytes());
a[o::FLAGS..o::FLAGS + 2].copy_from_slice(&flags.to_le_bytes());
a[o::ATTRIBUTE_ID..o::ATTRIBUTE_ID + 2].copy_from_slice(&1u16.to_le_bytes());
a[o::RES_CONTENT_LENGTH..o::RES_CONTENT_LENGTH + 4]
.copy_from_slice(&(content.len() as u32).to_le_bytes());
a[o::RES_CONTENT_OFFSET..o::RES_CONTENT_OFFSET + 2]
.copy_from_slice(&(content_offset as u16).to_le_bytes());
for (i, ch) in name_chars.iter().enumerate() {
let p = name_offset + i * 2;
a[p..p + 2].copy_from_slice(&ch.to_le_bytes());
}
a[content_offset..content_offset + content.len()].copy_from_slice(content);
a
}
#[allow(clippy::too_many_arguments)]
fn nonresident(
type_code: u32,
name: Option<&str>,
flags: u16,
start_vcn: u64,
last_vcn: u64,
allocated: u64,
real: u64,
initialized: u64,
runs: &[u8],
) -> Vec<u8> {
let name_chars: Vec<u16> = name.map(|n| n.encode_utf16().collect()).unwrap_or_default();
let name_offset = NONRESIDENT_MIN;
let runs_offset = align8(name_offset + name_chars.len() * 2);
let length = align8(runs_offset + runs.len());
let mut a = vec![0u8; length];
a[o::TYPE..o::TYPE + 4].copy_from_slice(&type_code.to_le_bytes());
a[o::LENGTH..o::LENGTH + 4].copy_from_slice(&(length as u32).to_le_bytes());
a[o::NON_RESIDENT] = 1;
a[o::NAME_LENGTH] = name_chars.len() as u8;
a[o::NAME_OFFSET..o::NAME_OFFSET + 2].copy_from_slice(&(name_offset as u16).to_le_bytes());
a[o::FLAGS..o::FLAGS + 2].copy_from_slice(&flags.to_le_bytes());
a[o::ATTRIBUTE_ID..o::ATTRIBUTE_ID + 2].copy_from_slice(&2u16.to_le_bytes());
a[o::NR_START_VCN..o::NR_START_VCN + 8].copy_from_slice(&start_vcn.to_le_bytes());
a[o::NR_LAST_VCN..o::NR_LAST_VCN + 8].copy_from_slice(&last_vcn.to_le_bytes());
a[o::NR_RUNS_OFFSET..o::NR_RUNS_OFFSET + 2]
.copy_from_slice(&(runs_offset as u16).to_le_bytes());
a[o::NR_COMPRESSION_UNIT..o::NR_COMPRESSION_UNIT + 2].copy_from_slice(&0u16.to_le_bytes());
a[o::NR_ALLOCATED_SIZE..o::NR_ALLOCATED_SIZE + 8].copy_from_slice(&allocated.to_le_bytes());
a[o::NR_REAL_SIZE..o::NR_REAL_SIZE + 8].copy_from_slice(&real.to_le_bytes());
a[o::NR_INITIALIZED_SIZE..o::NR_INITIALIZED_SIZE + 8]
.copy_from_slice(&initialized.to_le_bytes());
for (i, ch) in name_chars.iter().enumerate() {
let p = name_offset + i * 2;
a[p..p + 2].copy_from_slice(&ch.to_le_bytes());
}
a[runs_offset..runs_offset + runs.len()].copy_from_slice(runs);
a
}
fn record_with(first: usize, attrs: &[Vec<u8>]) -> Vec<u8> {
let mut rec = vec![0u8; first];
for a in attrs {
rec.extend_from_slice(a);
}
rec.extend_from_slice(&attr_types::END.to_le_bytes());
rec
}
#[test]
fn parses_resident_attribute() {
let content = b"\x10\x00\x00\x00hello"; let attr = resident(attr_types::STANDARD_INFORMATION, None, 0, content);
let rec = record_with(0x38, &[attr]);
let attrs = parse_attributes(&rec, 0x38).expect("walk");
assert_eq!(attrs.len(), 1);
let a = &attrs[0];
assert_eq!(a.type_code, attr_types::STANDARD_INFORMATION);
assert!(!a.non_resident);
assert_eq!(a.name, None);
assert_eq!(a.type_name(), Some("$STANDARD_INFORMATION"));
assert_eq!(a.resident_content(&rec), Some(&content[..]));
}
#[test]
fn parses_nonresident_attribute() {
let runs = [0x21u8, 0x08, 0x00, 0x10, 0x00];
let attr = nonresident(
attr_types::DATA,
Some("ads"),
0,
0,
7,
0x8000,
0x7A00,
0x7A00,
&runs,
);
let rec = record_with(0x38, &[attr]);
let attrs = parse_attributes(&rec, 0x38).unwrap();
let a = &attrs[0];
assert!(a.non_resident);
assert_eq!(a.name.as_deref(), Some("ads"));
assert_eq!(
a.body,
AttributeBody::NonResident {
start_vcn: 0,
last_vcn: 7,
runs_offset: 0x48,
compression_unit: 0,
allocated_size: 0x8000,
real_size: 0x7A00,
initialized_size: 0x7A00,
}
);
}
fn header(type_code: u32, length: u32, non_resident: bool) -> Vec<u8> {
let mut a = vec![0u8; length.max(HEADER_MIN as u32) as usize];
a[o::TYPE..o::TYPE + 4].copy_from_slice(&type_code.to_le_bytes());
a[o::LENGTH..o::LENGTH + 4].copy_from_slice(&length.to_le_bytes());
a[o::NON_RESIDENT] = u8::from(non_resident);
a
}
#[test]
fn resident_content_is_none_for_non_resident() {
let runs = [0x21u8, 0x08, 0x00, 0x10, 0x00];
let attr = nonresident(
attr_types::DATA,
None,
0,
0,
7,
0x8000,
0x7A00,
0x7A00,
&runs,
);
let rec = record_with(0x38, &[attr]);
let attrs = parse_attributes(&rec, 0x38).unwrap();
assert_eq!(attrs[0].resident_content(&rec), None);
}
#[test]
fn rejects_header_running_past_record() {
let rec = attr_types::DATA.to_le_bytes().to_vec();
assert!(matches!(
parse_attributes(&rec, 0),
Err(NtfsError::BadAttribute { detail, .. }) if detail == "header runs past record"
));
}
#[test]
fn rejects_nonresident_header_past_attribute() {
let attr = header(attr_types::DATA, 0x20, true);
let rec = record_with(0, &[attr]);
assert!(matches!(
parse_attributes(&rec, 0),
Err(NtfsError::BadAttribute { detail, .. })
if detail == "non-resident header runs past attribute"
));
}
#[test]
fn rejects_resident_header_past_attribute() {
let attr = header(attr_types::DATA, 0x10, false);
let rec = record_with(0, &[attr]);
assert!(matches!(
parse_attributes(&rec, 0),
Err(NtfsError::BadAttribute { detail, .. })
if detail == "resident header runs past attribute"
));
}
#[test]
fn rejects_resident_content_out_of_bounds() {
let mut attr = header(attr_types::DATA, 0x18, false);
attr[o::RES_CONTENT_LENGTH..o::RES_CONTENT_LENGTH + 4]
.copy_from_slice(&0xFFFFu32.to_le_bytes());
attr[o::RES_CONTENT_OFFSET..o::RES_CONTENT_OFFSET + 2]
.copy_from_slice(&0x18u16.to_le_bytes());
let rec = record_with(0, &[attr]);
assert!(matches!(
parse_attributes(&rec, 0),
Err(NtfsError::BadAttribute { detail, .. })
if detail == "resident content out of bounds"
));
}
#[test]
fn decodes_named_ads_attribute() {
let attr = resident(
attr_types::DATA,
Some("Zone.Identifier"),
0,
b"[ZoneTransfer]",
);
let rec = record_with(0x38, &[attr]);
let attrs = parse_attributes(&rec, 0x38).unwrap();
assert_eq!(attrs[0].name.as_deref(), Some("Zone.Identifier"));
}
#[test]
fn walks_multiple_attributes_until_end() {
let si = resident(attr_types::STANDARD_INFORMATION, None, 0, &[0u8; 48]);
let fname = resident(attr_types::FILE_NAME, None, 0, &[0u8; 66]);
let data = resident(attr_types::DATA, None, 0, b"file contents");
let rec = record_with(0x38, &[si, fname, data]);
let attrs = parse_attributes(&rec, 0x38).unwrap();
assert_eq!(attrs.len(), 3);
assert_eq!(attrs[0].type_code, attr_types::STANDARD_INFORMATION);
assert_eq!(attrs[1].type_code, attr_types::FILE_NAME);
assert_eq!(attrs[2].type_code, attr_types::DATA);
}
#[test]
fn detects_compressed_and_sparse_flags() {
let attr = nonresident(
attr_types::DATA,
None,
flag::COMPRESSED | flag::SPARSE,
0,
0,
0x1000,
0x800,
0x800,
&[0x00],
);
let rec = record_with(0x38, &[attr]);
let a = &parse_attributes(&rec, 0x38).unwrap()[0];
assert!(a.is_compressed());
assert!(a.is_sparse());
assert!(!a.is_encrypted());
}
#[test]
fn end_marker_at_start_yields_no_attributes() {
let rec = record_with(0x38, &[]);
assert!(parse_attributes(&rec, 0x38).unwrap().is_empty());
}
#[test]
fn rejects_zero_length_attribute() {
let mut rec = vec![0u8; 0x40];
rec[0x00..0x04].copy_from_slice(&attr_types::DATA.to_le_bytes());
rec[0x04..0x08].copy_from_slice(&0u32.to_le_bytes()); assert!(matches!(
parse_attributes(&rec, 0x00),
Err(NtfsError::BadAttribute { .. })
));
}
#[test]
fn rejects_length_below_header_min() {
let mut rec = vec![0u8; 0x40];
rec[0x00..0x04].copy_from_slice(&attr_types::DATA.to_le_bytes());
rec[0x04..0x08].copy_from_slice(&8u32.to_le_bytes()); assert!(matches!(
parse_attributes(&rec, 0x00),
Err(NtfsError::BadAttribute { .. })
));
}
#[test]
fn rejects_attribute_past_record_end() {
let mut rec = vec![0u8; 0x20];
rec[0x00..0x04].copy_from_slice(&attr_types::DATA.to_le_bytes());
rec[0x04..0x08].copy_from_slice(&0x1000u32.to_le_bytes()); rec[0x08] = 0;
assert!(matches!(
parse_attributes(&rec, 0x00),
Err(NtfsError::BadAttribute { .. })
));
}
#[test]
fn rejects_name_out_of_bounds() {
let mut attr = resident(attr_types::DATA, None, 0, b"x");
attr[o::NAME_LENGTH] = 200; attr[o::NAME_OFFSET..o::NAME_OFFSET + 2]
.copy_from_slice(&(RESIDENT_MIN as u16).to_le_bytes());
let rec = record_with(0x00, &[attr]);
assert!(matches!(
parse_attributes(&rec, 0x00),
Err(NtfsError::BadAttribute { .. })
));
}
#[test]
fn missing_end_marker_does_not_overrun() {
let attr = resident(attr_types::DATA, None, 0, b"data");
let mut rec = vec![0u8; 0];
rec.extend_from_slice(&attr);
let attrs = parse_attributes(&rec, 0).unwrap();
assert_eq!(attrs.len(), 1);
}
}