O HotXLS grava workbooks Excel 5.0/95 (BIFF5) ofuscados com XOR que o Excel 16 só abre quando três detalhes batem com a [MS-OFFCRYPTO] exatamente: a chave do 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 registro) mod 16. O HotXLS acertou o índice na v2.384.47 e a chave e a rotação na v2.384.54. Antes disso, todo arquivo BIFF5 protegido por senha que ele produzia abria bem no HotXLS e falhava no Excel
Essa última frase é a história inteira em miniatura. Um reader e um writer que compartilham a mesma ideia errada concordam perfeitamente entre si, então os testes de round-trip continuam verdes enquanto o único consumidor que importa diz não. O Excel 16 disse não duas vezes, com duas mensagens diferentes, e cada mensagem apontou para uma camada diferente do esquema. Este artigo percorre essas camadas na ordem em que o Excel as confere, com detalhe a nível de byte que serve tanto se você chama o HotXLS quanto se escreve o seu próprio reader BIFF
O que a ofuscação XOR do BIFF realmente armazena?
A ofuscação XOR do BIFF armazena só duas words de 16 bits no arquivo, e todo o resto é recalculado a partir da senha. O registro FILEPASS ($002F) fica imediatamente depois do BOF dos globals do workbook, e num arquivo BIFF5 o corpo dele tem exatamente 4 bytes: a chave XOR seguida do verificador de senha. Não há salt, nem identificador de algoritmo, nem blob de verificador criptografado do tipo que os esquemas RC4 e AES carregam
A partir dessas duas words, um reader reconstrói três coisas:
- O verificador, um hash de 16 bits dos bytes da senha em XOR com
$CE4B. Compará-lo com a word armazenada é a checagem de senha, e a única - A chave XOR, um valor de 16 bits vindo do
CreateXorKey_Method1na [MS-OFFCRYPTO] §2.3.7.2, dirigido por duas tabelas constantes (InitialCode, 15 words, eXorMatrix, 105 words) - O array XOR, 16 bytes feitos dos bytes da senha preenchidos com um pad fixo de 16 bytes, cada um em XOR com o byte baixo da chave (posições pares) ou o byte alto (posições ímpares), e depois rotacionado à direita um bit
Os cabeçalhos de registro ficam em texto puro, e o mesmo vale para um punhado de registros inteiros que o esquema isenta, entre eles BOF, FILEPASS e INTERFACEHDR. Todo outro corpo de registro é transformado byte a byte: rotação à esquerda de 5 bits, depois XOR com uma entrada do array de 16 bytes. A descriptografia, que a [MS-OFFCRYPTO] §2.3.7.3 detalha como DecryptData_Method1, é a imagem espelhada: XOR primeiro, depois rotação à direita de 5
No HotXLS você não toca em nada disso diretamente. Define uma senha, 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;
// BIFF5 só suporta ofuscação XOR; xletAuto escolheria o mesmo
Wb.EncryptionType := xletXor;
// Mantenha ASCII e no máximo 15 caracteres (veja abaixo)
Wb.EncryptionPassword := 'secret';
if Wb.SaveAs(FileName, xlExcel5) <> 1 then
raise Exception.Create('BIFF5 save failed');
end;
Por que o Excel diz que a senha está errada quando o verificador bate?
O Excel rejeita a senha porque não confia na chave armazenada: o Excel deriva a chave da senha digitada com CreateXorKey_Method1 e compara com a word de chave do FILEPASS, então um arquivo cuja chave é qualquer outra coisa falha na checagem de senha mesmo quando o verificador está correto. A especificação descreve a chave como uma saída da senha, não como um parâmetro livre, e o Excel 16 faz cumprir essa leitura
O writer do HotXLS antes da v2.384.54 preenchia a word de chave com dois bytes aleatórios. No papel isso parece inofensivo, já que o verificador é a checagem de senha documentada e o array é construído a partir de qualquer chave que o arquivo declare. O próprio HotXLS lia esses arquivos sem problema, porque o reader dele tomava a chave do FILEPASS como dada. O Excel 16, recebendo o mesmo arquivo e a senha correta, respondia que a senha não estava correta. Desde a v2.384.54 a chave é derivada, então o FILEPASS da senha secret sempre traz a chave $014D e o verificador $DAA7, valores conferidos contra uma implementação independente da especificação
A derivação em si é curta uma vez que as duas tabelas estão no lugar. Percorra a senha de trás para frente, olhe o bit 6 de cada byte sete vezes enquanto o desloca à esquerda, e faça XOR com uma entrada de XorMatrix cada vez que o bit estiver ligado. O que segue é um esboço de princípio que reproduz o algoritmo da especificação e bate 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 (só ilustração, não é 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 isso produz o array 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF, um fixture bem útil se você está testando o seu próprio reader
Por que a chave certa ainda produz um arquivo danificado?
Uma chave correta ainda produz um arquivo danificado quando o array XOR é rotacionado do lado errado: a [MS-OFFCRYPTO] define o passo do array como XorRor, uma rotação à direita de um bit, e um array rotacionado à esquerda por dois descriptografa todo corpo de registro em ruído. Corrigir a chave fez o Excel 16 passar da senha e cair direto num erro diferente, um aviso de que o arquivo tem um problema e não pode ser aberto
O código antigo do HotXLS rotacionava cada byte do array à esquerda por 2 bits, uma forma que circula em várias implementações BIFF. Como o HotXLS usava a mesma rotação dos dois lados, o próprio reader dele nunca notou. O Excel 16 não salva mais arquivos Excel 5.0/95, e não oferece XOR ao salvar BIFF8, então não havia amostra nativa do Excel para diff. A evidência teve de vir da outra direção: escrever um stream BIFF5 em texto puro, recodificá-lo de oito maneiras e deixar o Excel 16 abrir cada variante. As oito variantes cruzaram três escolhas independentes:
| Escolha | Opção A | Opção B |
|---|---|---|
| Rotação do array | XorRor (rotação à direita de 1) | Rotação à esquerda de 2 |
| Índice do array | (offset + comprimento do registro) mod 16 | offset mod 16 |
| Ordem da transformação do byte | Rotação à esquerda de 5, depois XOR | XOR, depois rotação à esquerda de 5 |
O Excel 16 abriu exatamente duas das oito: XorRor com rotação-antes-do-XOR e o índice com comprimento do registro, e uma variante que só parece diferente. Rotação-à-esquerda-2 com XOR-antes-da-rotação e o mesmo índice é a mesma função disfarçada. A rotação distribui sobre o XOR, então 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, e é por isso que o array com rotação à esquerda de 2 parece plausível isoladamente: ele só está correto junto com a ordem de transformação contrária. Emparelhado com a ordem da especificação, ele corrompe cada byte transformado
O mesmo experimento decidiu uma segunda questão. As variantes que descartavam o comprimento do registro do índice falharam todas, o que confirmou a regra de índice que o HotXLS havia adotado um release antes apenas pela força do texto da especificação
Como o XorArrayIndex é calculado para cada byte?
O XorArrayIndex de um byte é o offset dele no stream do workbook mais o comprimento de todo o dado do registro a que ele pertence, mod 16. O índice portanto reinicia num valor dependente do registro para cada registro e incrementa um por byte dentro dele. O pseudocódigo da especificação nomeia as entradas FileOffset e Data.Length, o que é fácil de ler errado como só o offset de início do registro, e essa leitura errada é exatamente o que o HotXLS entregou até a v2.384.47
Três detalhes decidem se os seus índices se alinham com o Excel:
- O cabeçalho de registro de 4 bytes nunca é transformado, mas ainda ocupa posições no stream, então o primeiro byte de corpo de um registro fica no offset do cabeçalho + 4
- O termo de comprimento é o comprimento total do dado do registro, não o número de bytes efetivamente transformados
- BOUNDSHEET é parcialmente puro: os primeiros 4 bytes dele, o
lbPlyPos, offset no stream do BOF da planilha, ficam legíveis para um parser localizar as planilhas. Esses 4 bytes são pulados pela transformação mas ainda contam tanto para o offset quanto para o comprimento do registro
Posto junto, o transformador por registro são poucas linhas. De novo, este é um esboço da regra, não algo que você precise chamar:
function Rol8(B: Byte; N: Integer): Byte;
begin
Result := Byte((B shl N) or (B shr (8 - N)));
end;
// Ofusca um corpo de registro in place. BodyPos é o offset no stream de
// Body[0], ou seja, o offset do cabeçalho do registro + 4. PlainPrefix é 4 para
// BOUNDSHEET, o comprimento total para BOF / FILEPASS, 0 para a maioria dos registros
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 reader do HotXLS calculava o índice só pela posição no stream e o writer usava o offset puro do byte. Ambos ignoravam o comprimento do registro, então de novo as duas metades concordavam entre si e com mais ninguém. Um decoder escrito de forma independente lia a saída da v2.384.47 corretamente e a saída mais antiga como lixo, e o teste das oito variantes no Excel 16 depois confirmou a regra contra o alvo real
O que acontece com arquivos XOR gravados por versões antigas do HotXLS?
O HotXLS continua lendo os próprios arquivos XOR anteriores à v2.384.54 conferindo a chave do FILEPASS: quando a chave armazenada é igual à chave derivada da senha, o reader constrói o array XorRor da especificação, e quando difere, o reader trata o arquivo como um arquivo antigo do HotXLS e reconstrói o array com rotação à esquerda de 2. Arquivos gravados pelo Excel sempre trazem a chave derivada, então sempre seguem o caminho da especificação
O teste é uma heurística com uma taxa de falha precisa. Um arquivo antigo cuja chave aleatória por acaso fosse igual à chave derivada seria lido com o array errado, e a chance disso é 1 em 65.536. O fallback cobre só a rotação do array; a regra de índice não é trocada, então os arquivos que ele resgata são os gravados entre a v2.384.47 e a v2.384.53. Se você ainda guarda arquivos XOR BIFF5 dessa janela, abra-os com o HotXLS atual e salve-os de novo para obter um arquivo que o Excel aceita
Dois detalhes de senha valem para todo arquivo, antigo ou novo:
- Comprimento. O
CreateXorKey_Method1lê só os primeiros 15 bytes da senha, que é o limite da especificação. O HotXLS aplica esse teto à chave e mantém o verificador e o array nas regras usuais de comprimento total e 16 bytes, de forma consistente dos dois lados. O próprio Excel recusa senhas com mais de 15 caracteres nesse formato, então trate 15 como o máximo real - Conjunto de caracteres. O HotXLS converte a senha para bytes pela página de código ANSI do sistema. A especificação descreve pegar o byte baixo de cada caractere UTF-16, o que coincide para ASCII. Sem amostras do Excel protegidas por senhas não ASCII não há verdade de referência para o resto, então fique com senhas ASCII para arquivos XOR
Do lado da leitura, o TXLSWorkbook.OnPassword permite pedir uma senha quando o Open encontra um registro FILEPASS. O evento é um TXLSPasswordEvent com um var PassWord: WideString e um var Retry: Boolean; marque Retry como 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: senha exigida mas nenhuma fornecida; -1005: senha 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 você já sabe a senha, o Open(FileName, APassWord) pula o evento por completo
A ofuscação XOR é segura o suficiente para alguma coisa?
A ofuscação XOR do BIFF não é criptografia e não protege nada contra um leitor motivado. A checagem de senha é um verificador de 16 bits, a chave tem 16 bits, e o array de 16 bytes se repete pelo stream inteiro, então conteúdos previsíveis de registro BIFF expõem bytes do array sem senha nenhuma. O HotXLS grava XOR só porque arquivos Excel 5.0/95 não têm outra opção, e o motivo para produzir tais arquivos hoje é um consumidor legado que não lê nada mais novo
O motor clássico seleciona o esquema pelo TXLSWorkbook.EncryptionType, e a combinação com o formato de salvamento é conferida de forma estrita:
xletAuto(padrão) grava RC4 CryptoAPI paraxlExcel97e XOR paraxlExcel5, acompanhando o que o próprio Excel gravava para cada formatoxletXoré válido só para BIFF5; comxlExcel97o salvamento lança uma exceção em vez de cair silenciosamente para outro esquemaxletRC4exletRC4CryptoAPIsão só BIFF8, e pedi-los num salvamento BIFF5 também lança exceção
O RC4 também está datado, e os detalhes para fazê-lo interoperar estão cobertos em por que o Excel rejeita um workbook criptografado com a senha correta. Se o destinatário consegue ler XLSX, use o motor XLSX em vez disso: o TXLSXWorkbook.SaveAsEncryptedAgile grava Agile Encryption (hash de senha SHA-512 com spin count de 100.000 iterações e AES-256-CBC), o formato que o Excel 2010 e posteriores gravam por padrão, enquanto o SaveAsEncrypted grava a Standard Encryption AES-128 mais antiga. Os trade-offs entre os dois estão em criptografar arquivos XLSX com AES em Delphi, e o lado da leitura está coberto em ler arquivos Excel criptografados com Agile no HotXLS
Referência rápida: ofuscação XOR BIFF que o Excel 16 aceita
- FILEPASS (
$002F) vem depois do BOF dos globals; em BIFF5 o corpo dele tem 4 bytes: chave, depois verificador - Chave =
CreateXorKey_Method1(password)conforme [MS-OFFCRYPTO] §2.3.7.2, nunca aleatória; parasecreté$014D - Array = bytes da senha + pad, XOR com o byte baixo da chave nas posições pares e o byte alto nas ímpares, depois XorRor (rotação à direita de 1)
- Criptografar um byte: rotação à esquerda de 5, depois XOR; descriptografar: XOR, depois rotação à direita de 5 (§2.3.7.3)
- XorArrayIndex = (offset do byte no stream + comprimento do dado do registro) mod 16; cabeçalhos e prefixos puros contam para o offset
- BOUNDSHEET mantém os primeiros 4 bytes puros; BOF, FILEPASS e INTERFACEHDR ficam totalmente puros
- Senhas: ASCII, no máximo 15 caracteres
- HotXLS: índice corrigido na v2.384.47, chave e XorRor corrigidos na v2.384.54, arquivos XOR antigos do HotXLS detectados pela divergência de chave
- Para proteção de verdade, use no mínimo RC4 CryptoAPI BIFF8, ou Agile Encryption XLSX
O HotXLS trata proteção por senha BIFF5 e BIFF8, Standard e Agile Encryption XLSX, e o callback de senha do lado da leitura numa única biblioteca Delphi e C++Builder, com os detalhes de interop acima resolvidos para você. Veja o componente de planilha HotXLS para Delphi para edições, plataformas e download da versão trial