|
Professor Seleznov
|
 Привет, Хабр! На связи uFactor. В одной из предыдущих статей мы рассказывали о туннелировании файловой системы NTFS и затронули тему карвинга. В этой статье — на примере из предыдущей — разберем, как можно осуществить поиск удаленных записей USN-журнала в незанятой области. Давайте вспомним следующую историю из прошлого материала: мы подменили содержимое файла 5ac761dd7e05df02eef0f0d7562f45c2.png, записав в него другое изображение и при этом сохранив все временные метки в $MFT. Использовали нестандартную технику совместно с туннелированием. Операция для туннеля — переименование файла: file → new_file → file. Также определились, что основными Reason для таких событий будут RENAME_NEW_NAME и RENAME_OLD_NAME. Теперь посмотрим записи USN-журнала для этого события.

Рисунок 1. Фрагмент USN-записей, связанных с туннелем На рисунке 1 можно увидеть, что время событий переименования меньше секунды. Файл переименовывается в 5ac761dd7e05df02eef0f0d7562f45c21.png и обратно. Обратите внимание на следующее: · зеленым цветом выделено MFT Entry; · желтым — Sequence Number; · красным цветом — Parent Entry Number и Parent Sequence Number. Теперь посмотрим на файл 5ac761dd7e05df02eef0f0d7562f45c2.png в $MFT.

Рисунок 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) значения будут пусты.

Рисунок 3. Файл «5ac761dd7e05df02eef0f0d7562f45c2.png» и временные метки А теперь напомню, что в USN Journal (Change Journal) записи по умолчанию могут храниться неделями или месяцами для низко нагруженных систем и от нескольких часов до 2 дней для высоко нагруженных. И без этих записей вы можете увидеть картину, как на рисунке 3, и сделать неправильные выводы в отношении файла. Наша задача — сделать все возможное для построения всестороннего и объективного анализа. Одним из кирпичиков будет карвинг незанятой области. Получить неразмеченную область в виде файла можно с образа диска либо с его клона — или же непосредственно с самого носителя информации. Например, при помощи Autopsy (бесплатного программного обеспечения) или X-Ways Forensics (платное).

Рисунок 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.

Рисунок 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. Важно помнить: если вы не нашли данных, подтверждающих или опровергающих подмену содержимого, нужно сделать вывод: «установить точное время создания и изменения файла, а также доступ к файлу не представляется возможным».-Источник
|