O HotXLS escreve livros ofuscados com XOR do Excel 5.0/95 (BIFF5) que o Excel 16 só abre quando três detalhes coincidem exatamente com o [MS-OFFCRYPTO]: a chave FILEPASS tem de ser CreateXorKey_Method1(password), o array XOR de 16 bytes tem de ser construído com XorRor (rotação à direita de um bit), e cada byte tem de usar XorArrayIndex = (offset no stream + comprimento do registo) mod 16. O HotXLS acertou no índice na v2.384.47 e na chave e na rotação na v2.384.54. Antes disso, todos os ficheiros BIFF5 protegidos por palavra-passe que produzia abriam bem no HotXLS e falhavam no Excel
Essa última frase é a história toda em miniatura. Um leitor e um escritor que partilham a mesma ideia errada concordam perfeitamente entre si, por isso os testes de round-trip ficam verdes enquanto o único consumidor que interessa diz que não. O Excel 16 disse não duas vezes, com duas mensagens diferentes, e cada mensagem apontava para uma camada diferente do esquema. Este artigo percorre essas camadas pela ordem em que o Excel as verifica, com detalhe ao nível de byte que serve tanto se chama o HotXLS como se escreve o seu próprio leitor BIFF
O que é que a ofuscação XOR do BIFF realmente armazena?
A ofuscação XOR do BIFF armazena apenas duas palavras de 16 bits no ficheiro, e tudo o resto é recalculado a partir da palavra-passe. O registo FILEPASS ($002F) senta-se imediatamente a seguir ao BOF dos globals do livro, e num ficheiro BIFF5 o seu corpo são exatamente 4 bytes: a chave XOR seguida do verificador da palavra-passe. Não há salt, nem identificador de algoritmo, nem blob de verificador encriptado do tipo que os esquemas RC4 e AES transportam
A partir dessas duas palavras um leitor reconstrói três coisas:
- O verificador, um hash de 16 bits dos bytes da palavra-passe com XOR aplicado de
$CE4B. Compará-lo com a palavra armazenada é a verificação da palavra-passe, e a única - A chave XOR, um valor de 16 bits de
CreateXorKey_Method1no [MS-OFFCRYPTO] §2.3.7.2, comandado por duas tabelas constantes (InitialCode, 15 palavras, eXorMatrix, 105 palavras) - O array XOR, 16 bytes feitos dos bytes da palavra-passe acolchoados com um pad fixo de 16 bytes, cada um com XOR aplicado do byte baixo da chave (posições pares) ou do byte alto (posições ímpares), e depois rodado à direita um bit
Os headers de registo ficam em texto simples, e o mesmo acontece com um punhado de registos inteiros que o esquema isenta, entre eles BOF, FILEPASS e INTERFACEHDR. Todos os outros corpos de registo são transformados byte a byte: rotação à esquerda de 5 bits, depois XOR com uma entrada do array de 16 bytes. A desencriptação, que o [MS-OFFCRYPTO] §2.3.7.3 escreve como DecryptData_Method1, é a imagem espelhada: primeiro o XOR, depois a rotação à direita de 5
No HotXLS nunca toca diretamente em nada disto. Define uma palavra-passe, escolhe o formato, e o SaveAs emite o FILEPASS e transforma o stream:
uses
SysUtils, lxHandle;
procedure SaveLegacyProtectedBook(const FileName: string);
var
Wb: IXLSWorkbook;
begin
Wb := TXLSWorkbook.Create;
Wb.Sheets.Add.Name := 'Ledger';
Wb.Sheets[1].Range['A1', 'A1'].Value := 'Account';
Wb.Sheets[1].Range['B1', 'B1'].Value := 1250.75;
// O BIFF5 só suporta ofuscação XOR; o xletAuto também a escolheria
Wb.EncryptionType := xletXor;
// Mantenha-a ASCII e com no máximo 15 caracteres (ver abaixo)
Wb.EncryptionPassword := 'secret';
if Wb.SaveAs(FileName, xlExcel5) <> 1 then
raise Exception.Create('BIFF5 save failed');
end;
Porque é que o Excel diz que a palavra-passe está errada quando o verificador coincide?
O Excel rejeita a palavra-passe porque não confia na chave armazenada: o Excel deriva a chave da palavra-passe digitada com CreateXorKey_Method1 e compara-a com a palavra de chave do FILEPASS, por isso um ficheiro cuja chave seja outra qualquer falha a verificação da palavra-passe mesmo quando o verificador está correto. A especificação descreve a chave como um output da palavra-passe, não como um parâmetro livre, e o Excel 16 impõe essa leitura
O escritor do HotXLS antes da v2.384.54 enchia a palavra de chave com dois bytes aleatórios. Em papel isso parece inofensivo, já que o verificador é a verificação documentada da palavra-passe e o array é construído a partir da chave que o ficheiro declarar. O próprio HotXLS lia esses ficheiros sem problemas, porque o seu leitor tomava a chave do FILEPASS como dada. O Excel 16, com o mesmo ficheiro e a palavra-passe correta, respondia que a palavra-passe não estava correta. Desde a v2.384.54 a chave é derivada, por isso o FILEPASS para a palavra-passe secret contém sempre a chave $014D e o verificador $DAA7, valores cruzados contra uma implementação independente da especificação
A derivação em si é curta uma vez que as duas tabelas estejam no sítio. Percorra a palavra-passe ao contrário, olhe para o bit 6 de cada byte sete vezes enquanto o desloca à esquerda, e aplique XOR de uma entrada de XorMatrix cada vez que o bit estiver ligado. O que se segue é um esboço de princípio que reproduz o algoritmo da especificação e coincide com a implementação do HotXLS; não é uma API do HotXLS:
// Esboço de princípio do [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1
// e CreateXorArray_Method1 (ilustração apenas, não é uma API do HotXLS)
type
TXorArray = array [0..15] of Byte;
function DemoCreateXorKey(const Password: AnsiString): Word;
const
InitialCode: array [0..14] of Word = ($E1F0, $1D0F, $CC9C, $84C0, $110C,
$0E10, $F1CE, $313E, $1872, $E139, $D40F, $84F9, $280C, $A96A, $4EC3);
XorMatrix: array [0..104] of Word = (
$AEFC, $4DD9, $9BB2, $2745, $4E8A, $9D14, $2A09,
$7B61, $F6C2, $FDA5, $EB6B, $C6F7, $9DCF, $2BBF,
$4563, $8AC6, $05AD, $0B5A, $16B4, $2D68, $5AD0,
$0375, $06EA, $0DD4, $1BA8, $3750, $6EA0, $DD40,
$D849, $A0B3, $5147, $A28E, $553D, $AA7A, $44D5,
$6F45, $DE8A, $AD35, $4A4B, $9496, $390D, $721A,
$EB23, $C667, $9CEF, $29FF, $53FE, $A7FC, $5FD9,
$47D3, $8FA6, $0F6D, $1EDA, $3DB4, $7B68, $F6D0,
$B861, $60E3, $C1C6, $93AD, $377B, $6EF6, $DDEC,
$45A0, $8B40, $06A1, $0D42, $1A84, $3508, $6A10,
$AA51, $4483, $8906, $022D, $045A, $08B4, $1168,
$76B4, $ED68, $CAF1, $85C3, $1BA7, $374E, $6E9C,
$3730, $6E60, $DCC0, $A9A1, $4363, $86C6, $1DAD,
$3331, $6662, $CCC4, $89A9, $0373, $06E6, $0DCC,
$1021, $2042, $4084, $8108, $1231, $2462, $48C4);
var
Len, I, Bit, Element: Integer;
Ch: Byte;
begin
Result := 0;
Len := Length(Password);
if Len > 15 then
Len := 15; // a chave só vê 15 bytes
if Len = 0 then
Exit;
Result := InitialCode[Len - 1];
Element := $68; // última entrada de XorMatrix
for I := Len downto 1 do
begin
Ch := Ord(Password[I]);
for Bit := 1 to 7 do
begin
if (Ch and $40) <> 0 then
Result := Result xor XorMatrix[Element];
Ch := Byte(Ch shl 1);
Dec(Element);
end;
end;
end;
function XorRor(B, KeyByte: Byte): Byte;
begin
B := B xor KeyByte;
Result := Byte((B shr 1) or (B shl 7)); // rotação à direita de um bit
end;
procedure DemoCreateXorArray(const Password: AnsiString; out Arr: TXorArray);
const
PadArray: TXorArray = ($BB, $FF, $FF, $BA, $FF, $FF, $B9, $80,
$00, $BE, $0F, $00, $BF, $0F, $00, $00);
var
Key: Word;
Len, I: Integer;
begin
Key := DemoCreateXorKey(Password);
Len := Length(Password);
if Len > 16 then
Len := 16;
for I := 0 to Len - 1 do
Arr[I] := Ord(Password[I + 1]);
for I := Len to 15 do
Arr[I] := PadArray[I - Len];
for I := 0 to 15 do
if Odd(I) then
Arr[I] := XorRor(Arr[I], Byte(Key shr 8))
else
Arr[I] := XorRor(Arr[I], Byte(Key and $FF));
end;
Para secret isto produz o array 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF, que é um fixture prático se está a testar o seu próprio leitor
Porque é que a chave certa ainda produz um ficheiro danificado?
Uma chave correta ainda produz um ficheiro danificado quando o array XOR é rodado no sentido errado: o [MS-OFFCRYPTO] define o passo do array como XorRor, uma rotação à direita de um bit, e um array rodado à esquerda de dois bits desencripta todos os corpos de registo para ruído. Corrigir a chave levou o Excel 16 para além do pedido de palavra-passe e direto para um erro diferente, um aviso de que o ficheiro tem um problema e não pode ser aberto
O código antigo do HotXLS rodava cada byte do array à esquerda 2 bits, uma forma que circula em várias implementações BIFF. Como o HotXLS usava a mesma rotação dos dois lados, o seu próprio leitor nunca deu por isso. O Excel 16 já não consegue gravar ficheiros Excel 5.0/95, e não oferece XOR ao gravar BIFF8, por isso não havia amostra nativa do Excel para comparar. A evidência teve de vir da outra direção: escrever um stream BIFF5 em texto simples, recodificá-lo de oito maneiras, e deixar o Excel 16 abrir cada variante. As oito variantes cruzavam três escolhas independentes:
| Escolha | Opção A | Opção B |
|---|---|---|
| Rotação do array | XorRor (rotação à direita 1) | Rotação à esquerda 2 |
| Índice do array | (offset + comprimento do registo) mod 16 | offset mod 16 |
| Ordem da transformação de bytes | Rotação à esquerda 5, depois XOR | XOR, depois rotação à esquerda 5 |
O Excel 16 abriu exatamente duas das oito: XorRor com rotação-antes-de-XOR e o índice com comprimento do registo, e uma variante que só parece diferente. Rotação-à-esquerda-2 com XOR-antes-de-rotação e o mesmo índice é a mesma função disfarçada. A rotação distribui sobre o XOR, por isso rol5(p xor rol2(b)) é igual a rol5(p) xor rol7(b), e num valor de 8 bits uma rotação à esquerda de 7 é uma rotação à direita de 1. Em resumo, rol5 ∘ rol2 = ror1, razão pela qual o array de rotação-à-esquerda-2 parece plausível isolado: só está correto em conjunto com a ordem de transformação oposta. Emparelhado com a ordem da especificação, corrompe todos os bytes transformados
A mesma experiência decidiu uma segunda questão. As variantes que largavam o comprimento do registo do índice falharam todas, o que confirmou a regra de índice que o HotXLS tinha adotado uma versão antes apenas com base no texto da especificação
Como é calculado o XorArrayIndex para cada byte?
O XorArrayIndex de um byte é o seu offset no stream do livro mais o comprimento de todos os dados do registo a que pertence, mod 16. O índice por isso recomeça num valor dependente do registo para cada registo e incrementa um por byte dentro dele. O pseudocódigo da especificação chama aos inputs FileOffset e Data.Length, o que se lê facilmente como apenas o offset de início do registo, e essa leitura errada é exatamente o que o HotXLS enviou até à v2.384.47
Três detalhes decidem se os seus índices alinham com o Excel:
- O header de registo de 4 bytes nunca é transformado, mas ainda ocupa posições no stream, por isso o primeiro byte do corpo de um registo senta-se no offset do header + 4
- O termo do comprimento é o comprimento total dos dados do registo, não o número de bytes realmente transformados
- O BOUNDSHEET é parcialmente simples: os seus primeiros 4 bytes,
lbPlyPos, o offset no stream do BOF da folha, ficam legíveis para um parser poder localizar folhas. Esses 4 bytes são saltados pela transformação mas contam tanto para o offset como para o comprimento do registo
Posto junto, o transform por registo são umas quantas linhas. De novo, isto é um esboço da regra, não algo que precise de chamar:
function Rol8(B: Byte; N: Integer): Byte;
begin
Result := Byte((B shl N) or (B shr (8 - N)));
end;
// Ofusca um corpo de registo no sítio. BodyPos é o offset no stream de
// Body[0], ou seja o offset do header do registo + 4. PlainPrefix é 4 para
// BOUNDSHEET, o comprimento total para BOF / FILEPASS, 0 para a maioria
procedure DemoObfuscateRecord(var Body: array of Byte; RecordLength: Word;
PlainPrefix: Integer; BodyPos: LongWord; const Arr: TXorArray);
var
I: Integer;
begin
for I := PlainPrefix to RecordLength - 1 do
Body[I] := Rol8(Body[I], 5) xor
Arr[(BodyPos + LongWord(I) + RecordLength) mod 16];
end;
// A leitura é a imagem espelhada: B := Body[I] xor Arr[...];
// depois rotação à direita de 5, ou seja Rol8(B, 3)
Antes da v2.384.47 o leitor do HotXLS calculava o índice apenas a partir da posição no stream e o escritor usava o offset simples do byte. Ambos ignoravam o comprimento do registo, por isso de novo as duas metades concordavam entre si e com mais ninguém. Um descodificador escrito independentemente lia corretamente a saída da v2.384.47 e lia a saída mais antiga como lixo, e o teste das oito variantes no Excel 16 confirmou depois a regra contra o alvo real
O que acontece aos ficheiros XOR escritos por versões mais antigas do HotXLS?
O HotXLS continua a ler os seus próprios ficheiros XOR anteriores à v2.384.54 verificando a chave do FILEPASS: quando a chave armazenada é igual à chave derivada da palavra-passe, o leitor constrói o array XorRor da especificação, e quando difere, o leitor trata o ficheiro como um ficheiro HotXLS mais antigo e reconstrói o array de rotação-à-esquerda-2. Ficheiros escritos pelo Excel trazem sempre a chave derivada, por isso seguem sempre o caminho da especificação
O teste é uma heurística com uma taxa de falha precisa. Um ficheiro antigo cuja chave aleatória por acaso fosse igual à chave derivada seria lido com o array errado, e a probabilidade disso é 1 em 65.536. O fallback cobre apenas a rotação do array; a regra do índice não é comutada, por isso os ficheiros que resgata são os escritos entre a v2.384.47 e a v2.384.53. Se ainda tem ficheiros XOR BIFF5 dessa janela, abra-os com o HotXLS atual e grave-os de novo para obter um ficheiro que o Excel aceita
Dois detalhes de palavra-passe aplicam-se a todos os ficheiros, antigos ou novos:
- Comprimento.
CreateXorKey_Method1só lê os primeiros 15 bytes da palavra-passe, que é o limite da especificação. O HotXLS aplica esse teto à chave e mantém o verificador e o array nas suas regras habituais de comprimento total e de 16 bytes, consistentemente dos dois lados. O próprio Excel recusa palavras-passe com mais de 15 caracteres para este formato, por isso trate 15 como o máximo real - Conjunto de caracteres. O HotXLS converte a palavra-passe para bytes através da página de código ANSI do sistema. A especificação descreve tomar o byte baixo de cada caráter UTF-16, o que coincide para ASCII. Sem amostras do Excel protegidas por palavras-passe não ASCII não há verdade de referência para o resto, por isso fique pelas palavras-passe ASCII nos ficheiros XOR
Do lado da leitura, o TXLSWorkbook.OnPassword deixa pedir uma palavra-passe quando o Open encontra um registo FILEPASS. O evento é um TXLSPasswordEvent com um var PassWord: WideString e um var Retry: Boolean; ponha Retry a True para tentar de novo, até três tentativas:
procedure TImportForm.WorkbookPassword(Sender: TObject;
var PassWord: WideString; var Retry: Boolean);
var
S: string;
begin
S := '';
Retry := InputQuery('Protected workbook', 'Password:', S);
PassWord := S;
end;
procedure TImportForm.ImportLegacyFile(const FileName: string);
var
Wb: IXLSWorkbook;
Rc: Integer;
begin
Wb := TXLSWorkbook.Create;
Wb.OnPassword := WorkbookPassword;
Rc := Wb.Open(FileName);
// -1003: palavra-passe exigida mas nenhuma fornecida; -1005: palavra-passe errada
if Rc <> 1 then
raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;
Se já conhece a palavra-passe, o Open(FileName, APassWord) salta o evento por inteiro
A ofuscação XOR é suficientemente segura para alguma coisa?
A ofuscação XOR do BIFF não é encriptação e não protege nada contra um leitor motivado. A verificação da palavra-passe é um verificador de 16 bits, a chave tem 16 bits, e o array de 16 bytes repete-se ao longo de todo o stream, por isso conteúdos previsíveis de registos BIFF expõem bytes do array sem qualquer palavra-passe. O HotXLS escreve XOR só porque os ficheiros Excel 5.0/95 não têm outra opção, e a razão para produzir tais ficheiros hoje é um consumidor legado que não lê nada mais recente
O motor clássico escolhe o esquema através de TXLSWorkbook.EncryptionType, e a combinação com o formato de gravação é verificada estritamente:
xletAuto(por omissão) escreve RC4 CryptoAPI paraxlExcel97e XOR paraxlExcel5, a condizer com o que o próprio Excel escrevia para cada formatoxletXorsó é válido para BIFF5; comxlExcel97a gravação lança uma exceção em vez de fazer fallback silenciosoxletRC4exletRC4CryptoAPIsão só para BIFF8, e pedi-los numa gravação BIFF5 também lança uma exceção
O RC4 também está datado, e os detalhes de o fazer interoperar estão cobertos em porque é que o Excel rejeita um livro encriptado com uma palavra-passe correta. Se o destinatário consegue ler XLSX, use antes o motor XLSX: o TXLSXWorkbook.SaveAsEncryptedAgile escreve Agile Encryption (hashing de palavra-passe SHA-512 com um spin count de 100.000 iterações e AES-256-CBC), o formato que o Excel 2010 e posteriores escrevem por omissão, enquanto o SaveAsEncrypted escreve a mais antiga Standard Encryption AES-128. As trocas entre os dois estão em encriptar ficheiros XLSX com AES em Delphi, e o lado da leitura está coberto em ler ficheiros Excel encriptados Agile com o HotXLS
Referência rápida: ofuscação XOR BIFF que o Excel 16 aceita
- O FILEPASS (
$002F) segue o BOF dos globals; em BIFF5 o seu corpo são 4 bytes: chave, depois verificador - Chave =
CreateXorKey_Method1(password)conforme [MS-OFFCRYPTO] §2.3.7.2, nunca aleatória; parasecreté$014D - Array = bytes da palavra-passe + pad, XOR do byte baixo da chave nas posições pares e do byte alto nas ímpares, depois XorRor (rotação à direita 1)
- Encriptar um byte: rotação à esquerda 5, depois XOR; desencriptar: XOR, depois rotação à direita 5 (§2.3.7.3)
- XorArrayIndex = (offset no stream do byte + comprimento dos dados do registo) mod 16; headers e prefixos simples contam para o offset
- O BOUNDSHEET mantém os seus primeiros 4 bytes simples; BOF, FILEPASS e INTERFACEHDR ficam totalmente simples
- Palavras-passe: ASCII, no máximo 15 caracteres
- HotXLS: índice corrigido na v2.384.47, chave e XorRor corrigidos na v2.384.54, ficheiros XOR HotXLS mais antigos detetados por divergência de chave
- Para proteção real use no mínimo RC4 CryptoAPI em BIFF8, ou Agile Encryption em XLSX
O HotXLS trata a proteção por palavra-passe BIFF5 e BIFF8, a Standard e a Agile Encryption em XLSX, e a callback de palavra-passe do lado da leitura a partir de uma única biblioteca Delphi e C++Builder, com os detalhes de interop acima tratados por si. Veja o componente de folhas de cálculo Delphi HotXLS para edições, plataformas e uma transferência de avaliação