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