Туннелирование NTFS: поиск USN Record в незанятой области

Страницы:  1

Ответить
 

Professor Seleznov


pic
Привет, Хабр! На связи uFactor. В одной из предыдущих статей мы рассказывали о туннелировании файловой системы NTFS и затронули тему карвинга. В этой статье — на примере из предыдущей — разберем, как можно осуществить поиск удаленных записей USN-журнала в незанятой области.
Давайте вспомним следующую историю из прошлого материала: мы подменили содержимое файла 5ac761dd7e05df02eef0f0d7562f45c2.png, записав в него другое изображение и при этом сохранив все временные метки в $MFT. Использовали нестандартную технику совместно с туннелированием. Операция для туннеля — переименование файла: file → new_file → file. Также определились, что основными Reason для таких событий будут RENAME_NEW_NAME и RENAME_OLD_NAME.
Теперь посмотрим записи USN-журнала для этого события.
pic
Рисунок 1. Фрагмент USN-записей, связанных с туннелем
На рисунке 1 можно увидеть, что время событий переименования меньше секунды. Файл переименовывается в 5ac761dd7e05df02eef0f0d7562f45c21.png и обратно. Обратите внимание на следующее:
·       зеленым цветом выделено MFT Entry;
·       желтым — Sequence Number;
·       красным цветом — Parent Entry Number и Parent Sequence Number.
Теперь посмотрим на файл 5ac761dd7e05df02eef0f0d7562f45c2.png в $MFT.
pic
Рисунок 2. Файл «5ac761dd7e05df02eef0f0d7562f45c2.png» после подмены содержимого
Сравните рисунки 1 и 2. Как видим, MFT Entry, Sequence Number, Parent Entry Number и Parent Sequence Number не изменились после подмены содержимого. А теперь на рисунке 3 посмотрим на временные метки, которые сохранили свои значения (время события подмены содержимого — 2025-11-01 14:31:30). Напомню, что MFTECmd (Eric Zimmerman's tools) выводит результат следующим образом: если временные метки $SI и $FN совпадают, то в полях для 0x30 ($FN) значения будут пусты.
pic
Рисунок 3. Файл «5ac761dd7e05df02eef0f0d7562f45c2.png» и временные метки
А теперь напомню, что в USN Journal (Change Journal) записи по умолчанию могут храниться неделями или месяцами для низко нагруженных систем и от нескольких часов до 2 дней для высоко нагруженных. И без этих записей вы можете увидеть картину, как на рисунке 3, и сделать неправильные выводы в отношении файла.
Наша задача — сделать все возможное для построения всестороннего и объективного анализа. Одним из кирпичиков будет карвинг незанятой области.
Получить неразмеченную область в виде файла можно с образа диска либо с его клона — или же непосредственно с самого носителя информации. Например, при помощи Autopsy (бесплатного программного обеспечения) или X-Ways Forensics (платное).
pic
Рисунок 4. Извлечение незанятой области в файл при помощи Autopsy
Для карвинга USN-записей нам необходимо знать, что нужно искать в этом нераспределенном пространстве. Давайте разберем структуру на нашем примере с подменным содержимым файла. Напомним, что журнал USN структурирован в два альтернативных потока данных (ADS) и резервный файл. Все журналирование хранится последовательно в формате беззнакового целого числа в файле $J($UsnJrnl:$J). Когда размер файла журнала USN превышает определенное значение, журнал начинает перезаписывать старые данные. Вы можете проверить размер журнала USN с помощью инструмента fsutil.
Получить файл $J можно с приобретенного образа диска (клона и т. п.) при помощи Autopsy либо при сборе артефактов с живой системы, например при помощи утилиты KAPE.
Откроем файл $J в hex-редакторе и найдем записи, связанные с файлами 5ac761dd7e05df02eef0f0d7562f45c2.png и 5ac761dd7e05df02eef0f0d7562f45c21.png, а именно — RENAME_NEW_NAME и RENAME_OLD_NAME.
pic
Рисунок 5. USN-записи
Попытаемся разобраться с записями, представленными на рисунке 5. О структуре USN-записей, а также Reason-кодах можно прочитать в документации Microsoft: USN_RECORD_V2 structure (winioctl.h), USN_RECORD_V3 structure (winioctl.h), USN_RECORD_V4 structure (winioctl.h). По умолчанию вам будет встречаться USN_RECORD_V2.
Давайте разбираться с рисунком 5. Начнем с файла 5ac761dd7e05df02eef0f0d7562f45c2.png. Заголовок — USN_RECORD_V2 (первые 56 байт). Заголовки типа V3 имеют ту же структуру.
Смещение Размер Значение (hex) Описание
0x00 4 88000000 (коричневый цвет) Record Length = 0x88 (136 байт)
0x04 2 200 (серый цвет) Major Version = 2
0x06 2 0 (серый цвет) Minor Version = 0
0x08 8 7D8A030000000500 (зеленый цвет) File Reference Number = 0x00038A7D (MFT entry) + 0x0500 (sequence)
0x10 8 3CC9020000003500 (желтый цвет) Parent File Reference Number = 0x0002C93C (parent MFT) + 0x3500 (sequence)
0x18 8 88A0C41B00000000 (голубой цвет) USN = 0x000000001BC4A088
0x20 8 81C8FD363C4BDC01 (красный цвет) Timestamp = Windows FileTime
0x28 4 00100000 (синий цвет) Reason = 0x1000 = USN_REASON_RENAME_OLD_NAME
0x2C 2 0 Source Info = 0
0x2E 2 0 SecurityId = 0
0x30 4 20000000 File Attributes = 0x20 = FILE_ATTRIBUTE_ARCHIVE
0x34 2 4800 (оранжевый цвет) FileName Length = 0x48 (72 байт, 36 символов UTF-16)
0x36 2 3C00 (черный цвет) FileName Offset = 0x3C (60 байт от начала записи)
0x3C 72 350061006300370036003100640064003700650030003500640066003000320065006500660030006600300064003700350036003200660034003500630032002E0070006E006700 File Name в UTF-16LE: 5ac761dd7e05df02eef0f0d7562f45c2.png
0x84 4 00000000 Padding (выравнивание)

Немного пояснений:
·       7D8A030000000500 (зеленый цвет): little-endian — читаем как 0x00038A7D; для MFT Entry переводим в DEC, получаем 232061.
·       3CC9020000003500 (желтый цвет): little-endian — читаем как 0x0002C93C; для Parent MFT Entry переводим в DEC, получаем 182588; 35 в DEC = 53.
·       Для лучшего понимания контекста см. рисунок 1.
Из описания следует, что USN_REASON_RENAME_OLD_NAME=0x00001000, в файле 0x00100000 (синий цвет) little-endian — читаем как 0x00001000.
Перейдем к файлу 5ac761dd7e05df02eef0f0d7562f45c21.png. Заголовок — USN_RECORD_V2 (первые 56 байт).
Смещение Размер Значение (hex) Описание
0x00 4 88000000 (коричневый цвет) Record Length = 0x88 (136 байт)
0x04 2 200 (серый цвет) Major Version = 2
0x06 2 0 (серый цвет) Minor Version = 0
0x08 8 7D8A030000000500 (зеленый цвет) File Reference Number = 0x00038A7D (MFT entry) + 0x0500 (sequence)
0x10 8 3CC9020000003500 (желтый цвет) Parent File Reference Number = 0x0002C93C (parent MFT) + 0x3500 (sequence)
0x18 8 10A1C41B00000000 (голубой цвет) USN = 0x000000001BC4A110
0x20 8 81C8FD363C4BDC01 (красный цвет) Timestamp = Windows FileTime
0x28 4 00200000 (синий цвет) Reason = 0x2000 = USN_REASON_RENAME_NEW_NAME
0x2C 2 0 Source Info = 0
0x2E 2 0 SecurityId = 0
0x30 4 20000000 File Attributes = 0x20 = FILE_ATTRIBUTE_ARCHIVE
0x34 2 4A00 (оранжевый цвет) FileName Length = 0x4A (74 байт, 37 символов UTF-16)
0x36 2 3C00 (черный цвет) FileName Offset = 0x3C (60 байт от начала записи)
0x3C 74 3500610063003700360031006400640037006500300035006400660030003200650065006600300066003000640037003500360032006600340035006300320031002E0070006E006700 5ac761dd7e05df02eef0f0d7562f45c21.png
0x86 2 0000 Padding (выравнивание)

Итак, когда мы разобрались со структурой, выделим основные паттерны для поиска. Нужно учитывать, что в незанятой области данные могут быть фрагментами — нам нужно максимально сузить поиск, но при этом точно понимать, к какому имени файла относится запись, время события, Reason, MFT Entry и Parent Entry. Искать мы будем только записи со следующими Reason: RENAME_NEW_NAME и RENAME_OLD_NAME. Ниже представлен код для поиска USN-записей с Reason RENAME_NEW_NAME и RENAME_OLD_NAME в файле незанятого пространства. Выходные результаты: JSON-файл, содержащий детальную техническую информацию, и CSV-файл с менее подробными данными, но имеющий все необходимые поля для дальнейшего анализа.

код для поиска USN-записей

import struct
import json
import csv
from datetime import datetime, timedelta
import os
def windows_filetime_to_datetime(filetime_bytes):
"""Конвертирует 8 байт Windows FileTime в datetime"""
try:
value = struct.unpack('<Q', filetime_bytes)[0]
if value == 0:
return None
epoch = datetime(1601, 1, 1)
microseconds = value // 10
return epoch + timedelta(microseconds=microseconds)
except:
return None
def is_relevant_date(dt):
"""
Проверяет, является ли дата релевантной
Обычно это даты с 2010 по 2030 год
"""
if not dt:
return False
return 2010 <= dt.year <= 2030
def has_invalid_filename_chars(filename):
"""Проверяет наличие запрещенных символов в имени файла"""
if not filename:
return True
forbidden_chars = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']
return any(char in filename for char in forbidden_chars)
def is_valid_filename_length(bytes_to_read):
"""Проверяет что длина имени файла не превышает 0x1FE (510 байт) учитывая кодировку UTF-16 LE 00 разделитель"""
return bytes_to_read <= 0x1FE
def is_printable_filename(filename):
"""Проверяет, что имя файла состоит из печатных символов"""
if not filename:
return False
# Проверяем на бинарные данные (много нулевых символов или управляющих)
if any(ord(c) < 32 and c not in '\t\n\r' for c in filename):
return False
# Проверяем, что есть хотя бы один печатный символ
if not any(c.isprintable() for c in filename):
return False
return True
def get_mft_entry_from_position(data, local_position, absolute_position):
"""
Получает MFT Entry Number отступив от даты на 24 байта вверх
"""
# Отступаем на 24 байта вверх от позиции даты (локально в чанке)
mft_local_position = local_position - 24
# Проверяем что позиция валидная в текущем чанке
if mft_local_position < 0 or mft_local_position + 7 >= len(data):
return None
try:
# Читаем 4 байта начиная с этой позиции как little-endian dword
mft_entry_bytes = data[mft_local_position:mft_local_position+4]
mft_entry = struct.unpack('<I', mft_entry_bytes)[0]
# Проверяем что MFT Entry в разумном диапазоне
if 0 <= mft_entry <= 100000000:
return {
'position': absolute_position - 24, # АБСОЛЮТНАЯ позиция в файле
'mft_entry_bytes': mft_entry_bytes.hex().upper(),
'mft_entry': mft_entry
}
except:
pass
return None
def get_parent_entry_from_position(data, local_position, absolute_position):
"""
Получает Parent Entry отступив от даты на 16 байта вверх
"""
# Отступаем на 16 байта вверх от позиции даты (локально в чанке)
parent_local_position = local_position - 16
# Проверяем что позиция валидная в текущем чанке
if parent_local_position < 0 or parent_local_position + 7 >= len(data):
return None
try:
# Читаем 4 байта начиная с этой позиции как little-endian dword
parent_entry_bytes = data[parent_local_position:parent_local_position+4]
parent_entry = struct.unpack('<I', parent_entry_bytes)[0]
# Проверяем что Parent Entry в разумном диапазоне
if 0 <= parent_entry <= 100000000:
return {
'position': absolute_position - 16, # АБСОЛЮТНАЯ позиция в файле
'parent_entry_bytes': parent_entry_bytes.hex().upper(),
'parent_entry': parent_entry
}
except:
pass
return None
def read_utf16_string(data, local_position, absolute_position):
"""
Читает строку в кодировке UTF-16 LE:
1. От даты вперед 24 байта, читаем word (2 байта) - количество байтов для чтения
2. От даты вперед 28 байт, читаем указанное количество байтов
3. Конвертируем в UTF-16 LE строку
"""
try:
# 1. От даты вперед 24 байта (локально в чанке), читаем word (2 байта)
length_local_position = local_position + 24
if length_local_position + 2 > len(data):
return None
length_bytes = data[length_local_position:length_local_position+2]
bytes_to_read = struct.unpack('<H', length_bytes)[0] # количество байтов для чтения
# ПРОВЕРКА ДЛИНЫ: если больше 0x1FE (510 байт) - пропускаем
if not is_valid_filename_length(bytes_to_read):
return None
# 2. От даты вперед 28 байт (локально в чанке), читаем указанное количество байтов
string_local_position = local_position + 28
string_end_position = string_local_position + bytes_to_read
if string_end_position > len(data):
return None
string_bytes = data[string_local_position:string_end_position]
# 3. Декодируем из UTF-16 LE
string_value = string_bytes.decode('utf-16le', errors='ignore').rstrip('\x00')
# ПРОВЕРКА ЗАПРЕЩЕННЫХ СИМВОЛОВ
if has_invalid_filename_chars(string_value):
return None
# ПРОВЕРКА ЧИТАЕМОСТИ ИМЕНИ
if not is_printable_filename(string_value):
return None
return {
'length_position': absolute_position + 24, # АБСОЛЮТНАЯ позиция в файле
'length_bytes': length_bytes.hex().upper(),
'bytes_to_read': bytes_to_read,
'string_position': absolute_position + 28, # АБСОЛЮТНАЯ позиция в файле
'string_value': string_value
}
except Exception as e:
return None
def find_datetime_patterns_in_file(filename, chunk_size=1024*1024*100): # 100MB chunks
"""Ищет паттерны в файле любого размера с использованием чанков"""
results = []
with open(filename, 'rb') as f:
file_size = f.seek(0, 2) # Получаем размер файла
f.seek(0) # Возвращаемся в начало
print(f"Размер файла: {file_size} байт")
chunk_number = 0
position_offset = 0
while True:
# Читаем чанк с перекрытием для поиска паттернов на границах
overlap = 32 # достаточный overlap для поиска паттернов
read_size = chunk_size + overlap
if position_offset > 0:
f.seek(position_offset - overlap)
else:
f.seek(0)
data = f.read(read_size)
if not data:
break
actual_chunk_size = min(chunk_size, len(data))
print(f"Обработка чанка {chunk_number + 1} ({len(data)} байт)...")
# Ищем паттерны в текущем чанке
i = 0
while i <= len(data) - 16 - (overlap if position_offset + i >= chunk_size else 0):
date_bytes = data[i:i+8]
# проверяем Reason code в little endian
if (i + 15 < len(data) and
data[i+8] == 0x00 and
data[i+9] in (0x10, 0x20) and # Младшие байты Reason code
all(b == 0x00 for b in data[i+10:i+12])): # Старшие байты Reason code (должны быть 00)
# Дополнительная проверка: читаем полный Reason code как little endian
reason_bytes = data[i+8:i+12]
reason = struct.unpack('<I', reason_bytes)[0]
# ФИЛЬТР: ТОЛЬКО RENAME_OLD_NAME и RENAME_NEW_NAME
if reason not in (0x00001000, 0x00002000):
i += 1
continue
dt = windows_filetime_to_datetime(date_bytes)
if dt and is_relevant_date(dt):
pattern_type = "RENAME_OLD_NAME" if reason == 0x00001000 else "RENAME_NEW_NAME"
absolute_position = position_offset + i
# ПЕРЕДАЕМ АБСОЛЮТНЫЕ ПОЗИЦИИ В ФУНКЦИИ
mft_info = get_mft_entry_from_position(data, i, absolute_position)
parent_info = get_parent_entry_from_position(data, i, absolute_position)
string_info = read_utf16_string(data, i, absolute_position)
# ДОБАВЛЯЕМ ТОЛЬКО ЕСЛИ ЕСТЬ ВАЛИДНОЕ ИМЯ ФАЙЛА
if string_info and string_info['bytes_to_read'] > 0:
results.append({
'position': absolute_position,
'date_hex': ''.join(f'{b:02X}' for b in date_bytes),
'datetime': dt,
'pattern_type': pattern_type,
'reason_code': f'{reason:08X}',
'full_pattern': data[i:i+16].hex().upper(),
'mft_info': mft_info,
'parent_info': parent_info,
'string_info': string_info
})
# ЛОГИКА ПРОПУСКА
if string_info and string_info['bytes_to_read'] > 0:
# СЦЕНАРИЙ 1: Полный паттерн + есть валидное имя файла
skip_bytes = 28 + string_info['bytes_to_read']
# Проверяем границы данных
if i + skip_bytes <= len(data):
i += skip_bytes
else:
i += 16 # fallback
else:
# СЦЕНАРИЙ 2: Полный паттерн без имени файла или невалидное имя
i += 16
continue
# СЦЕНАРИЙ 3-4: Паттерн не найден ИЛИ найден частично
# Двигаемся по 1 байту для тщательного поиска
i += 1
# Показываем прогресс внутри чанка
if i % (1024*1024) == 0:
bytes_processed = position_offset + i
progress = (bytes_processed / file_size) * 100 if file_size > 0 else 0
print(f"Обработано {bytes_processed} байт ({progress:.1f}%)...")
# Переходим к следующему чанку
position_offset += chunk_size
chunk_number += 1
# Проверяем, не достигли ли конца файла
if position_offset >= file_size:
break
return results
def save_to_json(results, filename):
"""Сохраняет результаты в JSON файл"""
with open(filename, 'w', encoding='utf-8') as f:
json.dump(results, f, indent=2, ensure_ascii=False, default=str)
print(f"Результаты сохранены в {filename}")
def save_to_csv(results, filename):
"""Сохраняет результаты в CSV файл"""
with open(filename, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow(['MFT Entry', 'Parent Entry', 'File Name', 'Record Type', 'Reason Code', 'Windows Time'])
for result in results:
mft_entry = result['mft_info']['mft_entry'] if result['mft_info'] else 'N/A'
parent_entry = result['parent_info']['parent_entry'] if result['parent_info'] else 'N/A'
filename_str = result['string_info']['string_value'] if result['string_info'] else 'N/A'
writer.writerow([mft_entry, parent_entry, filename_str, result['pattern_type'], result['reason_code'], result['datetime']])
print(f"Результаты сохранены в {filename}")
# Основная программа
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
filename = sys.argv[1]
else:
filename = input("Введите путь к файлу: ")
try:
print(f"Чтение файла: {filename}")
print("Поиск паттернов: 8 байт даты + RENAME_OLD_NAME (0x1000) ИЛИ RENAME_NEW_NAME (0x2000)")
print("Фильтрация: только релевантные даты (2010-2030 годы)")
print("Проверки: запрещенные символы в именах, длина имени ≤ 510 байт, читаемые имена")
print("Режим: обработка файлов любого размера")
print("Оптимизация: пропуск области имени файла после найденного паттерна\n")
results = find_datetime_patterns_in_file(filename)
# Статистика по типам записей
old_name_count = len([r for r in results if r['pattern_type'] == 'RENAME_OLD_NAME'])
new_name_count = len([r for r in results if r['pattern_type'] == 'RENAME_NEW_NAME'])
print(f" Найдено {len(results)} валидных записей:")
print(f" RENAME_OLD_NAME: {old_name_count} записей")
print(f" RENAME_NEW_NAME: {new_name_count} записей")
# Сохраняем в JSON (ТОЛЬКО валидные результаты)
save_to_json(results, 'CarverUSNREC.json')
# Сохраняем в CSV (ТОЛЬКО валидные результаты)
save_to_csv(results, 'CarverUSNREC.csv')
print(" Обработка завершена")
except FileNotFoundError:
print(f" Файл {filename} не найден")
except Exception as e:
print(f" Ошибка: {e}")
После карвинга остается проанализировать полученный результат на следующие события:
·       Файл с одним именем имеет следующие Reason: RENAME_NEW_NAME и RENAME_OLD_NAME.
·       Время изменения Reason у этого файла не больше 15 сек.
·       В этом же временном промежутке в этой же папке (Parent Entry Number) и с таким же значением MFT Entry имеется файл с новым именем, но с тем же расширением и имеющий те же Reason: RENAME_NEW_NAME и RENAME_OLD_NAME.
Важно помнить: если вы не нашли данных, подтверждающих или опровергающих подмену содержимого, нужно сделать вывод: «установить точное время создания и изменения файла, а также доступ к файлу не представляется возможным».-Источник
 
Loading...
Error