Túnel NTFS: Buscando Registros USN em Espaço Não Alocado

Túnel NTFS: Buscando Registros USN em Espaço Não Alocado

Este artigo explora a técnica de carver para recuperar registros USN (Update Sequence Number) excluídos do journal de alterações do NTFS em áreas não alocadas de um disco. Detalha a estrutura dos registros USN e como identificar eventos de renomeação de arquivos, mesmo após a exclusão das entradas originais do journal.

MundiX News·14 de maio de 2026·15 min de leitura·👁 2 views

Olá, Habr! Aqui é o uFactor.

Em um artigo anterior, discutimos o tunelamento do sistema de arquivos NTFS e abordamos o tema do carver. Neste artigo, usando o exemplo do material anterior, vamos detalhar como é possível realizar a busca por registros USN (Update Sequence Number) excluídos em áreas não alocadas. Vamos relembrar a seguinte situação do material passado: substituímos o conteúdo de um arquivo 5ac761dd7e05df02eef0f0d7562f45c2.png, gravando nele outra imagem e, ao mesmo tempo, preservando todos os carimbos de data/hora no $MFT. Utilizamos uma técnica não convencional em conjunto com o tunelamento. A operação para o túnel é a renomeação do arquivo: arquivonovo_arquivoarquivo. Também determinamos que os principais Reason para tais eventos seriam RENAME_NEW_NAME e RENAME_OLD_NAME.

Agora, vamos examinar os registros do journal USN para este evento. Na Figura 1, podemos ver que o tempo dos eventos de renomeação é inferior a um segundo. O arquivo é renomeado para 5ac761dd7e05df02eef0f0d7562f45c21.png e de volta. Observe o seguinte:

  • Em verde está destacado o MFT Entry.
  • Em amarelo, o Sequence Number.
  • Em vermelho, o Parent Entry Number e o Parent Sequence Number.

Agora, vamos olhar para o arquivo 5ac761dd7e05df02eef0f0d7562f45c2.png no $MFT. Compare as Figuras 1 e 2. Como podemos ver, o MFT Entry, Sequence Number, Parent Entry Number e Parent Sequence Number não mudaram após a substituição do conteúdo. E agora, na Figura 3, vamos observar os carimbos de data/hora que mantiveram seus valores (o tempo do evento de substituição de conteúdo é 2025-11-01 14:31:30). Lembro que o MFTECmd (ferramentas de Eric Zimmerman) exibe o resultado da seguinte forma: se os carimbos de data/hora $SI e $FN coincidirem, os campos para $FN (0x30) estarão vazios.

Agora, lembremos que no USN Journal (Change Journal), as entradas podem ser armazenadas por padrão por semanas ou meses em sistemas de baixa carga e de várias horas a 2 dias em sistemas de alta carga. E sem essas entradas, você pode ver a imagem como na Figura 3 e tirar conclusões incorretas sobre o arquivo. Nossa tarefa é fazer tudo o que for possível para construir uma análise abrangente e objetiva. Um dos blocos de construção será o carver da área não alocada.

É possível obter a área não alocada como um arquivo a partir de uma imagem de disco ou de seu clone – ou diretamente do próprio meio de armazenamento. Por exemplo, com a ajuda do Autopsy (software gratuito) ou do X-Ways Forensics (pago). Para o carver de registros USN, precisamos saber o que procurar neste espaço não alocado. Vamos analisar a estrutura em nosso exemplo com o conteúdo substituído do arquivo. Lembremos que o journal USN é estruturado em dois fluxos de dados alternativos (ADS) e um arquivo de backup. Todo o registro é armazenado sequencialmente em formato de inteiro sem sinal no arquivo $J($UsnJrnl:$J). Quando o tamanho do arquivo do journal USN excede um determinado valor, o journal começa a sobrescrever os dados antigos. Você pode verificar o tamanho do journal USN usando a ferramenta fsutil. É possível obter o arquivo $J a partir de uma imagem de disco adquirida (clone, etc.) usando o Autopsy ou ao coletar artefatos de um sistema em execução, por exemplo, usando a utilidade KAPE. Abrimos o arquivo $J em um editor hexadecimal e encontramos as entradas relacionadas aos arquivos 5ac761dd7e05df02eef0f0d7562f45c2.png e 5ac761dd7e05df02eef0f0d7562f45c21.png, a saber – RENAME_NEW_NAME e RENAME_OLD_NAME.

Vamos tentar entender as entradas apresentadas na Figura 5. Sobre a estrutura das entradas USN, bem como os códigos Reason, você pode ler na documentação da Microsoft: USN_RECORD_V2 structure (winioctl.h), USN_RECORD_V3 structure (winioctl.h), USN_RECORD_V4 structure (winioctl.h). Por padrão, você encontrará USN_RECORD_V2. Vamos analisar a Figura 5. Começaremos com o arquivo 5ac761dd7e05df02eef0f0d7562f45c2.png. O cabeçalho é USN_RECORD_V2 (os primeiros 56 bytes). Cabeçalhos do tipo V3 têm a mesma estrutura.

Um pouco de explicação:

  • 7D8A030000000500 (cor marrom): little-endian – lemos como 0x00038A7D; para MFT Entry, convertemos para DEC, obtemos 232061.
  • 3CC9020000003500 (cor amarela): little-endian – lemos como 0x0002C93C; para Parent MFT Entry, convertemos para DEC, obtemos 182588; 35 em DEC = 53.
  • Para um melhor entendimento do contexto, veja a Figura 1.

Da descrição, segue que USN_REASON_RENAME_OLD_NAME=0x00001000, no arquivo 00100000 (cor azul) little-endian – lemos como 0x00001000.

Vamos para o arquivo 5ac761dd7e05df02eef0f0d7562f45c21.png. O cabeçalho é USN_RECORD_V2 (os primeiros 56 bytes).

Assim, depois de entendermos a estrutura, vamos destacar os principais padrões para busca. É preciso levar em conta que na área não alocada os dados podem ser fragmentos – precisamos restringir a busca ao máximo, mas ao mesmo tempo entender exatamente a qual nome de arquivo a entrada pertence, o tempo do evento, o Reason, o MFT Entry e o Parent Entry. Buscaremos apenas entradas com os seguintes Reason: RENAME_NEW_NAME e RENAME_OLD_NAME.

Abaixo está o código para buscar registros USN com Reason RENAME_NEW_NAME e RENAME_OLD_NAME no arquivo de espaço não alocado. Os resultados de saída são: um arquivo JSON contendo informações técnicas detalhadas e um arquivo CSV com dados menos detalhados, mas com todos os campos necessários para análise posterior.

python
import struct
import json
import csv
from datetime import datetime, timedelta
import os

def windows_filetime_to_datetime(filetime_bytes):
    """Converte 8 bytes de Windows FileTime para 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):
    """
    Verifica se a data é relevante
    Geralmente são datas de 2010 a 2030
    """
    if not dt:
        return False
    return 2010 <= dt.year <= 2030

def has_invalid_filename_chars(filename):
    """Verifica a presença de caracteres proibidos no nome do arquivo"""
    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):
    """Verifica se o comprimento do nome do arquivo não excede 0x1FE (510 bytes), considerando a codificação UTF-16 LE 00 separador"""
    return bytes_to_read <= 0x1FE

def is_printable_filename(filename):
    """Verifica se o nome do arquivo consiste em caracteres imprimíveis"""
    if not filename:
        return False
    
    # Verifica dados binários (muitos caracteres nulos ou de controle)
    if any(ord(c) < 32 and c not in '\t\n\r' for c in filename):
        return False
        
    # Verifica se há pelo menos um caractere imprimível
    if not any(c.isprintable() for c in filename):
        return False
        
    return True

def get_mft_entry_from_position(data, local_position, absolute_position):
    """
    Obtém o MFT Entry Number recuando 24 bytes a partir da data para cima
    """
    # Recua 24 bytes a partir da posição da data (localmente no chunk)
    mft_local_position = local_position - 24
    
    # Verifica se a posição é válida no chunk atual
    if mft_local_position < 0 or mft_local_position + 7 >= len(data):
        return None
    
    try:
        # Lê 4 bytes a partir desta posição como dword little-endian
        mft_entry_bytes = data[mft_local_position:mft_local_position+4]
        mft_entry = struct.unpack('<I', mft_entry_bytes)[0]
        
        # Verifica se o MFT Entry está em um intervalo razoável
        if 0 <= mft_entry <= 100000000:
            return {
                'position': absolute_position - 24,  # Posição ABSOLUTA no arquivo
                '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):
    """
    Obtém o Parent Entry recuando 16 bytes a partir da data para cima
    """
    # Recua 16 bytes a partir da posição da data (localmente no chunk)
    parent_local_position = local_position - 16
    
    # Verifica se a posição é válida no chunk atual
    if parent_local_position < 0 or parent_local_position + 7 >= len(data):
        return None
    
    try:
        # Lê 4 bytes a partir desta posição como dword little-endian
        parent_entry_bytes = data[parent_local_position:parent_local_position+4]
        parent_entry = struct.unpack('<I', parent_entry_bytes)[0]
        
        # Verifica se o Parent Entry está em um intervalo razoável
        if 0 <= parent_entry <= 100000000:
            return {
                'position': absolute_position - 16,  # Posição ABSOLUTA no arquivo
                '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):
    """
    Lê uma string na codificação UTF-16 LE:
    1. A partir da data, 24 bytes adiante, lê um word (2 bytes) – número de bytes a ler
    2. A partir da data, 28 bytes adiante, lê o número de bytes especificado
    3. Converte para string UTF-16 LE
    """
    try:
        # 1. A partir da data, 24 bytes adiante (localmente no chunk), lê um word (2 bytes)
        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]  # número de bytes a ler
        
        # VERIFICAÇÃO DE COMPRIMENTO: se for maior que 0x1FE (510 bytes) – pula
        if not is_valid_filename_length(bytes_to_read):
            return None
        
        # 2. A partir da data, 28 bytes adiante (localmente no chunk), lê o número de bytes especificado
        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. Decodifica de UTF-16 LE
        string_value = string_bytes.decode('utf-16le', errors='ignore').rstrip('\x00')
        
        # VERIFICAÇÃO DE CARACTERES PROIBIDOS
        if has_invalid_filename_chars(string_value):
            return None
        
        # VERIFICAÇÃO DE LEGIBILIDADE DO NOME
        if not is_printable_filename(string_value):
            return None
        
        return {
            'length_position': absolute_position + 24,  # Posição ABSOLUTA no arquivo
            'length_bytes': length_bytes.hex().upper(),
            'bytes_to_read': bytes_to_read,
            'string_position': absolute_position + 28,  # Posição ABSOLUTA no arquivo
            'string_value': string_value
        }
        
    except Exception as e:
        return None

def find_datetime_patterns_in_file(filename, chunk_size=1024*1024*100):  # 100MB chunks
    """Busca padrões em um arquivo de qualquer tamanho usando chunks"""
    results = []
    
    with open(filename, 'rb') as f:
        file_size = f.seek(0, 2)  # Obtém o tamanho do arquivo
        f.seek(0)  # Volta para o início
        
        print(f"Tamanho do arquivo: {file_size} bytes")
        
        chunk_number = 0
        position_offset = 0
        
        while True:
            # Lê chunk com sobreposição para buscar padrões nas bordas
            overlap = 32  # overlap suficiente para buscar padrões
            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"Processando chunk {chunk_number + 1} ({len(data)} bytes)...")
            
            # Busca padrões no chunk atual
            i = 0
            while i <= len(data) - 16 - (overlap if position_offset + i >= chunk_size else 0):
                date_bytes = data[i:i+8]
                
                # verifica o código Reason em little endian
                if (i + 15 < len(data) and 
                    data[i+8] == 0x00 and 
                    data[i+9] in (0x10, 0x20) and  # Bytes menos significativos do código Reason
                    all(b == 0x00 for b in data[i+10:i+12])):  # Bytes mais significativos do código Reason (devem ser 00)
                    
                    # Verificação adicional: lê o código Reason completo como little endian
                    reason_bytes = data[i+8:i+12]
                    reason = struct.unpack('<I', reason_bytes)[0]
                    
                    # FILTRO: APENAS RENAME_OLD_NAME e 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
                        
                        # PASSA POSIÇÕES ABSOLUTAS PARA AS FUNÇÕES
                        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)
                        
                        # ADICIONA APENAS SE HOUVER NOME DE ARQUIVO VÁLIDO
                        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
                            })
                        
                        # LÓGICA DE PULO
                        if string_info and string_info['bytes_to_read'] > 0:
                            # CENÁRIO 1: Padrão completo + nome de arquivo válido existe
                            skip_bytes = 28 + string_info['bytes_to_read']
                            # Verifica os limites dos dados
                            if i + skip_bytes <= len(data):
                                i += skip_bytes
                            else:
                                i += 16  # fallback
                        else:
                            # CENÁRIO 2: Padrão completo sem nome de arquivo ou nome inválido
                            i += 16
                        continue
                
                # CENÁRIO 3-4: Padrão não encontrado OU encontrado parcialmente
                # Avança 1 byte para busca minuciosa
                i += 1
                
                # Mostra progresso dentro do chunk
                if i % (1024*1024) == 0:
                    bytes_processed = position_offset + i
                    progress = (bytes_processed / file_size) * 100 if file_size > 0 else 0
                    print(f"Processado {bytes_processed} bytes ({progress:.1f}%)...")
            
            # Avança para o próximo chunk
            position_offset += chunk_size
            chunk_number += 1
            
            # Verifica se o final do arquivo foi atingido
            if position_offset >= file_size:
                break
    
    return results

def save_to_json(results, filename):
    """Salva os resultados em um arquivo JSON"""
    with open(filename, 'w', encoding='utf-8') as f:
        json.dump(results, f, indent=2, ensure_ascii=False, default=str)
    print(f"Resultados salvos em {filename}")

def save_to_csv(results, filename):
    """Salva os resultados em um arquivo 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"Resultados salvos em {filename}")

# Programa principal
if __name__ == "__main__":
    import sys
    
    if len(sys.argv) > 1:
        filename = sys.argv[1]
    else:
        filename = input("Digite o caminho do arquivo: ")
    
    try:
        print(f"Lendo arquivo: {filename}")
        print("Buscando padrões: 8 bytes de data + RENAME_OLD_NAME (0x1000) OU RENAME_NEW_NAME (0x2000)")
        print("Filtragem: apenas datas relevantes (anos 2010-2030)")
        print("Verificações: caracteres proibidos em nomes, comprimento do nome ≤ 510 bytes, nomes legíveis")
        print("Modo: processamento de arquivos de qualquer tamanho")
        print("Otimização: pulo da área do nome do arquivo após o padrão encontrado\n")
        
        results = find_datetime_patterns_in_file(filename)
        
        # Estatísticas por tipo de registro
        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"   Encontrado {len(results)} registros válidos:")
        print(f"   RENAME_OLD_NAME: {old_name_count} registros")
        print(f"   RENAME_NEW_NAME: {new_name_count} registros")
        
        # Salva em JSON (APENAS resultados válidos)
        save_to_json(results, 'CarverUSNREC.json')
        
        # Salva em CSV (APENAS resultados válidos)
        save_to_csv(results, 'CarverUSNREC.csv')
        
        print(" Processamento concluído")
            
    except FileNotFoundError:
        print(f" Arquivo {filename} não encontrado")
    except Exception as e:
        print(f" Erro: {e}")

Após o carver, resta analisar o resultado obtido para os seguintes eventos:

  • Um arquivo com um nome tem os seguintes Reason: RENAME_NEW_NAME e RENAME_OLD_NAME.
  • O tempo de alteração do Reason para este arquivo não é superior a 15 segundos.
  • No mesmo intervalo de tempo, na mesma pasta (Parent Entry Number) e com o mesmo valor de MFT Entry, há um arquivo com um novo nome, mas com a mesma extensão e com os mesmos Reason: RENAME_NEW_NAME e RENAME_OLD_NAME.

É importante lembrar: se você não encontrou dados que confirmem ou refutem a substituição de conteúdo, você deve concluir: "não é possível determinar o tempo exato de criação e modificação do arquivo, bem como o acesso ao arquivo".

📤 Compartilhar & Baixar