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: arquivo → novo_arquivo → arquivo. 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 Numbere oParent 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.
pythonimport 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_NAMEeRENAME_OLD_NAME. - O tempo de alteração do
Reasonpara 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_NAMEeRENAME_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".
