Technical Article

Porque é que o Excel Rejeita o seu Livro Encriptado: ECB e RC4

Escreve um livro (workbook), encripta-o com uma palavra-passe, entrega o ficheiro a um colega, e este abre-o no Excel. O Excel solicita a palavra-passe. O colega digita-a, e o Excel aceita-a. Até aqui, a encriptação parece correta. De seguida, o Excel apresenta uma caixa de diálogo a indicar que o ficheiro está corrompido e não pode ser aberto, ou abre-o exibindo uma folha de células sem sentido. A palavra-passe estava correta, mas o ficheiro está corrompido de qualquer forma. Este é o modo de falha mais desorientador na encriptação do Office, porque a parte que valida se a palavra-passe está correta e a parte que contém os seus dados são protegidas por duas operações distintas, e conseguir que uma esteja correta em nada garante a integridade da outra.

Ambos os erros descritos aqui apresentavam exatamente este formato. Em cada caso, o verificador era bem-sucedido e o corpo da folha não, o que o leva a procurar um erro de palavra-passe ou de derivação de chave que não existe. A falha real situava-se a jusante, na forma como os bytes do pacote eram transformados. As duas falhas são independentes, uma no percurso do AES e outra no do RC4, mas partilham um problema de diagnóstico, pelo que vale a pena analisar a razão pela qual um resultado parcialmente correto é o mais difícil de interpretar.

Porque é que uma palavra-passe válida não prova nada sobre o corpo

O formato utilizado pelo XLSX encriptado moderno é a Encriptação Padrão ECMA-376 (Standard Encryption), e armazena dois elementos encriptados lado a lado. Um é o EncryptionVerifier: um pequeno bloco que contém um valor aleatório e o hash desse valor, encriptados com a chave derivada da palavra-passe. O outro é o EncryptedPackage: a totalidade do contentor zip do livro, encriptado com a mesma chave. O verificador existe para que um leitor consegue validar uma palavra-passe antes de despender esforço a processar megabytes de corpo. Desencripta-se o verificador, calcula-se o hash do valor aleatório, compara-se com o hash guardado e, se coincidirem, a palavra-passe está correta.

A armadilha é que o verificador e o pacote são encriptados por chamadas separadas em buffers distintos. Uma chave derivada corretamente desencriptará o verificador sem problemas, independentemente do que aconteça ao pacote a seguir. Assim, se a sua derivação de chaves estiver correta, mas a transformação do pacote falhar, o Excel confirma a palavra-passe a partir do verificador e falha de seguida no corpo. O sintoma é lido como "palavra-passe correta, ficheiro corrompido", o que direciona a investigação para o caminho da palavra-passe, a única secção que nunca esteve avariada. A mesma separação rege o cenário legado do RC4: o hash do verificador é verificado primeiro, e um corpo que sofra desvios de sincronismo mantém essa verificação intacta.

Bug um: AES em ECB, não CBC

A especificação [MS-OFFCRYPTO] §2.3.4.15 indica que a Encriptação Padrão encripta o pacote com o AES em modo Electronic Codebook (ECB). Cada bloco de 16 bytes do pacote alinhado é encriptado de forma independente com a mesma chave. Não existe encadeamento entre blocos nem vetor de inicialização. Trata-se de uma escolha invulgar para os padrões modernos, onde o modo ECB é normalmente evitado, mas a interoperabilidade não é o local indicado para questionar as especificações. O Excel desencripta o pacote como ECB, pelo que um produtor deve encriptá-lo como ECB para que ambos estejam de acordo.

O erro residia em que o pacote estava a ser encriptado com AES em modo CBC utilizando um vetor de inicialização de zeros. Eis a razão pela qual isto quase funciona, e porque "quase" é o pior cenário possível. Em CBC, o primeiro bloco de texto limpo é submetido a um XOR com o IV antes da encriptação. Quando o IV contém apenas zeros, esse XOR nada altera, pelo que o primeiro bloco de CBC com IV de zeros produz exatamente o mesmo texto cifrado que o modo ECB. Do segundo bloco em diante, o modo CBC alimenta o bloco cifrado anterior no seguinte, de modo que cada bloco posterior ao primeiro diverge do ECB.

Agora sobreponha isto à estrutura. O esquema do pacote coloca um prefixo de comprimento de 8 bytes em little-endian logo no início, pelo que as partes do ficheiro verificadas pelo Excel primeiro situam-se no primeiro ou segundo bloco. Um primeiro bloco coincendente significa que a validação inicial é bem-sucedida, enquanto cada bloco posterior se desencripta em ruído. A correção é óbvia assim que se identifica o modo: encriptar cada bloco de 16 bytes com ECB e parar o encadeamento. No motor, XlsEncryptStdPackage percorre o buffer com preenchimento em passos de 16 bytes e chama AESEncryptECB128Block em cada um, que é a mesma primitiva já utilizada para os blocos do verificador. A base de código contém um comentário no ciclo que indica a regra de forma clara: o modo CBC com um IV de zeros apenas coincide com o ECB no primeiro bloco, pelo que o resto do pacote se desencriptaria em dados corrompidos e o Excel rejeitaria o ficheiro.

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create(nil);
  try
    Book.Open('report.xlsx');
    // SaveAsEncrypted serializes the workbook, then runs the
    // ECMA-376 Standard Encryption pipeline: AES-128 ECB over the
    // package per [MS-OFFCRYPTO] 2.3.4.15. Returns 1 on success.
    if Book.SaveAsEncrypted('report_secure.xlsx', 'S3cret!') <> 1 then
      raise Exception.Create('Encryption failed');
  finally
    Book.Free;
  end;
end;

Bug dois: a regeração de chave RC4 desalinha-se

O percurso legado do .xls utiliza o esquema CryptoAPI do RC4, e a sua regra é de natureza diferente. A especificação [MS-OFFCRYPTO] §2.3.6 indica que a cifra sofre um re-key em cada limite de bloco de 1024 bytes. O fluxo é dividido em blocos de 1024 bytes, uma nova chave RC4 é gerada para o bloco número 0, 1, 2 e assim sucessivamente, e dentro de cada bloco o fluxo de chave (keystream) é consumido continuamente de byte em byte. Duas invariantes devem manter-se: efetuar o re-key em cada limite e consumir o fluxo de chave sem lacunas dentro de um bloco. O RC4 é uma cifra de fluxo, pelo que o seu fluxo de chave é uma sequência ordenada única; o n-ésimo byte que extrai é determinado por quantos bytes extraiu antes dele. A desencriptação é o mesmo XOR contra a mesma sequência, o que significa que o produtor e o consumidor devem extrair exatamente os mesmos bytes nas mesmas posições.

Essa é a grande dificuldade. Uma cifra de fluxo não possui ressincronização. Se desperdiçar um único byte de fluxo de chave, cada byte seguinte sofrerá um XOR com o byte incorreto do fluxo de chave, e o erro nunca se autocorrigirá; propaga-se em cascata até ao fim do bloco e, uma vez desalinhada a posição de execução, a todos os blocos subsequentes. O erro detetado fazia exatamente isto. O contador de blocos iniciava a partir de um valor sentinela de menos um, e a rotina de avanço (skip) assumia que o contador já coincidia com o bloco atual. A partir dessa sentinela, efetuava o re-key e consumia um bloco de 1024 bytes completo de fluxo de chave que nunca deveria ter sido acedido, tornando negativo o contador restante. A partir daí, o desencriptador ficava desalinhado por um bloco inteiro. O verificador, analisado antes deste processo, continuava a passar, pelo que a palavra-passe parecia correta enquanto cada célula de dados resultava em lixo.

A lógica corrigida reside em TXLSDecrypterRC4. Ambas as funções Skip e Decrypt partilham um ciclo: efetuar o re-key apenas quando a posição ativa cruza para um novo bloco, onde o índice de bloco é a posição dividida por REKEY_BLOCK_SIZE (1024), consumindo depois até ao restante do bloco atual e nada mais. A função MakeKey é invocada com o índice de bloco e nunca com um índice desatualizado ou sentinela, e a posição avança a quantidade exata de bytes processados para que Skip e Decrypt se mantenham alinhados com o produtor. A lição está no elemento mais pequeno: um único byte desperdiçado não é um erro menor numa cifra de fluxo, representa a perda total de tudo o que se segue a jusante.

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create(nil);
  try
    // CanReadEncrypted checks the Compound File (OLE2) signature so
    // you can branch before attempting a normal Open. OpenEncrypted
    // routes plain files to Open and handles the encrypted container.
    if Book.CanReadEncrypted('legacy.xls') then
      Book.OpenEncrypted('legacy.xls', 'S3cret!')
    else
      Book.Open('legacy.xls');
    // read cells here
  finally
    Book.Free;
  end;
end;

A interoperabilidade com uma especificação fixa faz-se ao byte

Ambos os erros se reduzem ao mesmo princípio fundamental, que merece ser enunciado de forma isolada porque altera o peso das decisões de design. Quando o consumidor do seu resultado é um programa externo fixo que não pode alterar, o modo da cifra e o compasso do re-key não constituem detalhes de implementação que possa otimizar ou simplificar. Fazem parte do contrato de transmissão. O Excel desencriptará com ECB e efetuará o re-key em limites de 1024 bytes quer essas opções lhe agradem ou não, e o seu único trabalho consiste em produzir bytes que se desencriptem para o original sob esse procedimento exato. Um modo mais moderno, um IV que pareça inofensivo, un contador que comece onde lhe parece natural: qualquer um destes desvios constitui um defeito no instante em que diverge do que o leitor espera. A interoperabilidade contra uma especificação rígida não é aproximada: ou é exata ao byte ou falha.

Esta é também a razão pela qual o verificador constitui, por si só, um teste básico (smoke test) fraco. Confirma que a derivação de chaves funciona, o que é necessário mas longe de ser suficiente. Um teste que se limite a abrir um ficheiro encriptado e valide a palavra-passe reportará sucesso embora o corpo seja ilegível. Um teste real desencripta o pacote e compara os bytes recuperados com os dados originais de entrada, ou realiza uma operação completa de encriptação e desencriptação do livro para ler as células de volta. O verificador valida a palavra-passe; apenas o corpo comprova a encriptação.

A forma recomendada de ler e escrever livros protegidos

A superfície pública é reduzida. Para escrever um livro moderno protegido por palavra-passe, preencha ou abra um TXLSXWorkbook e chame SaveAsEncrypted com um nome de ficheiro e palavra-passe; o método serializa o livro e executa o pipeline da Encriptação Padrão corrigido pela primeira intervenção, retornando 1 em caso de sucesso. Para ler, chame CanReadEncrypted para testar se um ficheiro é um contentor de Ficheiro Composto (Compound File) encriptado, definindo de seguida o desvio: OpenEncrypted processa o caminho encriptado e recorre ao Open comum em ficheiros limpos, estando o Open com palavra-passe disponível diretamente. O processamento do modo e o ciclo de re-key descritos operam por baixo destas chamadas; fornece a palavra-passe e o nome do ficheiro e o motor encarrega-se de cumprir a especificação por si.

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create(nil);
  try
    Book.Open('quarterly.xlsx');
    Book.SaveAsEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
    // Reopen on the consumer side
    Book.OpenEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
  finally
    Book.Free;
  end;
end;

O formato do resultado protegido, o fluxo EncryptionInfo, os blocos do verificador e o esquema do pacote são explicados no nosso guia de saída XLSX protegida por AES. Para a questão complementar do bloqueio ao nível da folha de cálculo e de como a proteção interage com a configuração de página e a impressão, consulte o artigo sobre proteção, configuração de página e impressão. Ambos assentam no percurso de encriptação aqui descrito, disponibilizado como parte do HotXLS spreadsheet component para Delphi e C++Builder, a par das APIs de leitura, escrita e renderização documentadas noutras secções deste blogue.