Artículo técnico

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

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

Esa última oración es toda la historia en miniatura. Un reader y un writer que comparten la misma idea equivocada concuerdan perfectamente entre sí, así que las pruebas de round-trip quedan 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 distinta del esquema. Este artículo recorre esas capas en el orden en que Excel las verifica, con detalle a nivel de byte que le sirve tanto si llama a HotXLS como si escribe su propio reader 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 record FILEPASS ($002F) va inmediatamente después del BOF de los globals del workbook, 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 como el que cargan los esquemas RC4 y AES

De esas dos palabras un reader reconstruye tres cosas:

  • El verificador, un hash de 16 bits de los bytes de la contraseña XOR $CE4B. Compararlo con la palabra guardada es el chequeo de contraseña, y el único
  • La clave XOR, un valor de 16 bits que sale 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 hechos de los bytes de la contraseña rellenados con un pad fijo de 16 bytes, cada uno XOR 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 los records quedan en texto plano, y también unos cuantos records completos que el esquema exime, entre ellos BOF, FILEPASS e INTERFACEHDR. Cada otro cuerpo de record se transforma byte por byte: rotar a la izquierda 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 espejo: primero XOR, luego rotar a la derecha 5

En HotXLS usted no toca nada de esto directamente. Ponga una contraseña, elija el formato, y SaveAs emite el 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éngalo ASCII y de a lo sumo 15 caracteres (ver 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 confía en la clave guardada: Excel deriva la clave desde la contraseña tecleada con CreateXorKey_Method1 y la compara con la palabra de clave del FILEPASS, así que un archivo cuya clave sea cualquier otra cosa falla el chequeo de contraseña incluso cuando el verificador es correcto. La especificación describe la clave como una salida de la contraseña, no como un parámetro libre, y Excel 16 hace cumplir esa lectura

El writer de HotXLS antes de v2.384.54 llenaba la palabra de clave con dos bytes aleatorios. En el papel parece inofensivo, ya que el verificador es el chequeo de contraseña documentado y el array se construye a partir de la clave que el archivo declare. El propio HotXLS leía esos archivos sin problema, porque su reader tomaba la clave del FILEPASS como algo dado. Excel 16, recibiendo 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 record FILEPASS XOR de BIFF5 que Excel 16 verifica al abrir: el cuerpo plano de cuatro bytes guarda la clave XOR y el verificador de contraseña, Excel deriva la clave desde 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 desde su contraseña y compara; una clave rellenada al azar falla el chequeo incluso con un verificador correcto, razón por la cual HotXLS la deriva desde v2.384.54

La derivación en sí es corta una vez que las dos tablas están en su lugar. Recorra la contraseña hacia atrás, mire el bit 6 de cada byte siete veces mientras lo corre a la izquierda, y XOR una entrada de XorMatrix cada vez que el bit esté encendido. Lo que sigue 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));   // rotar a la derecha 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 reader

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 XOR 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, una rotación al final; rotar a la izquierda 2 solo funcionaba cuando la transformación del byte corría en el orden opuesto, y Excel sigue el orden de la especificación

¿Por qué la clave correcta todavía produce un archivo dañado?

Una clave correcta todavía produce un archivo dañado cuando el array XOR se rota del lado equivocado: [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 record a ruido. Arreglar la clave llevó a Excel 16 más allá del prompt de contraseña y directo a un error distinto, el reporte de que el archivo tiene un problema y no se puede abrir

El código antiguo de HotXLS rotaba cada byte del array a la izquierda 2 bits, una forma que circula en varias implementaciones BIFF. Como HotXLS usaba la misma rotación en ambos lados, su propio reader 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 contra 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 a la derecha 1)Rotar a la izquierda 2
Índice del array(offset + record length) mod 16offset mod 16
Orden de la transformación del byteRotar a la izquierda 5, luego XORXOR, luego rotar a la izquierda 5

Excel 16 abrió exactamente dos de las ocho: XorRor con rotar-luego-XOR y el índice con longitud de record, 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 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 rotar a la izquierda 7 es rotar a la derecha 1. En corto, rol5 ∘ rol2 = ror1, razón por la cual el array de rotar-izquierda-2 parece plausible en aislamiento: es correcto solo junto con el orden de transformación opuesto. Emparejado con el orden de la especificación, corrompe cada byte transformado

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

¿Cómo se calcula XorArrayIndex para cada byte?

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

Tres detalles deciden si sus índices cuadran con Excel:

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

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

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

// Ofusca un cuerpo de record in situ. BodyPos es el offset del stream de
// Body[0], es decir, offset de la cabecera del record + 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;

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

Antes de v2.384.47 el reader de HotXLS calculaba el índice solo desde la posición del stream y el writer usaba el offset plano del byte. Ambos ignoraban la longitud del record, así que otra vez las dos mitades concordaban entre sí y con nadie más. Un decoder escrito de forma independiente leyó correctamente la salida de v2.384.47 y la salida anterior como basura, y la prueba de 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 verificando la clave del FILEPASS: cuando la clave guardada iguala a la clave derivada de la contraseña, el reader construye el array XorRor de la especificación, y cuando difiere, el reader 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 diera casualmente igual a la derivada se leería con el array equivocado, y la probabilidad de eso es 1 en 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 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, 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
  • Juego de caracteres. HotXLS convierte la contraseña a bytes por la página de códigos 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 con contraseñas no ASCII no hay verdad de referencia para el resto, así que quédese con contraseñas ASCII para los archivos XOR

Del lado de lectura, TXLSWorkbook.OnPassword le permite pedir una contraseña cuando Open se topa con un record FILEPASS. El evento es un TXLSPasswordEvent con un var PassWord: WideString y un var Retry: Boolean; ponga Retry en True para intentar 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 pide contraseña pero no se dio ninguna; -1005: contraseña incorrecta
  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

¿La ofuscación XOR es lo bastante segura para algo?

La ofuscación XOR de BIFF no es cifrado y no protege nada contra un lector motivado. El chequeo de contraseña es un verificador de 16 bits, la clave es de 16 bits, y el array de 16 bytes se repite a lo largo de todo el stream, así que contenidos de record BIFF predecibles 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 legacy que no puede leer nada más nuevo

El engine clásico selecciona el esquema mediante TXLSWorkbook.EncryptionType, y la combinación con el formato de guardado se verifica de forma estricta:

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

RC4 también está añejo, y los detalles para hacerlo interoperar están cubiertos en por qué Excel rechaza un workbook cifrado con una contraseña correcta. Si el destinatario puede leer XLSX, use mejor el engine XLSX: 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 Standard Encryption AES-128 más antiguo. Las contrapartidas entre los dos están en cifrar archivos XLSX con AES en Delphi, y el lado de lectura está cubierto en leer archivos Excel cifrados con Agile usando HotXLS

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

  • FILEPASS ($002F) va después del BOF de globals; en BIFF5 su cuerpo es de 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 a la derecha 1)
  • Cifrar un byte: rotar a la izquierda 5, luego XOR; descifrar: XOR, luego rotar a la derecha 5 (§2.3.7.3)
  • XorArrayIndex = (offset del byte en el stream + longitud de los datos del record) mod 16; cabeceras y prefijos planos cuentan para el offset
  • BOUNDSHEET mantiene sus primeros 4 bytes planos; BOF, FILEPASS e INTERFACEHDR quedan totalmente planos
  • Contraseñas: ASCII, de a lo sumo 15 caracteres
  • HotXLS: índice corregido en v2.384.47, clave y XorRor corregidos en v2.384.54, archivos XOR antiguos de HotXLS detectados por discrepancia de clave
  • Para protección de verdad use BIFF8 RC4 CryptoAPI como mínimo, o XLSX Agile Encryption

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