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_Method1en [MS-OFFCRYPTO] §2.3.7.2, movido por dos tablas constantes (InitialCode, 15 palabras, yXorMatrix, 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
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
¿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ón | Opción A | Opción B |
|---|---|---|
| Rotación del array | XorRor (rotar a la derecha 1) | Rotar a la izquierda 2 |
| Índice del array | (offset + record length) mod 16 | offset mod 16 |
| Orden de la transformación del byte | Rotar a la izquierda 5, luego XOR | XOR, 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
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_Method1solo 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 paraxlExcel97y XOR paraxlExcel5, en línea con lo que el propio Excel escribía para cada formatoxletXores válido solo para BIFF5; conxlExcel97el guardado lanza una excepción en vez de hacer fallback en silencioxletRC4yxletRC4CryptoAPIson 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; parasecretes$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