Forense de metadados em PDF: onde a evidência realmente mora

Onde o formato guarda os seus recibos, o que cada estrutura é de fato especificada para significar, e qual evidência sobrevive a qual tipo de edição.

Esta página é sobre o formato do arquivo, não sobre um produto. Se você está construindo qualquer coisa que recebe PDFs que não foi você quem gerou, vale saber exatamente quais estruturas registram o histórico de um documento, como elas são especificadas e como cada uma falha. Tudo abaixo é legível com um editor hexadecimal e paciência.

Dois repositórios de metadados, e por que eles discordam

Um PDF pode se descrever em dois lugares inteiramente separados, e a maioria dos arquivos modernos usa os dois.

O dicionário de informações do documento

A chave /Info do trailer aponta para um dicionário com um vocabulário fixo e pequeno: /Title, /Author, /Subject, /Keywords, /Creator, /Producer, /CreationDate, /ModDate, /Trapped. A divisão entre os dois últimos campos de ferramenta é a parte útil e é lida errado com frequência: /Creator nomeia o aplicativo que criou o documento original — o editor de texto, o motor de relatórios — enquanto /Producer nomeia o que quer que o tenha convertido em PDF, ou o que escreveu os bytes do PDF mais recentemente. Um extrato gerado no servidor normalmente carrega a string de uma biblioteca em /Producer; o caso interessante é quando /Producer nomeia uma ferramenta cuja finalidade é editar.

Os valores são strings de texto PDF, o que significa PDFDocEncoding ou UTF-16BE introduzido por uma marca de ordem de bytes FEFF. Num arquivo sem fluxos de objetos o dicionário costuma estar ali, aberto, encontrável com grep. A ISO 32000-2 (PDF 2.0) deprecia o dicionário de informações em favor do XMP, mantendo as entradas de data — mas depreciação num formato tão longevo não diz quase nada sobre o que você vai de fato receber, e o dicionário Info continua praticamente universal no mundo real.

O pacote XMP

A chave /Metadata do catálogo do documento aponta para um fluxo de /Type /Metadata /Subtype /XML contendo um pacote XMP da Adobe: RDF/XML, embrulhado em instruções de processamento que começam com <?xpacket begin= e um id de pacote fixo e — por convenção e por recomendação da própria especificação — armazenado sem filtro, para que um software incapaz de interpretar PDF ainda consiga encontrá-lo e lê-lo. É por isso que o strings encontra o XMP e pouca coisa além dele.

Um pacote XMP resumido

<rdf:Description rdf:about=""
    xmlns:pdf="http://ns.adobe.com/pdf/1.3/"
    xmlns:xmp="http://ns.adobe.com/xap/1.0/"
    xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/">
  <pdf:Producer>iText 7.2.5</pdf:Producer>
  <xmp:CreatorTool>StatementRenderer</xmp:CreatorTool>
  <xmp:CreateDate>2026-01-04T10:02:00+01:00</xmp:CreateDate>
  <xmp:ModifyDate>2026-01-04T10:02:00+01:00</xmp:ModifyDate>
  <xmpMM:DocumentID>uuid:8f2c...</xmpMM:DocumentID>
  <xmpMM:InstanceID>uuid:41de...</xmpMM:InstanceID>
</rdf:Description>

Os pares que importam para o cruzamento são pdf:Producer/Producer, xmp:CreatorTool/Creator, xmp:CreateDate/CreationDate e xmp:ModifyDate/ModDate.

Duas propriedades do XMP merecem atenção por si sós. O xmpMM:DocumentID deve permanecer estável ao longo da vida de um documento, enquanto o xmpMM:InstanceID muda a cada instância salva — conceitualmente o mesmo desenho do par /ID do trailer, um nível acima. E o xmpMM:History, quando presente, é uma lista ordenada de entradas stEvt: registrando ações, agentes de software e carimbos de data. É opcional, é escrito por relativamente poucas ferramentas e é trivialmente removível — mas quando um arquivo o carrega, é o mais perto que o PDF tem de um log de edição, e ler não custa nada.

Por que os dois repositórios divergem

Não existe mecanismo no formato que os mantenha sincronizados. Se eles concordam é puramente uma propriedade das ferramentas que tocaram o arquivo. Um único gerador escrevendo uma vez preenche os dois de forma consistente. Depois disso:

  • Alguns editores reescrevem o dicionário Info e deixam o pacote XMP intacto, porque reescrever XML dá mais trabalho que definir um valor de dicionário.
  • Outros fazem o contrário, atualizando o XMP como repositório moderno e deixando o Info desatualizado.
  • Esteiras de várias etapas — aplicativo de design, destilador, imposição, pós-processador — legitimamente deixam cada repositório refletindo uma etapa diferente.

Ou seja, divergência indica "mais de um escritor", não "um falsificador". A versão forte do achado é mais estreita: os dois repositórios discordam sobre a ferramenta e só um deles nomeia um editor de consumo. Aí a divergência é o único motivo de aquela ferramenta estar visível, o que é uma observação significativamente diferente de duas bibliotecas de servidor discordando sobre uma string de versão.

Qualquer comparação que você construir precisa de tolerâncias, ou vai afogar você. Normalize as strings de ferramenta e trate uma como concordando com a outra quando qualquer uma for substring da outra, para que iText 7.2.5 e iText casem. Dê aos carimbos de data um minuto de folga, porque os dois repositórios arredondam com precisões diferentes.

A sintaxe de datas do PDF, e os quatro jeitos de dar errado

As datas do dicionário Info são strings num formato criado pela própria especificação:

D:YYYYMMDDHHmmSSOHH'mm'
D:20260104100200+01'00'     forma do PDF 1.7, com apóstrofo final
D:20260104100200+01'00      também aparece; a ISO 32000-2 tirou a aspa final
D:20260104100200Z           UTC
D:20260104                  legal — tudo depois do ano pode ser truncado

Tudo depois do ano é opcional, então D:2026 é uma data PDF válida. O indicador de fuso O é +, - ou Z. As datas do XMP, em contraste, são ISO 8601. Qualquer cruzamento precisa normalizar os dois em instantes antes de comparar, e precisa lidar com uma ausência legítima de informação de fuso em qualquer um dos lados.

Quatro modos de falha merecem teste:

  • Modificação antes da criação. Compare dentro de um único repositório, não entre os dois — divergência entre repositórios é uma questão de metadados, não de cronologia. Dentro de um repositório, um gerador funcionando corretamente não consegue produzir isso.
  • Carimbos no futuro. Use uma tolerância de um dia, não de um segundo. Deriva de relógio e bugs de fuso horário são reais e chatos.
  • Deslocamentos UTC impossíveis. Nenhum fuso real passa de ±14:00. Um deslocamento fora dessa faixa foi digitado, não calculado.
  • Strings ilegíveis. Comuns o bastante em produtores legados e de nicho para merecerem peso próprio, mais baixo. Trate "este gerador é desleixado" separadamente de "estes carimbos são inconsistentes".

Uma assimetria a internalizar: a presença de uma data ruim é evidência; a ausência de datas não é. Remover metadados é uma linha de código em qualquer biblioteca de PDF.

O par /ID do trailer

O /ID do trailer é um vetor de exatamente duas strings de bytes, convencionalmente de 16 bytes cada e convencionalmente exibidas em hexadecimal. A especificação lhes dá papéis distintos e úteis: o primeiro elemento é um identificador permanente estabelecido quando o documento é criado e não deve mudar durante a vida daquele documento; o segundo é atualizado sempre que o arquivo é escrito de novo. É, em outras palavras, um marcador de alteração que o formato mantém para você.

trailer
<< /Size 41 /Root 1 0 R /Info 9 0 R
   /ID [<8f2c9a1b...> <41de77c0...>] >>

As implementações normalmente derivam o valor de um hash da hora atual, do caminho do arquivo, do tamanho do arquivo e do conteúdo do dicionário Info, e é por isso que os dois elementos costumam ter o comprimento de um MD5. Essa derivação é uma recomendação, não uma exigência, e nada impede uma ferramenta de escrever o que bem entender.

Três observações práticas:

  • O /ID é obrigatório quando o trailer tem uma entrada /Encrypt, e para o manipulador de segurança padrão nas revisões mais antigas o primeiro elemento participa da derivação da chave de criptografia. Então arquivos cifrados essencialmente sempre têm um.
  • Alguns geradores minimalistas omitem o /ID por completo. Isso remove a verificação; não indica nada sobre o documento.
  • Uma ferramenta que reescreve o arquivo inteiro pode definir os dois elementos com o mesmo valor novo, apagando a evidência completamente. Um par idêntico, portanto, não prova nada. O sinal é unidirecional: divergência é informativa, identidade não é.

Quando um arquivo tem várias revisões ele tem vários trailers, e cada um carrega o seu próprio /ID. Coletar todos mostra o segundo elemento evoluindo enquanto o primeiro fica fixo — uma pequena e satisfatória confirmação de que você está lendo a cadeia de revisões corretamente.

Atualizações incrementais e a cadeia /Prev

Esta é a estrutura que torna a forense de PDF possível, e é um recurso deliberado, não um descuido. Em vez de reescrever um arquivo, um escritor conforme pode anexar: objetos novos e substitutos, depois uma nova seção de referências cruzadas cobrindo apenas o que mudou, depois um novo trailer, depois startxref com o deslocamento dessa nova seção, depois %%EOF. Os bytes originais ficam intocados. Um documento salvo três vezes pode conter fisicamente os três estados.

O trailer de cada seção anexada carrega /Prev, o deslocamento em bytes da seção de referências cruzadas anterior. Essa é a cadeia. Você entra nela pelo último startxref do arquivo e caminha para trás até um trailer sem /Prev; essa seção terminal é o documento original.

%PDF-1.7
… corpo original …
xref
0 41
0000000000 65535 f
0000000015 00000 n
…
trailer
<< /Size 41 /Root 1 0 R /Info 9 0 R /ID [<8f2c…> <8f2c…>] >>
startxref
118201
%%EOF                       <-- fim da revisão 1
… objetos anexados: 12 0 obj (fluxo de conteúdo substituto) …
xref
0 1
0000000000 65535 f
12 1
0000119004 00000 n
trailer
<< /Size 41 /Root 1 0 R /Info 9 0 R
   /Prev 118201 /ID [<8f2c…> <41de…>] >>
startxref
183640
%%EOF                       <-- fim da revisão 2

Leia essa seção anexada com atenção: ela lista o objeto 12 e nada mais. O objeto 12 já existia na revisão 1. A nova entrada vence, porque a resolução sempre usa a seção mais nova que menciona um número de objeto. Se o objeto 12 for o fluxo de conteúdo de uma página, então o que o documento exibe mudou entre a revisão 1 e a revisão 2 — e a revisão 1 continua no arquivo, então você pode renderizar as duas.

Essa distinção é o jogo inteiro. "O arquivo tem duas revisões" é fraco: assinar um documento é uma atualização incremental, preencher um campo de formulário também, acrescentar uma anotação também, aplicar um comentário também. "Uma revisão posterior substitui um objeto que já existia e que carrega dados de página, de fluxo de conteúdo ou de imagem" é forte, porque significa que o documento visível mudou depois da geração.

Tabelas de referência cruzada clássicas

Uma seção xref clássica é composta de cabeçalhos de subseção (primeiro número de objeto, quantidade) seguidos de entradas de largura fixa de 20 bytes: um deslocamento de 10 dígitos, um número de geração de 5 dígitos, um caractere de tipo n (em uso) ou f (livre), e um fim de linha de dois caracteres. A largura fixa é o motivo de as tabelas serem trivialmente legíveis por máquina e de PDFs editados à mão as quebrarem com tanta frequência — insira um byte em qualquer ponto anterior do arquivo e todo deslocamento depois dele está errado.

As entradas livres formam uma lista ligada cuja cabeça é o objeto 0 com geração 65535. Números de geração existem para que um número de objeto liberado possa ser reutilizado com segurança, e na prática quase nada os incrementa: uma geração diferente de zero é incomum o suficiente para merecer atenção.

Contando revisões com honestidade

Contar marcadores %%EOF é o método popular e é um piso razoável, mas não é a contagem de revisões. Ele discorda da realidade nas duas direções:

  • Conta a mais em arquivos linearizados. Um PDF linearizado ("fast web view") legitimamente coloca uma seção de referências cruzadas da primeira página perto do início do arquivo, com o seu próprio trailer e %%EOF, cujo /Prev aponta para a seção principal no fim. Um salvamento, dois marcadores.
  • Conta a mais em arquivos com lixo no fim — uma sequência de bytes parecida com %%EOF dentro de um fluxo, ou conteúdo anexado por uma transferência quebrada.
  • Conta a menos quando falta um marcador, o que produtores fora da norma conseguem fazer com regularidade.
  • Não diz nada sobre o que mudou, que é a única parte com peso.

O método defensável é percorrer a cadeia /Prev e contar seções, mantendo os deslocamentos de %%EOF como corroboração e como alternativa quando a cadeia está quebrada. Proteja a caminhada: um laço em /Prev é uma coisa real de se encontrar num arquivo hostil, então registre os deslocamentos visitados e limite o comprimento da cadeia.

Fluxos de referência cruzada, fluxos de objetos e arquivos híbridos

O PDF 1.5 introduziu dois recursos que juntos enfraquecem bastante a forense ingênua baseada em texto.

Fluxos de referência cruzada substituem a tabela em texto puro por um objeto de fluxo binário comprimido. O vetor /W dá as larguras de campo de cada entrada e o /Index dá as subseções. O tipo de entrada 0 é livre, o tipo 1 é um objeto normal com deslocamento em bytes e geração, e o tipo 2 é um objeto que vive dentro de um fluxo de objetos, identificado pelo número do objeto do fluxo que o contém mais um índice dentro dele. Chaves de trailer como /Root, /Info, /ID e /Prev migram para o dicionário do próprio fluxo — então um arquivo pode ter uma cadeia de revisões completa e nenhuma palavra-chave trailer literal em lugar algum.

Fluxos de objetos (/Type /ObjStm) empacotam muitos objetos não-fluxo dentro de um único fluxo comprimido com Flate. É por isso que o strings num PDF moderno costuma não mostrar quase nada: o dicionário Info, a árvore de páginas e os dicionários de fonte estão todos dentro de contêineres comprimidos. Qualquer parser forense de verdade tem de descomprimi-los.

Arquivos de referência híbrida são a sutileza que vale conhecer. Para continuarem legíveis por software anterior ao 1.5, um arquivo pode carregar uma tabela clássica e um fluxo de referência cruzada companheiro, apontado pelo /XRefStm do trailer clássico. Leitores antigos veem uma visão do arquivo; leitores novos veem outra. As duas visões podem legitimamente diferir em cobertura — e podem ser feitas para diferir em substância, o que é uma técnica documentada para construir um PDF que mostra conteúdos diferentes a leitores diferentes. Qualquer parser que pretenda ser minucioso tem de ler as duas e fundi-las, em vez de escolher uma.

Prefixos de subconjunto de fonte

Quando um produtor incorpora apenas os glifos que um documento de fato usa, a convenção — especificada, não folclore — é prefixar o nome da fonte com seis letras ASCII maiúsculas e um sinal de mais:

/BaseFont /ABCDEF+Helvetica
/FontDescriptor 22 0 R
  /FontName /ABCDEF+Helvetica

As seis letras são arbitrárias; a especificação pede apenas que sejam escolhidas de modo que subconjuntos diferentes dificilmente colidam. Para fontes simples, o /FontName do descritor precisa bater com o /BaseFont, então você tem dois lugares para ler a mesma etiqueta.

O valor forense vem de uma propriedade dos geradores, não do formato: uma ferramenta escrevendo um documento numa passada reúne todos os glifos de que precisa para uma tipografia em um subconjunto com uma etiqueta. Duas etiquetas diferentes para a mesma fonte base significam que glifos daquela tipografia foram incorporados em duas ocasiões separadas — o rastro deixado quando texto é acrescentado a, ou substituído em, um PDF existente por uma ferramenta diferente.

A sua força real é a durabilidade. Etiquetas de subconjunto vivem nos recursos da página, não nos metadados, então sobrevivem à remoção de metadados, e sobrevivem a uma reescrita do arquivo inteiro que achata a cadeia de revisões. A sua fraqueza é que a montagem de documentos produz o mesmo padrão de forma inocente: junte dois PDFs que usem Helvetica e você fica com dois subconjuntos. Leia junto com o histórico de revisões, nunca sozinho.

O /ByteRange da assinatura

O /Contents de um dicionário de assinatura guarda o blob PKCS#7 como uma string hexadecimal, e o /ByteRange é um vetor de pares deslocamento/comprimento nomeando os bytes que foram usados no hash. O arranjo padrão são dois pares que cobrem tudo exceto o buraco onde o próprio /Contents fica — a assinatura não consegue assinar a si mesma.

/ByteRange [0 8420 42840 15680]
             ^ ^    ^     ^
             | |    |     +-- comprimento do segundo trecho coberto
             | |    +-------- início do segundo trecho (logo após /Contents)
             | +------------- comprimento do primeiro trecho coberto
             +--------------- início do primeiro trecho

coberto até o byte    42840 + 15680 = 58520
tamanho do arquivo                  = 61204
                                      -------
bytes fora da assinatura            =  2684

Se o último byte coberto não é o último byte do arquivo, a diferença foi anexada depois da assinatura e não é protegida por ela. Isso é aritmética com dois números inteiros. Sem certificado, sem repositório de confiança, sem criptografia — e vale independentemente de a matemática da assinatura validar.

Mantenha as duas perguntas separadas, porque os leitores costumam misturá-las:

  • A assinatura confere? Exige o certificado, um repositório de confiança, verificação de revogação e uma política sobre o que conta como confiável.
  • Existe algo fora do intervalo assinado? Exige o arquivo.

Um leitor pode legitimamente reportar uma assinatura válida enquanto exibe conteúdo que a assinatura nunca cobriu. É isso que "assinado, com alterações posteriores" significa na interface, e é por isso que a cobertura merece ser reportada como fato próprio.

A ressalva honesta: bytes fora do intervalo não são automaticamente ilegítimos. Fluxos com múltiplas assinaturas os produzem por construção, e o PDF tem um mecanismo inteiro — permissões DocMDP, expressas por uma referência de assinatura com /TransformMethod /DocMDP e um nível /P — para declarar quais alterações posteriores o signatário permitiu. Reportar uma lacuna de cobertura não é o mesmo que avaliar se a alteração era permitida.

O que sobrevive a quê

O modelo mental mais útil ao triar um arquivo: edições diferentes destroem evidências diferentes. Nada aqui é garantia — o comportamento varia por ferramenta — mas o formato geral se mantém.

Evidência Metadados removidos Arquivo reescrito por inteiro Achatado em imagem
Strings Producer / Creator somem substituídas substituídas
Divergência Info × XMP some normalmente some some
Anomalias de data somem zeradas zeradas
Cadeia de revisões (/Prev) sobrevive some some
Divergência do /ID do trailer sobrevive pode ser apagada pode ser apagada
Subconjuntos de fonte duplicados sobrevivem muitas vezes sobrevivem somem (sem texto)
Imagem de página inteira + texto visível sobrevive sobrevive vira o sinal
Lacuna de cobertura da assinatura sobrevive assinatura destruída assinatura destruída

Leia as colunas, não as linhas. Limpar apenas os metadados é a evasão mais barata e deixa todo sinal estrutural intacto. Reescrever o arquivo inteiro é mais eficaz, mas destrói qualquer assinatura e tende a deixar um conjunto de metadados novo, coerente e conspicuamente recente. Achatar em imagem derrota por completo a análise em nível de texto e cria um documento cujo próprio formato — uma imagem de página inteira onde um extrato gerado por máquina deveria ter texto vetorial — é o achado.

A lição geral: nenhum sinal isolado é robusto, e as evasões são mutuamente exclusivas o bastante para que um conjunto de sinais independentes seja bem mais difícil de derrotar do que qualquer um deles. É também por isso que o enquadramento honesto é sinais, não detecção.

Lendo por conta própria

Dá para ir longe só com um terminal. Comece pelo fim do arquivo e pelos marcadores:

tail -c 500 extrato.pdf                     # trailer, startxref, %%EOF
grep -c '%%EOF' extrato.pdf                 # um piso para a contagem de revisões
grep -abo 'startxref' extrato.pdf           # o ponto de entrada de cada revisão
strings extrato.pdf | grep -A40 'x:xmpmeta' # o pacote XMP, geralmente sem compressão
strings extrato.pdf | grep -o '/BaseFont *[^ /]*' | sort -u

Além disso você precisa de um parser de verdade, e a escolha da ferramenta importa mais do que parece:

  • qpdf é o honesto. qpdf --qdf --object-streams=disable reescreve um arquivo numa forma normal, sem compressão e legível por humanos, que é a coisa mais útil que você pode fazer com um PDF que quer entender. O --show-xref despeja a tabela de referências cruzadas resolvida. Repare no que ele não preserva: a saída é um arquivo novo com uma revisão. Analise o original e use a cópia normalizada para leitura.
  • mutool (mutool show arquivo.pdf trailer, … grep) é excelente para cutucar objetos e dicionários individuais.
  • pikepdf (Python, sobre o qpdf) é a opção agradável para scripts e, ao contrário da maioria das bibliotecas de alto nível, deixa você chegar ao /Prev e ao trailer bruto.
  • exiftool lê os dois repositórios de metadados e mostra Info e XMP lado a lado, que é exatamente a comparação que você quer.

A armadilha do carregar-e-salvar. A maioria das bibliotecas de PDF de alto nível — aquelas que você usaria para criar um PDF — normaliza na leitura. Carregue um arquivo, escreva-o de volta e você tipicamente obtém: uma revisão, uma tabela de referências cruzadas reconstruída, um par /ID novo, fontes re-subconjuntadas e metadados novos. Todo sinal estrutural é destruído pelo próprio ato de examinar o arquivo. Se você está construindo qualquer coisa forense, interprete os bytes; não os faça dar uma volta completa.

Um aviso relacionado sobre manuseio de evidência: nunca analise a cópia que uma etapa de conversão, um gateway de e-mail, um antivírus ou uma camada de armazenamento com processamento no servidor tocou. Gere o hash do original ao recebê-lo e analise o original.

Ou deixe outra coisa fazer a caminhada

A Tamperlens implementa tudo o que está nesta página como um parser de bytes brutos — nenhuma biblioteca de PDF de alto nível no caminho forense, porque essas bibliotecas normalizam exatamente a evidência descrita aqui. Onze famílias de sinais, achados em linguagem clara com evidência legível por máquina, saída determinística. O verificador gratuito roda isso no seu navegador contra o seu próprio arquivo, sem armazenar nada; a API é um POST, com 25 documentos por mês de graça.

A Tamperlens reporta sinais de risco, não veredictos de autenticidade. Sinais podem ter causas benignas; combine-os com a sua própria lógica de decisão.

Leitura relacionada