Artículo técnico

Ofuscación XOR XLS de HotXLS: derivación de clave y XorRor

HotXLS escribe libros con ofuscación XOR de Excel 5.0/95 (BIFF5) que Excel 16 abre solo cuando tres detalles coinciden con [MS-OFFCRYPTO] exactamente: la clave FILEPASS debe ser CreateXorKey_Method1(password), el array XOR de 16 bytes debe construirse con XorRor (rotación a la derecha de un bit), y cada byte debe usar XorArrayIndex = (offset de stream + longitud de registro) mod 16. HotXLS acertó el índice en v2.384.47 y la clave y la rotación en v2.384.54. Antes de eso, cada archivo BIFF5 protegido por contraseña que producía se abría bien en HotXLS y fallaba en Excel

Esa última frase es toda la historia en miniatura. Un lector y un escritor que comparten la misma idea equivocada están perfectamente de acuerdo entre sí, así que las pruebas de ida y vuelta se mantienen en verde mientras el único consumidor que importa dice que no. Excel 16 dijo que no dos veces, con dos mensajes distintos, y cada mensaje apuntaba a una capa diferente del esquema. Este artículo recorre esas capas en el orden en que Excel las comprueba, con detalle a nivel de byte que le servirá tanto si llama a HotXLS como si escribe su propio lector BIFF

¿Qué guarda realmente la ofuscación XOR de BIFF?

La ofuscación XOR de BIFF guarda solo dos palabras de 16 bits en el archivo, y todo lo demás se recalcula a partir de la contraseña. El registro FILEPASS ($002F) va inmediatamente después del BOF de los globals del libro, y en un archivo BIFF5 su cuerpo es exactamente 4 bytes: la clave XOR seguida del verificador de contraseña. No hay salt, ni identificador de algoritmo, ni blob de verificador cifrado del tipo que llevan los esquemas RC4 y AES

A partir de esas dos palabras un lector reconstruye tres cosas:

  • El verificador, un hash de 16 bits de los bytes de la contraseña XOReado con $CE4B. Compararlo con la palabra guardada es la comprobación de contraseña, y la única
  • La clave XOR, un valor de 16 bits de CreateXorKey_Method1 en [MS-OFFCRYPTO] §2.3.7.2, movido por dos tablas constantes (InitialCode, 15 palabras, y XorMatrix, 105 palabras)
  • El array XOR, 16 bytes formados por los bytes de la contraseña rellenados con un pad fijo de 16 bytes, cada uno XOReado con el byte bajo de la clave (posiciones pares) o el byte alto (posiciones impares), y luego rotado a la derecha un bit

Las cabeceras de registro quedan en texto plano, y también un puñado de registros enteros que el esquema exime, entre ellos BOF, FILEPASS e INTERFACEHDR. Cada otro cuerpo de registro se transforma byte a byte: rotación a la izquierda de 5 bits, y luego XOR con una entrada del array de 16 bytes. El descifrado, que [MS-OFFCRYPTO] §2.3.7.3 detalla como DecryptData_Method1, es la imagen especular: primero XOR, luego rotación a la derecha de 5

En HotXLS usted no toca nada de esto directamente. Fije una contraseña, elija el formato, y SaveAs emite FILEPASS y transforma el 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 solo soporta ofuscación XOR; xletAuto también la elegiría
  Wb.EncryptionType := xletXor;
  // Manténgala ASCII y de 15 caracteres como mucho (ver más abajo)
  Wb.EncryptionPassword := 'secret';

  if Wb.SaveAs(FileName, xlExcel5) <> 1 then
    raise Exception.Create('BIFF5 save failed');
end;

¿Por qué Excel dice que la contraseña es incorrecta cuando el verificador coincide?

Excel rechaza la contraseña porque no se fía de la clave guardada: Excel deriva la clave de la contraseña tecleada con CreateXorKey_Method1 y la compara con la palabra de clave del FILEPASS, así que un archivo cuya clave sea otra cosa falla la comprobación de contraseña aunque el verificador sea correcto. La especificación describe la clave como una salida de la contraseña, no como un parámetro libre, y Excel 16 impone esa lectura

El escritor de HotXLS antes de v2.384.54 rellenaba la palabra de clave con dos bytes aleatorios. Sobre el papel parece inofensivo, ya que el verificador es la comprobación de contraseña documentada y el array se construye a partir de la clave que el archivo declare. El propio HotXLS leía esos archivos sin problemas, porque su lector tomaba la clave del FILEPASS tal cual. Excel 16, con el mismo archivo y la contraseña correcta, respondía que la contraseña no era correcta. Desde v2.384.54 la clave se deriva, así que el FILEPASS para la contraseña secret siempre lleva clave $014D y verificador $DAA7, valores cotejados contra una implementación independiente de la especificación

Diagrama de HotXLS del registro FILEPASS XOR de BIFF5 que Excel 16 comprueba al abrir: el cuerpo plano de cuatro bytes lleva la clave XOR y el verificador de contraseña, Excel deriva la clave de la contraseña tecleada con CreateXorKey_Method1 y rechaza una clave aleatoria con un error de contraseña aunque el verificador coincida; HotXLS guarda la clave derivada 014D para secret
El cuerpo del FILEPASS son solo dos palabras, pero Excel vuelve a derivar la clave de su contraseña y compara; una clave rellenada al azar falla la comprobación aunque el verificador sea correcto, y por eso HotXLS la deriva desde v2.384.54

La derivación en sí es corta una vez que están las dos tablas. Recorra la contraseña hacia atrás, mire el bit 6 de cada byte siete veces mientras lo desplaza a la izquierda, y XORee una entrada de XorMatrix cada vez que el bit esté a uno. Lo siguiente es un boceto de principio que reproduce el algoritmo de la especificación y coincide con la implementación de HotXLS; no es una API de HotXLS:

// Boceto de principio de [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1
// y CreateXorArray_Method1 (solo ilustración, no es una API de 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;                       // la clave solo ve 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));   // rotación a la derecha de un 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 esto produce el array 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF, un fixture práctico si está probando su propio lector

Diagrama de HotXLS de la construcción del array XOR para la ofuscación BIFF5: dieciséis bytes sembrados desde la contraseña y un pad fijo se XORean con el byte bajo de la clave en posiciones pares y el byte alto en posiciones impares, y luego se rotan a la derecha un bit con XorRor, produciendo el fixture 1F 32 17 B9 para la contraseña secret
Los bytes del array salen de la contraseña, el pad y los dos bytes de la clave, con una rotación al final; la rotación a la izquierda 2 solo funcionaba cuando la transformación de byte corría en el orden contrario, y Excel sigue el orden de la especificación

¿Por qué la clave correcta aún produce un archivo dañado?

Una clave correcta aún produce un archivo dañado cuando el array XOR se rota en la dirección equivocada: [MS-OFFCRYPTO] define el paso del array como XorRor, una rotación a la derecha de un bit, y un array rotado a la izquierda dos descifra cada cuerpo de registro a ruido. Arreglar la clave llevó a Excel 16 más allá del aviso de contraseña y directo a un error diferente, un aviso de que el archivo tiene un problema y no puede abrirse

El código antiguo de HotXLS rotaba cada byte del array 2 bits a la izquierda, una forma que circula en varias implementaciones BIFF. Como HotXLS usaba la misma rotación en ambos lados, su propio lector nunca se enteró. Excel 16 ya no puede guardar archivos de Excel 5.0/95, y no ofrece XOR al guardar BIFF8, así que no hubo muestra nativa de Excel con la que hacer diff. La evidencia tuvo que venir del otro lado: escribir un stream BIFF5 en texto plano, recodificarlo de ocho maneras y dejar que Excel 16 abriera cada variante. Las ocho variantes cruzaban tres decisiones independientes:

DecisiónOpción AOpción B
Rotación del arrayXorRor (rotar derecha 1)Rotar izquierda 2
Índice del array(offset + longitud de registro) mod 16offset mod 16
Orden de la transformación de byteRotar izquierda 5, luego XORXOR, luego rotar izquierda 5

Excel 16 abrió exactamente dos de las ocho: XorRor con rotar-luego-XOR y el índice de longitud de registro, y una variante que solo parece distinta. Rotar-izquierda-2 con XOR-luego-rotar y el mismo índice es la misma función disfrazada. La rotación se distribuye sobre el XOR, así que rol5(p xor rol2(b)) equivale a rol5(p) xor rol7(b), y sobre un valor de 8 bits una rotación a la izquierda de 7 es una rotación a la derecha de 1. En resumen, rol5 ∘ rol2 = ror1, que es por lo que el array de rotar-izquierda-2 parece plausible aislado: solo es correcto junto con el orden de transformación contrario. Emparejado con el orden de la especificación, corrompe cada byte transformado

El mismo experimento zanjó una segunda cuestión. Las variantes que quitaban la longitud de registro del índice fallaron todas, lo cual confirmó la regla de índice que HotXLS había adoptado una versión antes solo sobre la fuerza del texto de la especificación

¿Cómo se calcula XorArrayIndex para cada byte?

XorArrayIndex para un byte es su offset en el stream del libro más la longitud de todos los datos del registro al que pertenece, mod 16. El índice por tanto reinicia en un valor dependiente del registro para cada registro y se incrementa en uno por byte dentro de él. El pseudocódigo de la especificación nombra las entradas FileOffset y Data.Length, que es fácil malinterpretar como el offset de inicio del registro solamente, y esa mala lectura es exactamente lo que HotXLS enviaba hasta v2.384.47

Tres detalles deciden si sus índices cuadran con Excel:

  • La cabecera de registro de 4 bytes nunca se transforma, pero sí ocupa posiciones del stream, así que el primer byte del cuerpo de un registro está en el offset de cabecera + 4
  • El término de longitud es la longitud completa de los datos del registro, no el número de bytes realmente transformados
  • BOUNDSHEET es en parte plano: sus primeros 4 bytes, lbPlyPos, el offset de stream del BOF de la hoja, siguen legibles para que un parser pueda localizar hojas. Esos 4 bytes se saltan en la transformación pero cuentan tanto para el offset como para la longitud del registro
Diagrama de HotXLS de la regla XorArrayIndex para la ofuscación XOR de BIFF5: cada byte del cuerpo usa el offset de stream más la longitud completa de los datos del registro módulo 16, la cabecera plana de cuatro bytes y el prefijo lbPlyPos de BOUNDSHEET también cuentan para el offset, e ignorar el término de longitud de registro era el defecto que HotXLS arregló en v2.384.47
El índice del array reinicia una vez por registro, no una por stream: la cabecera y cualquier prefijo plano ocupan posiciones, la longitud de los datos del registro alimenta el módulo, y ambas mitades del HotXLS antiguo coincidían en la fórmula equivocada

Junto todo, la transformación por registro son unas pocas líneas. De nuevo, esto es un boceto de la regla, no algo que tenga que llamar:

function Rol8(B: Byte; N: Integer): Byte;
begin
  Result := Byte((B shl N) or (B shr (8 - N)));
end;

// Ofusca el cuerpo de un registro in situ. BodyPos es el offset de stream de
// Body[0], es decir, el offset de cabecera de registro + 4. PlainPrefix es 4 para
// BOUNDSHEET, la longitud completa para BOF / FILEPASS, 0 para la mayoría
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;

// La lectura es la imagen especular: B := Body[I] xor Arr[...];
// luego rotar a la derecha 5, es decir, Rol8(B, 3)

Antes de v2.384.47 el lector de HotXLS calculaba el índice solo desde la posición del stream y el escritor usaba el offset plano del byte. Ambos ignoraban la longitud del registro, así que otra vez las dos mitades coincidían entre sí y con nadie más. Un decoder escrito de forma independiente leyó correctamente la salida de v2.384.47 y la salida antigua como basura, y la prueba de las ocho variantes en Excel 16 confirmó después la regla contra el objetivo real

¿Qué pasa con los archivos XOR escritos por versiones antiguas de HotXLS?

HotXLS sigue leyendo sus propios archivos XOR anteriores a v2.384.54 comprobando la clave del FILEPASS: cuando la clave guardada coincide con la clave derivada de la contraseña, el lector construye el array XorRor de la especificación, y cuando difiere, el lector trata el archivo como un archivo HotXLS antiguo y reconstruye el array de rotar-izquierda-2. Los archivos escritos por Excel siempre llevan la clave derivada, así que siempre toman el camino de la especificación

La prueba es una heurística con una tasa de fallo precisa. Un archivo antiguo cuya clave aleatoria coincidiera por casualidad con la derivada se leería con el array equivocado, y la probabilidad de eso es 1 entre 65.536. El fallback cubre solo la rotación del array; la regla de índice no se conmuta, así que los archivos que rescata son los escritos entre v2.384.47 y v2.384.53. Si todavía conserva archivos BIFF5 XOR de esa ventana, ábralos con el HotXLS actual y guárdelos de nuevo para obtener un archivo que Excel acepte

Dos detalles de contraseña se aplican a cada archivo, viejo o nuevo:

  • Longitud. CreateXorKey_Method1 solo lee los primeros 15 bytes de la contraseña, que es el límite de la especificación. HotXLS aplica ese tope a la clave y mantiene el verificador y el array en sus reglas habituales de longitud completa y 16 bytes, de forma consistente en ambos lados. El propio Excel rechaza contraseñas de más de 15 caracteres para este formato, así que trate 15 como el máximo real
  • Conjunto de caracteres. HotXLS convierte la contraseña a bytes por la code page ANSI del sistema. La especificación describe tomar el byte bajo de cada carácter UTF-16, lo cual coincide para ASCII. Sin muestras de Excel protegidas por contraseñas no ASCII no hay verdad de referencia para el resto, así que aténgase a contraseñas ASCII para archivos XOR

Por el lado de lectura, TXLSWorkbook.OnPassword le permite pedir una contraseña cuando Open se encuentra un registro FILEPASS. El evento es un TXLSPasswordEvent con un var PassWord: WideString y un var Retry: Boolean; fije Retry a True para intentarlo de nuevo, hasta tres reintentos:

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: se requiere contraseña pero no se dio ninguna; -1005: contraseña equivocada
  if Rc <> 1 then
    raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
  ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;

Si ya conoce la contraseña, Open(FileName, APassWord) se salta el evento por completo

¿Es la ofuscación XOR lo bastante segura para algo?

La ofuscación XOR de BIFF no es cifrado y no protege nada frente a un lector motivado. La comprobación de contraseña es un verificador de 16 bits, la clave son 16 bits, y el array de 16 bytes se repite por todo el stream, así que los contenidos predecibles de los registros BIFF exponen bytes del array sin contraseña alguna. HotXLS escribe XOR solo porque los archivos de Excel 5.0/95 no tienen otra opción, y la razón para producir tales archivos hoy es un consumidor heredado que no sabe leer nada más moderno

El motor clásico selecciona el esquema por medio de TXLSWorkbook.EncryptionType, y la combinación con el formato de guardado se comprueba estrictamente:

  • xletAuto (por defecto) escribe RC4 CryptoAPI para xlExcel97 y XOR para xlExcel5, coincidiendo con lo que el propio Excel escribía para cada formato
  • xletXor solo es válido para BIFF5; con xlExcel97 el guardado lanza una excepción en lugar de hacer fallback silencioso
  • xletRC4 y xletRC4CryptoAPI son solo BIFF8, y pedirlos en un guardado BIFF5 también lanza una excepción

RC4 también está anticuado, y los detalles de hacer que interoperate están tratados en por qué Excel rechaza un libro cifrado con una contraseña correcta. Si el destinatario puede leer XLSX, use el motor XLSX en su lugar: TXLSXWorkbook.SaveAsEncryptedAgile escribe Agile Encryption (hash de contraseña SHA-512 con un spin count de 100.000 iteraciones y AES-256-CBC), el formato que Excel 2010 y posteriores escriben por defecto, mientras que SaveAsEncrypted escribe el antiguo Standard Encryption AES-128. Las compensaciones entre ambos están en cifrar archivos XLSX con AES en Delphi, y el lado de lectura está cubierto en leer archivos Excel cifrados con Agile en HotXLS

Referencia rápida: ofuscación XOR BIFF que Excel 16 acepta

  • FILEPASS ($002F) sigue al BOF de globals; en BIFF5 su cuerpo son 4 bytes: clave, luego verificador
  • Clave = CreateXorKey_Method1(password) según [MS-OFFCRYPTO] §2.3.7.2, jamás aleatoria; para secret es $014D
  • Array = bytes de contraseña + pad, XOR con el byte bajo de la clave en posiciones pares y el byte alto en impares, luego XorRor (rotar derecha 1)
  • Cifrar un byte: rotar izquierda 5, luego XOR; descifrar: XOR, luego rotar derecha 5 (§2.3.7.3)
  • XorArrayIndex = (offset de stream del byte + longitud de datos del registro) mod 16; las cabeceras y los prefijos planos cuentan para el offset
  • BOUNDSHEET mantiene sus primeros 4 bytes planos; BOF, FILEPASS e INTERFACEHDR quedan totalmente planos
  • Contraseñas: ASCII, 15 caracteres como mucho
  • HotXLS: índice arreglado en v2.384.47, clave y XorRor arreglados en v2.384.54, archivos XOR HotXLS antiguos detectados por desajuste de clave
  • Para protección real use BIFF8 RC4 CryptoAPI como mínimo, o Agile Encryption de XLSX

HotXLS maneja la protección por contraseña BIFF5 y BIFF8, Standard y Agile Encryption de XLSX, y el callback de contraseña del lado de lectura desde una sola biblioteca Delphi y C++Builder, con los detalles de interop de arriba resueltos por usted. Vea el componente de hojas de cálculo Delphi HotXLS para ediciones, plataformas y una descarga de prueba