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

от автора

Привет, Хабр! На связи 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 совпадают, то в полях для 0x30 ($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)

Описание

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 structimport jsonimport csvfrom datetime import datetime, timedeltaimport osdef 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 Nonedef is_relevant_date(dt):    """    Проверяет, является ли дата релевантной    Обычно это даты с 2010 по 2030 год    """    if not dt:        return False    return 2010 <= dt.year <= 2030def 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 <= 0x1FEdef 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 Truedef 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 Nonedef 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 Nonedef 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 Nonedef 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 resultsdef 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.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1034584/