Artigo Técnico

Ofuscação XOR em XLS no HotXLS: derivação de chave e XorRor

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_Method1 na [MS-OFFCRYPTO] §2.3.7.2, dirigido por duas tabelas constantes (InitialCode, 15 words, e XorMatrix, 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

Diagrama do HotXLS do registro FILEPASS XOR BIFF5 que o Excel 16 confere na abertura: o corpo puro de quatro bytes guarda a chave XOR e o verificador de senha, o Excel deriva a chave da senha digitada com CreateXorKey_Method1 e rejeita uma chave aleatória com erro de senha mesmo quando o verificador bate; o HotXLS armazena a chave derivada 014D para secret
O corpo do FILEPASS tem só duas words, mas o Excel rederiva a chave da sua senha e compara; uma chave preenchida aleatoriamente falha na checagem mesmo com verificador correto, e é por isso que o HotXLS a deriva desde a v2.384.54

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

Diagrama do HotXLS da construção do array XOR para a ofuscação BIFF5: dezesseis bytes semeados da senha e de um pad fixo são colocados em XOR com o byte baixo da chave nas posições pares e o byte alto nas ímpares, depois rotacionados à direita um bit com XorRor, produzindo o fixture 1F 32 17 B9 para a senha secret
Os bytes do array vêm da senha, do pad e dos dois bytes da chave, com uma rotação no fim; a rotação à esquerda de 2 só funcionava quando a transformação de byte rodava na ordem contrária, e o Excel segue a ordem da especificação

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:

EscolhaOpção AOpção B
Rotação do arrayXorRor (rotação à direita de 1)Rotação à esquerda de 2
Índice do array(offset + comprimento do registro) mod 16offset mod 16
Ordem da transformação do byteRotação à esquerda de 5, depois XORXOR, 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
Diagrama do HotXLS da regra do XorArrayIndex para a ofuscação XOR BIFF5: cada byte de corpo usa o offset no stream mais o comprimento total do dado do registro módulo 16, o cabeçalho puro de quatro bytes e o prefixo lbPlyPos do BOUNDSHEET ainda contam para o offset, e ignorar o termo de comprimento do registro era o defeito que o HotXLS corrigiu na v2.384.47
O índice do array reinicia uma vez por registro, não uma vez por stream: o cabeçalho e qualquer prefixo puro ocupam posições, o comprimento do dado do registro alimenta o módulo, e as duas metades do HotXLS antigo concordavam na fórmula errada

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_Method1 lê 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 para xlExcel97 e XOR para xlExcel5, acompanhando o que o próprio Excel gravava para cada formato
  • xletXor é válido só para BIFF5; com xlExcel97 o salvamento lança uma exceção em vez de cair silenciosamente para outro esquema
  • xletRC4 e xletRC4CryptoAPI sã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; para secret é $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