Привет, Хабр! На связи uFactor. В одной из предыдущих статей мы рассказывали о туннелировании файловой системы NTFS и затронули тему карвинга. В этой статье — на примере из предыдущей — разберем, как можно осуществить поиск удаленных записей USN‑журнала в незанятой области.

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

Теперь посмотрим записи USN‑журнала для этого события.

Рисунок 1. Фрагмент USN-записей, связанных с туннелем
Рисунок 1. Фрагмент USN‑записей, связанных с туннелем

На рисунке 1 можно увидеть, что время событий переименования меньше секунды. Файл переименовывается в 5ac761dd7e05df02eef0f0d7562f45c21.png и обратно. Обратите внимание на следующее:

  • зеленым цветом выделено MFT Entry;

  • желтым — Sequence Number;

  • красным цветом — Parent Entry Number и Parent Sequence Number.

Теперь посмотрим на файл 5ac761dd7e05df02eef0f0d7562f45c2.png в $MFT.

Рисунок 2. Файл «5ac761dd7e05df02eef0f0d7562f45c2.png» после подмены содержимого
Рисунок 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 совпадают, то в полях для 0×30 ($FN) значения будут пусты.

Рисунок 3. Файл «5ac761dd7e05df02eef0f0d7562f45c2.png» и временные метки
Рисунок 3. Файл «5ac761dd7e05df02eef0f0d7562f45c2.png» и временные метки

А теперь напомню, что в USN Journal (Change Journal) записи по умолчанию могут храниться неделями или месяцами для низко нагруженных систем и от нескольких часов до 2 дней для высоко нагруженных. И без этих записей вы можете увидеть картину, как на рисунке 3, и сделать неправильные выводы в отношении файла.

Наша задача — сделать все возможное для построения всестороннего и объективного анализа. Одним из кирпичиков будет карвинг незанятой области.

Получить неразмеченную область в виде файла можно с образа диска либо с его клона — или же непосредственно с самого носителя информации. Например, при помощи Autopsy (бесплатного программного обеспечения) или X‑Ways Forensics (платное).

Рисунок 4. Извлечение незанятой области в файл при помощи Autopsy
Рисунок 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‑записи

Попытаемся разобраться с записями, представленными на рисунке 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)

Описание

0×00

4

88 000 000 (коричневый цвет)

Record Length = 0×88 (136 байт)

0×04

2

200 (серый цвет)

Major Version = 2

0×06

2

0 (серый цвет)

Minor Version = 0

0×08

8

7D8A030000000500 (зеленый цвет)

File Reference Number = 0×00038A7D (MFT entry) + 0×0500 (sequence)

0×10

8

3CC9020000003500 (желтый цвет)

Parent File Reference Number = 0×0002C93C (parent MFT) + 0×3500 (sequence)

0×18

8

88A0C41B00000000 (голубой цвет)

USN = 0×000000001BC4A088

0×20

8

81C8FD363C4BDC01 (красный цвет)

Timestamp = Windows FileTime

0×28

4

00 100 000 (синий цвет)

Reason = 0×1000 = USN_REASON_RENAME_OLD_NAME

0×2C

2

0

Source Info = 0

0×2E

2

0

SecurityId = 0

0×30

4

20 000 000

File Attributes = 0×20 = FILE_ATTRIBUTE_ARCHIVE

0×34

2

4800 (оранжевый цвет)

FileName Length = 0×48 (72 байт, 36 символов UTF-16)

0×36

2

3C00 (черный цвет)

FileName Offset = 0×3C (60 байт от начала записи)

0×3C

72

350061006300370036003100640064003700650030003500640066003000320065006500660030006600300064003700350036003200660034003500630032002E0070006E006700

File Name в UTF-16LE: 5ac761dd7e05df02eef0f0d7562f45c2.png

0×84

4

00 000 000

Padding (выравнивание)

Немного пояснений:

  • 7D8A030000000500 (зеленый цвет): little‑endian — читаем как 0×00038A7D; для MFT Entry переводим в DEC, получаем 232061.

  • 3CC9020000003500 (желтый цвет): little‑endian — читаем как 0×0002C93C; для Parent MFT Entry переводим в DEC, получаем 182588; 35 в DEC = 53.

  • Для лучшего понимания контекста см. рисунок 1.

Из описания следует, что USN_REASON_RENAME_OLD_NAME=0x00001000, в файле 0×00100000 (синий цвет) little‑endian — читаем как 0×00001000.

Перейдем к файлу 5ac761dd7e05df02eef0f0d7562f45c21.png. Заголовок — USN_RECORD_V2 (первые 56 байт).

Смещение

Размер

Значение (hex)

Описание

0×00

4

88 000 000 (коричневый цвет)

Record Length = 0×88 (136 байт)

0×04

2

200 (серый цвет)

Major Version = 2

0×06

2

0 (серый цвет)

Minor Version = 0

0×08

8

7D8A030000000500 (зеленый цвет)

File Reference Number = 0×00038A7D (MFT entry) + 0×0500 (sequence)

0×10

8

3CC9020000003500 (желтый цвет)

Parent File Reference Number = 0×0002C93C (parent MFT) + 0×3500 (sequence)

0×18

8

10A1C41B00000000 (голубой цвет)

USN = 0×000000001BC4A110

0×20

8

81C8FD363C4BDC01 (красный цвет)

Timestamp = Windows FileTime

0×28

4

00 200 000 (синий цвет)

Reason = 0×2000 = USN_REASON_RENAME_NEW_NAME

0×2C

2

0

Source Info = 0

0×2E

2

0

SecurityId = 0

0×30

4

20 000 000

File Attributes = 0×20 = FILE_ATTRIBUTE_ARCHIVE

0×34

2

4A00 (оранжевый цвет)

FileName Length = 0×4A (74 байт, 37 символов UTF-16)

0×36

2

3C00 (черный цвет)

FileName Offset = 0×3C (60 байт от начала записи)

0×3C

74

3500610063003700360031006400640037006500300035006400660030003200650065006600300066003000640037003500360032006600340035006300320031002E0070006E006700

5ac761dd7e05df02eef0f0d7562f45c21.png

0×86

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.

Важно помнить: если вы не нашли данных, подтверждающих или опровергающих подмену содержимого, нужно сделать вывод: «установить точное время создания и изменения файла, а также доступ к файлу не представляется возможным».

Комментарии (0)