A PDF Library for Delphi encontrou cinco defeitos nos descodificadores ao levar o seu código CCITT, TIFF, PNG, Flate e de buffers de stream até ao Free Pascal, e todos eles passavam a suite completa de testes do Delphi há anos. Nenhum era um bug do compilador. Cada um era Pascal que o Delphi executava corretamente por acidente, por causa de um detalhe de implementação: um parâmetro de resultado oculto que fazia alias do array do chamador, um ramo fora de intervalo por onde ninguém nunca passou, um buffer de comprimento zero cuja única proteção era um interruptor de verificação de intervalos, um offset baseado em 1 que só um caminho de código passava como 1, e um contrato de TStream.Read que os streams em memória nunca exercitam. Troque o compilador, ou dê ao mesmo código um ficheiro malformado, e o acidente deixa de se aguentar
O que vem a seguir é a forma concreta de cada um, a correção e a disciplina que daí saiu: o mesmo código-fonte tem agora de produzir a mesma semântica de documento nos dois compiladores, e um include de testes verifica que assim é. O artigo irmão sobre reforçar um parser de PDF em Pascal contra ficheiros maliciosos cobriu a largura dos inteiros, a profundidade de recursão e os buffers não inicializados. Este trata de uma classe de falha diferente: código que sempre esteve errado e tinha um compilador a encobri-lo em silêncio
Porque é que uma função que devolve um array dinâmico funciona no Delphi sem SetLength?
Porque o Delphi passa a própria variável do chamador como parâmetro de resultado oculto, pelo que uma função que nunca aloca o seu resultado consegue na mesma escrever num array que o chamador alocou. O TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray é a pesquisa na linha de referência que está no centro da descodificação bidimensional Group 3 e Group 4: dada a posição atual a0 e a cor do segmento atual, procura os changing elements da linha de varrimento anterior, os b1 e b2 do esquema de codificação bidimensional ITU-T T.4 e T.6, e devolve-os num array de dois elementos. A função original escrevia Result[0] e Result[1] e nunca chamava SetLength sobre Result
Isso devia dar erro na primeira escrita, e no Free Pascal dá. No Delphi nunca deu, porque os dois locais de chamada do descodificador são assim: declarar b: TCCITTIntegerArray, executar SetLength(b, 2) uma vez antes do ciclo das linhas de varrimento e, dentro do ciclo, atribuir b := GetNextChangingElement(a0, IsWhite) e ler b[0] e b[1]. O guia da linguagem Delphi afirma que uma função cujo resultado é uma long string, um array dinâmico ou outro tipo gerido recebe esse resultado como parâmetro var adicional e, na prática, o compilador passa o endereço do alvo da atribuição. Ou seja, Result dentro da função é o próprio b, já com dois elementos, e todas as escritas aterram em memória que pertence ao chamador. O Free Pascal entrega à função um array nil novo e só depois o atribui a b, que é a leitura do contrato contra a qual o código devia ter sido escrito desde o início
O alias transportava também uma semântica da qual o descodificador depende. Result[0] só é atribuído quando a pesquisa encontra um elemento maior que a0, e Result[1] só quando existe um elemento a seguir, pelo que, numa falha, as posições mantêm o que a iteração anterior deixou em b. A correção óbvia, alocar duas posições e zerá-las em cada chamada, teria destruído esse transporte e alterado a saída descodificada no Delphi. A correção que saiu é uma proteção em vez de um reinício: no Delphi é código morto e o caminho de descodificação fica byte a byte como estava, e no Free Pascal transforma uma falha no comportamento pretendido. É precisamente essa assimetria que interessa, já que a correção tinha de ser um no-op no compilador onde o código já produzia saída verificada
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// O Delphi chega aqui com o array de dois elementos do chamador
// em alias como Result, pelo que isto é um no-op. O FPC chega com nil.
If (Length(Result) < 2) Then
SetLength(Result, 2);
...
// Result[0] e Result[1] só são escritos num acerto, por isso uma falha
// mantém os valores da iteração anterior exatamente como antes
End;
Uma contagem que sobreviveu aos seus dados: a entrada de diretório TIFF
Quando invalida um array, tem de invalidar a sua contagem na mesma instrução, ou a contagem vai ser acreditada por código que nunca vê o array. Uma entrada image file directory do TIFF (TIFF 6.0 §2, o layout de 12 bytes com tag, type, count e value-or-offset) transporta uma contagem de 32 bits diretamente do ficheiro, e a PDF Library for Delphi lê cada uma através de PopDE: TTIFFEntry, um record com Tag, TagType, Length, Offset e os arrays descodificados IntegerValues e DoubleValues. O código original verificava se Offset + TypeSize * Length passava do fim do ficheiro e, se passasse, punha os dois arrays com comprimento zero. Deixava Result.Length com o valor que vinha do ficheiro
A partir daí, duas coisas correram mal. A função termina com um fallback que diz «se Length for zero, dá à entrada um elemento com valor zero», para que os chamadores possam ler sempre o elemento zero. Como Length nunca era limpo no caminho fora de intervalo, esse fallback nunca disparava para o único caso para o qual existia. E os chamadores leem o elemento zero sem qualquer condição: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip e mais uma dúzia tomam E.IntegerValues[0], e as tabelas de strips fazem Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), copiando Length vezes quatro bytes de um array que não tem nenhum. Um array limpo com uma contagem viva é estritamente mais perigoso do que um array não verificado, porque o não verificado pelo menos contém os bytes que afirma conter
O segundo problema era a ordenação. As duas chamadas a SetLength corriam antes do teste de intervalo e eram dimensionadas a partir da contagem do ficheiro, pelo que uma entrada hostil podia pedir uma alocação de vários gigabytes antes de qualquer verificação de validade. No Delphi, a exceção resultante era apanhada por um handler mais acima no caminho de carregamento da imagem e o ficheiro simplesmente não carregava, e foi por isso que ninguém deu por nada; o que acontecia de facto era um evento de out-of-memory escolhido pelo ficheiro. A correção passa a alocação para depois do teste e faz a contagem viajar com os dados
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
> Length(Source);
If OutOfRange Then
Begin
Result.Length := 0; // a contagem vai junto com os valores
SetLength(Result.IntegerValues, 0);
SetLength(Result.DoubleValues, 0);
End
Else
Begin
SetLength(Result.IntegerValues, Result.Length); // só agora
SetLength(Result.DoubleValues, Result.Length);
End;
// ... mais tarde, o fallback existente chega finalmente ao caso para que existia:
If (Result.Length = 0) Then
Begin
SetLength(Result.IntegerValues, 1);
Result.IntegerValues[0] := 0;
End;
Nada nesta correção é específico de um compilador, e é isso que a faz pertencer a esta lista. O defeito estava latente no Delphi pela mesma razão que estava latente no Free Pascal: nenhum ficheiro de teste tinha uma entrada de diretório a apontar para lá do fim do ficheiro. A portabilidade não o expôs. Lê-lo com a pergunta «o que é que o Delphi faz por mim aqui que eu não esteja a fazer sozinho» é que expôs
O que acontece quando um IHDR de PNG declara um color type que o formato não define?
A PDF Library for Delphi rejeita agora a imagem antes de os filtros de linha correrem; antes da v3.539.2 calculava uma linha de varrimento de zero bytes e entregava aos ciclos de desfiltragem um buffer vazio. A ISO 15948 §11.2.2 define o chunk IHDR e a Tabela 11.1 lista as seis combinações legais de color type e bit depth: escala de cinzentos a 1, 2, 4, 8 ou 16 bits, cor indexada a 1, 2, 4 ou 8, e truecolor, escala de cinzentos com alpha e truecolor com alpha a 8 ou 16. O TPNGReader validava os campos compression method e filter method do IHDR e deixava passar FColorType e a bit depth intactos
O código dos filtros de linha dimensiona tudo a partir de um Case FColorType Of que mapeia cada color type para uma contagem de componentes. Um color type fora dos seis cai no ramo Else, onde SourceComponents é 0, logo ScanlineByteCount é 0, logo à SetLength(PreviousScanline, 0) segue-se imediatamente um FillChar(PreviousScanline[0], ScanlineByteCount, 0). Indexar o elemento zero de um array dinâmico vazio é um endereço calculado a partir de nil. Com a verificação de intervalos desligada, um preenchimento de zero bytes através desse endereço é um no-op silencioso e o descodificador segue em frente por linhas que não existem; com a verificação ligada é um ERangeError logo na primeira imagem; e as chamadas a Move que se seguem ficam a um passo de uma access violation. Qual destes cenários lhe calha depende do compilador e dos switches de compilação, e não de qualquer decisão do descodificador, e é isso que denuncia que o descodificador nunca decidiu nada
A correção é a tabela da especificação, aplicada onde os outros campos do IHDR já eram verificados: COLOR_GRAYSCALE aceita FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE aceita [1, 2, 4, 8], e COLOR_RGB, COLOR_GRAYSCALEALPHA e COLOR_RGBALPHA aceitam [8, 16]; qualquer outra coisa limpa ValidImage e a imagem é recusada com a largura e a altura intactas para diagnóstico. Um chunk pHYs mais curto que os seus nove bytes foi fechado na mesma passagem, já que o leitor de DPI indexava S[1] a S[8] de uma string que o chunk curto tinha deixado vazia
Um offset baseado em 1 tratado como ponteiro baseado em 0
O InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString recebe um StartPos baseado em 1, porque a sua entrada é uma AnsiString e a implementação Delphi endereça a entrada do zlib como @Input[StartPos]. A implementação Free Pascal, escrita contra o paszlib para que ambos os alvos Windows liguem a compressão estaticamente, punha next_in a PAnsiChar(Input) + StartPos e avail_in a Length(Input) - StartPos. Isto é aritmética de ponteiros, e é baseada em 0. Passe 1, que é o que «começar no início» significa para esta função, e o build FPC começa a inflar no segundo byte e para um byte antes do fim
A razão pela qual sobreviveu é que o único chamador que a maioria dos testes alcança é o InflateStr, que passa 0. Zero calha a ser o offset correto baseado em 0, por isso os dois builds concordavam em todas as chamadas simples a InflateStr e em todos os testes que passavam por lá. O TPDFDocument.DecodeAllStreams, a rotina que o SaveQDFToFile e o ConvertFileToQDF usam para expandir streams de FlateDecode simples para uma forma legível, passa 1. No build FPC, o cabeçalho zlib saltado fazia o inflate falhar, mas o stream zlib reportava na mesma um Consumed diferente de zero para os bytes que tinha examinado, pelo que o DecodeAllStreams tomava a carga útil vazia como uma descodificação bem-sucedida e substituía todos os content streams por uma string vazia. O QDF resultante tinha a contagem de páginas correta, estrutura válida e nenhum conteúdo de página, o que é um ficheiro que abre sem erro em qualquer visualizador e não mostra nada
// Ramo FPC de InflateStrFromPosition, depois da v3.539.16.
// StartPos é baseado em 1 como no ramo Delphi; limitá-lo e depois converter
// para um offset de ponteiro baseado em 0 uma única vez, na fronteira.
If (StartPos < 1) Then
StartPos := 1;
If (Length(Input) = 0) Or (StartPos > Length(Input)) Then
Exit;
...
strm.next_in := Pointer(PAnsiChar(Input) + StartPos - 1);
strm.avail_in := Length(Input) - StartPos + 1;
A regressão que o protege é a mais pequena possível: comprimir uma carga útil com deflate, inflá-la a partir da posição 0 e da posição 1, e afirmar que ambas devolvem a mesma carga útil e que ambas reportam Consumed igual ao comprimento total do stream. Um stream RFC 1950 tem um cabeçalho de dois bytes e um trailer Adler-32 de quatro bytes, pelo que um off-by-one em qualquer dos extremos não é uma corrupção subtil, é um stream que ou não arranca ou não termina. A lição é sobre a fronteira, não sobre o zlib: quando o parâmetro de uma função é definido numa base de índice e a implementação por baixo usa a outra, a conversão pertence a exatamente uma linha, e um teste tem de a chamar com o valor que distingue as duas bases
Porque é que um TStream.Read curto não é o fim do stream?
Porque o TStream.Read tem permissão para devolver menos bytes do que os pedidos, por qualquer razão que lhe apeteça, e só um retorno de 0 significa que não há mais nada. O TMemoryStream e o TFileStream num disco local quase enchem sempre o pedido, e é por isso que o código que trata «devolveu menos do que pedi» como fim de ficheiro passa todos os testes que os usam. Streams suportados por rede, streams de descompressão e qualquer descendente de TStream escrito por um cliente podem devolver dois bytes quando se pedem sessenta e quatro mil e ainda ter gigabytes atrás
O TPLBuffer é o leitor por onde passa cada parser da PDF Library for Delphi, e pode envolver uma AnsiString, um ponteiro, um array de bytes ou um TStream. As suas quatro consultas de varrimento, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte e DistanceToOtherBytes, todas a devolver Int64, leem a fonte em blocos de 64 KB à procura de um delimitador e reportam a que distância está sem mexer na posição lógica. Cada ciclo terminava com Until ReadCount < BlockSize. Para as três fontes em memória isso está correto, já que o ReadIntoBuffer entrega sempre o bloco completo até ao último. Para a fonte de stream significa que o varrimento desiste na primeira leitura curta, reporta o delimitador como ausente, e o tokenizer acima decide que o objeto termina onde não termina
// TPLBuffer.DistanceToByte, o ciclo depois da v3.539.6.
// Zero é o único sinal de fim de dados que o TStream.Read define.
TempPosition := FPosition;
Try
Repeat
ReadCount := ReadIntoBuffer(@TempBuffer[0], BlockSize);
For TestPos := 0 To ReadCount - 1 Do
If TempBuffer[TestPos] = Value Then
Begin
Result := TotalSkipped + TestPos;
Exit;
End;
Inc(TotalSkipped, ReadCount);
Until ReadCount = 0;
Finally
FPosition := TempPosition; // um peek não pode mover o leitor
End;
O teste que fixa isto é um descendente de TMemoryStream cujo override de Read limita cada pedido a dois bytes. Envolva a string aaaaaX nele, ponha a posição do buffer a 1, e as quatro consultas têm de reportar uma distância de 4 até ao X, deixar a posição a 1 no fim, e reportar -1 para um byte que não está lá. Antes da correção, a primeira consulta via dois bytes, concluía que o stream estava esgotado e devolvia -1. O finally importa tanto como a condição do ciclo: um Exit de dentro do varrimento é o caminho normal de sucesso, e a posição lógica tem de ser restaurada também nesse caminho, não só quando o ciclo corre até ao fim
Um código-fonte, dois compiladores, um conjunto de asserções
A disciplina que saiu destes cinco é que «o build Delphi passa» é evidência sobre o Delphi, não sobre o código-fonte. Desde a v3.539.16, a suite DUnitX do Delphi e a suite de consola do Free Pascal incluem ambas o mesmo Tests\CrossCompilerSemantics.inc, uma única rotina, RunCrossCompilerFileSemantics, que constrói um documento de duas páginas com conteúdo comprimido através de TPDFlib, o guarda, guarda-o outra vez como QDF através de SaveQDFToFile, repara o QDF com RepairQDFFile, encripta o ficheiro simples com AES-128 através de EncryptFile e uma máscara de permissões do EncodePermissions, e depois recarrega todos os artefactos e afirma as mesmas coisas nos dois compiladores: a contagem de páginas é 2, o título sobrevive, o texto da página dois é extraído intacto dos ficheiros simples, reparado e encriptado, a palavra-passe errada é recusada com um LastErrorCode diferente de zero, EncryptionStrength é 128, EncryptionAlgorithm é 2, e os bits de permissão individuais do GetUserPermissions voltam exatamente como foram codificados
A comparação é deliberadamente normalizada em vez de byte a byte. A encriptação sorteia salts aleatórios e o escritor atribui identificadores de documento, por isso não se espera que os dois builds emitam ficheiros idênticos; espera-se que emitam ficheiros que significam o mesmo, e as asserções estão formuladas a esse nível. A parte do QDF está lá exatamente por causa do bug do offset: um QDF com duas páginas e nenhum conteúdo passa uma verificação de contagem de páginas e falha uma verificação de extração de texto, e a matriz afirma a segunda. Qualquer correção futura que seja um no-op num compilador e uma mudança de comportamento no outro, o que descreve quatro dos cinco acima, passa agora a ter de limpar duas vezes as mesmas asserções antes de sair
A metade de link-time da mesma portabilidade, fazer os objetos OMF do Delphi e as expectativas COFF do Free Pascal concordarem, é uma história própria em ligação de objetos estáticos OMF para COFF no FPC Win32, e o reforço estrutural do mesmo leitor TIFF contra BigTIFF e ficheiros em mosaico está em as notas do descodificador TIFF incorporado. Os descodificadores deste artigo, e o teste cross-compiler que agora lhes está por baixo, são distribuídos na PDF Library for Delphi para Delphi, C++Builder e Free Pascal, onde se espera que o mesmo código-fonte ganhe o mesmo resultado em todos os compiladores que suporta, em vez de lhe ser concedido por um deles