Bài viết kỹ thuật

HotXLS XLS XOR Obfuscation: Key Derivation và XorRor

HotXLS ghi các workbook Excel 5.0/95 (BIFF5) obfuscate bằng XOR mà Excel 16 chỉ mở khi ba chi tiết khớp [MS-OFFCRYPTO] chuẩn xác: key FILEPASS phải là CreateXorKey_Method1(password), mảng XOR 16 byte phải dựng bằng XorRor (xoay phải một bit), và mỗi byte phải dùng XorArrayIndex = (offset stream + độ dài record) mod 16. HotXLS chỉnh đúng chỉ số trong v2.384.47 và đúng key cùng phép xoay trong v2.384.54. Trước đó, mọi tệp BIFF5 có mật khẩu mà nó tạo ra đều mở ngon lành trong HotXLS và hỏng ở Excel

Câu cuối đó là cả câu chuyện ở phiên bản tí hon. Một reader và một writer chia sẻ cùng một ý niệm sai thì ăn ý với nhau hoàn hảo, nên test round-trip vẫn xanh trong khi consumer duy nhất quan trọng lại lắc đầu. Excel 16 đã lắc đầu hai lần, với hai thông báo khác nhau, và mỗi thông báo chỉ vào một tầng khác nhau của sơ đồ. Bài này đi qua từng tầng ấy theo đúng thứ tự Excel kiểm tra, với chi tiết mức byte dùng được dù bạn gọi HotXLS hay tự viết một BIFF reader riêng

BIFF XOR obfuscation thực chất lưu gì?

BIFF XOR obfuscation chỉ lưu đúng hai word 16 bit trong tệp, còn mọi thứ khác được tính lại từ mật khẩu. Record FILEPASS ($002F) nằm ngay sau workbook globals BOF, và trong tệp BIFF5 body của nó đúng 4 byte: key XOR theo sau bởi password verifier. Không có salt, không có định danh thuật toán và không có blob verifier mã hóa kiểu mà các sơ đồ RC4 và AES mang theo

Từ hai word đó, một reader dựng lại ba thứ:

  • verifier, một hash 16 bit của các byte mật khẩu XOR với $CE4B. So nó với word đã lưu chính là phép kiểm tra mật khẩu, và là duy nhất
  • XOR key, một giá trị 16 bit đến từ CreateXorKey_Method1 trong [MS-OFFCRYPTO] §2.3.7.2, dẫn dắt bởi hai bảng hằng (InitialCode 15 word và XorMatrix 105 word)
  • XOR array, 16 byte làm từ các byte mật khẩu đệm bằng một pad 16 byte cố định, mỗi byte XOR với byte thấp của key (vị trí chẵn) hay byte cao (vị trí lẻ), rồi xoay phải một bit

Header record giữ nguyên dạng plain text, và mấy record nguyên vẹn được sơ đồ miễn cũng vậy, trong đó có BOF, FILEPASS và INTERFACEHDR. Mọi body record khác bị biến đổi từng byte: xoay trái 5 bit, rồi XOR với một phần tử của mảng 16 byte. Giải mã, mà [MS-OFFCRYPTO] §2.3.7.3 gọi tên là DecryptData_Method1, là ảnh gương: XOR trước, rồi xoay phải 5

Trong HotXLS bạn chẳng bao giờ đụng trực tiếp vào mấy thứ này. Đặt một mật khẩu, chọn định dạng, và SaveAs sẽ phát FILEPASS cùng biến đổi 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 chỉ hỗ trợ XOR obfuscation; xletAuto cũng sẽ chọn nó
  Wb.EncryptionType := xletXor;
  // Giữ ASCII và tối đa 15 ký tự (xem bên dưới)
  Wb.EncryptionPassword := 'secret';

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

Vì sao Excel bảo mật khẩu sai trong khi verifier khớp?

Excel từ chối mật khẩu vì nó không tin key đã lưu: Excel tự derive key từ mật khẩu gõ vào bằng CreateXorKey_Method1 rồi so với word key trong FILEPASS, nên một tệp có key nào khác sẽ trượt phép kiểm tra mật khẩu dù verifier đúng. Spec mô tả key là một output của mật khẩu, không phải một tham số tự do, và Excel 16 ép đúng cách đọc đó

Writer HotXLS trước v2.384.54 đổ key word bằng hai byte ngẫu nhiên. Trên giấy trông vô hại, vì verifier mới là phép kiểm tra mật khẩu được ghi trong tài liệu còn mảng được dựng từ key nào tệp khai báo. HotXLS tự nó đọc các tệp đó không trục trặc, vì reader của nó lấy key từ FILEPASS như một thứ cho sẵn. Excel 16, cầm cùng tệp đó với mật khẩu đúng, đáp rằng mật khẩu không đúng. Kể từ v2.384.54 key được derive, nên FILEPASS cho mật khẩu secret luôn giữ key $014D và verifier $DAA7, các giá trị đã đối chiếu chéo với một bản hiện thực spec độc lập

Sơ đồ HotXLS về record FILEPASS XOR BIFF5 mà Excel 16 kiểm tra lúc mở: body bốn byte plain giữ key XOR và password verifier, Excel derive key từ mật khẩu gõ vào bằng CreateXorKey_Method1 và từ chối key ngẫu nhiên với lỗi mật khẩu dù verifier khớp; HotXLS lưu key đã derive 014D cho secret
Body FILEPASS chỉ có hai word, nhưng Excel derive lại key từ mật khẩu của bạn rồi so; key đổ ngẫu nhiên trượt phép kiểm tra dù verifier đúng, vì thế HotXLS derive key kể từ v2.384.54

Bản thân phép derive thì ngắn một khi hai bảng đã sẵn sàng. Đi ngược mật khẩu từ cuối lên, nhìn bit 6 của từng byte bảy lần trong khi dịch trái nó, và XOR vào một phần tử XorMatrix mỗi lần bit bật. Dưới đây là phác thảo nguyên lý tái tạo thuật toán trong spec và khớp với bản hiện thực của HotXLS; nó không phải một API HotXLS:

// Phác thảo nguyên lý của [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1
// và CreateXorArray_Method1 (chỉ để minh họa, không phải API 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;                       // key chỉ nhìn thấy 15 byte
  if Len = 0 then
    Exit;
  Result := InitialCode[Len - 1];
  Element := $68;                    // phần tử cuối của 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));   // xoay phải một 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;

Với secret, phép này cho ra mảng 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF, một fixture tiện dụng nếu bạn đang test reader riêng của mình

Sơ đồ HotXLS về việc dựng mảng XOR cho obfuscation BIFF5: mười sáu byte được gieo từ mật khẩu và một pad cố định, XOR với byte thấp của key ở vị trí chẵn và byte cao ở vị trí lẻ, rồi xoay phải một bit bằng XorRor, cho ra fixture 1F 32 17 B9 cho mật khẩu secret
Các byte mảng đến từ mật khẩu, pad và hai byte của key, một phép xoay ở cuối; xoay trái 2 chỉ chạy khi phép biến đổi byte chạy theo thứ tự ngược lại, còn Excel đi theo thứ tự trong spec

Vì sao key đúng vẫn cho ra một tệp hỏng?

Một key đúng vẫn cho ra tệp hỏng khi mảng XOR bị xoay sai chiều: [MS-OFFCRYPTO] định nghĩa bước mảng là XorRor, xoay phải một bit, còn một mảng bị xoay trái hai sẽ giải mã mọi body record thành nhiễu. Sửa key giúp Excel 16 qua được cửa hỏi mật khẩu và đáp thẳng vào một lỗi khác, một thông báo rằng tệp có vấn đề và không thể mở

Code HotXLS cũ xoay từng byte mảng sang trái 2 bit, một dạng đang lưu hành trong vài bản hiện thực BIFF. Vì HotXLS dùng cùng phép xoay ở cả hai phía, reader của nó chẳng bao giờ hay biết. Excel 16 không còn lưu được tệp Excel 5.0/95, và khi lưu BIFF8 nó cũng không đưa ra tùy chọn XOR, nên chẳng có mẫu Excel gốc nào để diff. Bằng chứng buộc phải đến từ chiều ngược lại: viết một stream BIFF5 dạng plain text, mã hóa lại nó theo tám cách, rồi để Excel 16 mở từng biến thể. Tám biến thể là tổ hợp của ba lựa chọn độc lập:

Lựa chọnPhương án APhương án B
Xoay mảngXorRor (xoay phải 1)Xoay trái 2
Chỉ số mảng(offset + độ dài record) mod 16offset mod 16
Thứ tự biến đổi byteXoay trái 5, rồi XORXOR, rồi xoay trái 5

Excel 16 mở đúng hai trên tám: XorRor với xoay-trước-rồi-XOR cùng chỉ số độ dài record, và một biến thể chỉ trông khác thôi. Xoay-trái-2 với XOR-trước-rồi-xoay cùng chỉ số ấy là cùng một hàm ngụy trang. Phép xoay phân phối trên XOR, nên rol5(p xor rol2(b)) bằng rol5(p) xor rol7(b), và trên giá trị 8 bit thì xoay trái 7 chính là xoay phải 1. Nói ngắn gọn, rol5 ∘ rol2 = ror1, vì thế mảng xoay-trái-2 nhìn có vẻ hợp lý khi đứng một mình: nó chỉ đúng khi đi cùng thứ tự biến đổi ngược lại. Ghép với thứ tự trong spec, nó hỏng mọi byte bị biến đổi

Cùng thí nghiệm đó chốt luôn câu hỏi thứ hai. Các biến thể bỏ độ dài record khỏi chỉ số đều hỏng, qua đó xác nhận luật chỉ số mà HotXLS đã chọn từ bản phát hành trước chỉ dựa trên chữ spec

XorArrayIndex được tính cho từng byte ra sao?

XorArrayIndex của một byte là offset của nó trong workbook stream cộng với độ dài toàn bộ record data mà nó thuộc về, lấy mod 16. Chỉ số vì thế khởi động lại từ một giá trị tùy record cho mỗi record và tăng một đơn vị mỗi byte bên trong nó. Pseudocode trong spec gọi hai input là FileOffset và Data.Length, dễ bị đọc nhầm thành riêng offset bắt đầu record, và chính cách đọc nhầm đó là thứ HotXLS đã ship cho tới v2.384.47

Ba chi tiết quyết định các chỉ số của bạn có thẳng hàng với Excel hay không:

  • Header record 4 byte không bao giờ bị biến đổi, nhưng nó vẫn chiếm vị trí trên stream, nên byte body đầu tiên của một record nằm tại offset header + 4
  • Số hạng độ dài là toàn bộ độ dài record data, không phải số byte thực sự bị biến đổi
  • BOUNDSHEET plain một phần: 4 byte đầu của nó, lbPlyPos, offset stream của sheet BOF, giữ đọc được để parser định vị được các sheet. 4 byte đó bị phép biến đổi bỏ qua nhưng vẫn được tính vào cả offset lẫn độ dài record
Sơ đồ HotXLS về luật XorArrayIndex cho obfuscation XOR BIFF5: mỗi byte body dùng offset stream cộng toàn bộ độ dài record data lấy mod 16, header bốn byte plain và tiền tố lbPlyPos của BOUNDSHEET vẫn tính vào offset, còn việc bỏ qua số hạng độ dài record là lỗi HotXLS đã sửa trong v2.384.47
Chỉ số mảng khởi động lại theo từng record, không phải theo cả stream: header và bất kỳ tiền tố plain nào cũng chiếm vị trí, độ dài record data nạp vào phép mod, và cả hai nửa HotXLS cũ cùng thống nhất với nhau về một công thức sai

Gộp lại, phép biến đổi theo từng record chỉ vài dòng. Nhắc lại, đây là phác thảo của luật, không phải thứ bạn cần gọi:

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

// Obfuscate thân một record tại chỗ. BodyPos là offset stream của
// Body[0], tức offset header record + 4. PlainPrefix là 4 cho
// BOUNDSHEET, toàn bộ độ dài cho BOF / FILEPASS, 0 cho phần lớn record
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;

// Đọc là ảnh gương: B := Body[I] xor Arr[...];
// rồi xoay phải 5, tức Rol8(B, 3)

Trước v2.384.47, reader HotXLS tính chỉ số chỉ từ vị trí stream còn writer dùng offset byte plain. Cả hai đều bỏ qua độ dài record, nên lại lần nữa hai nửa đồng ý với nhau và với không ai khác. Một decoder viết độc lập đọc đúng output v2.384.47 và đọc output cũ thành rác, còn test tám biến thể với Excel 16 sau đó xác nhận luật trước mục tiêu thật

Điều gì xảy ra với các tệp XOR do phiên bản HotXLS cũ ghi?

HotXLS vẫn đọc các tệp XOR trước v2.384.54 của chính nó bằng cách kiểm tra key FILEPASS: khi key đã lưu bằng key derive từ mật khẩu, reader dựng mảng XorRor theo spec; khi nó khác, reader coi tệp là tệp HotXLS cũ và dựng lại mảng xoay-trái-2. Các tệp do Excel ghi luôn mang key derive, nên chúng luôn đi đường spec

Phép kiểm tra là một heuristic với tỉ lệ trượt chính xác. Một tệp cũ mà key ngẫu nhiên của nó tình cờ bằng key derive sẽ bị đọc bằng mảng sai, và xác suất là 1 trong 65,536. Cơ chế dự phòng chỉ phủ phép xoay mảng; luật chỉ số không được đổi, nên các tệp nó cứu được là những tệp viết trong khoảng v2.384.47 tới v2.384.53. Nếu bạn còn giữ tệp BIFF5 XOR từ cửa sổ thời gian đó, hãy mở bằng HotXLS hiện tại rồi lưu lại để có một tệp Excel chấp nhận

Hai chi tiết mật khẩu áp dụng cho mọi tệp, cũ hay mới:

  • Độ dài. CreateXorKey_Method1 chỉ đọc 15 byte mật khẩu đầu, đúng giới hạn trong spec. HotXLS áp trần đó cho key và giữ verifier cùng mảng theo luật đủ độ dài và 16 byte thường lệ, nhất quán ở cả hai phía. Bản thân Excel từ chối mật khẩu dài hơn 15 ký tự với định dạng này, nên coi 15 là mức tối đa thật
  • Bộ ký tự. HotXLS chuyển mật khẩu thành byte qua system ANSI code page. Spec mô tả việc lấy byte thấp của từng ký tự UTF-16, điều trùng khớp với ASCII. Thiếu các mẫu Excel được bảo vệ bằng mật khẩu non-ASCII thì chẳng có ground truth nào cho phần còn lại, nên cứ bám mật khẩu ASCII cho các tệp XOR

Phía đọc, TXLSWorkbook.OnPassword cho bạn hỏi mật khẩu khi Open gặp một record FILEPASS. Event là một TXLSPasswordEvent với var PassWord: WideString và var Retry: Boolean; đặt Retry thành True để thử lại, tối đa ba lần:

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: cần mật khẩu nhưng không cung cấp; -1005: mật khẩu sai
  if Rc <> 1 then
    raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
  ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;

Nếu bạn đã biết trước mật khẩu, Open(FileName, APassWord) bỏ hẳn event

XOR obfuscation có đủ an toàn cho bất cứ việc gì không?

BIFF XOR obfuscation không phải mã hóa và không bảo vệ gì trước một reader có chủ đích. Phép kiểm tra mật khẩu là một verifier 16 bit, key 16 bit, và mảng 16 byte lặp suốt cả stream, nên những nội dung record BIFF đoán trước được sẽ lộ các byte mảng mà chẳng cần mật khẩu gì. HotXLS chỉ ghi XOR vì tệp Excel 5.0/95 chẳng có lựa chọn nào khác, còn lý do để tạo những tệp như vậy ngày nay là một consumer cũ không đọc được gì mới hơn

Engine kinh điển chọn sơ đồ qua TXLSWorkbook.EncryptionType, và tổ hợp với định dạng lưu được kiểm tra nghiêm:

  • xletAuto (mặc định) ghi RC4 CryptoAPI cho xlExcel97 và XOR cho xlExcel5, khớp với những gì Excel tự ghi cho từng định dạng
  • xletXor chỉ hợp lệ cho BIFF5; với xlExcel97 thì lệnh save ném exception thay vì âm thầm fallback
  • xletRC4 và xletRC4CryptoAPI chỉ dành cho BIFF8, và đòi chúng khi save BIFF5 cũng ném exception

RC4 cũng đã cũ, và chi tiết làm nó tương thích được nói trong vì sao Excel từ chối một workbook mã hóa với mật khẩu đúng. Nếu người nhận đọc được XLSX, hãy dùng engine XLSX thay thế: TXLSXWorkbook.SaveAsEncryptedAgile ghi Agile Encryption (hash mật khẩu SHA-512 với spin count 100,000 vòng và AES-256-CBC), định dạng Excel 2010 trở lên mặc định ghi, còn SaveAsEncrypted ghi Standard Encryption AES-128 cũ hơn. Cân nhắc giữa hai cái nằm trong mã hóa tệp XLSX bằng AES trong Delphi, còn phía đọc được nói trong đọc tệp Excel mã hóa Agile với HotXLS

Tra nhanh: BIFF XOR obfuscation mà Excel 16 chấp nhận

  • FILEPASS ($002F) theo sau globals BOF; trong BIFF5 body của nó 4 byte: key, rồi verifier
  • Key = CreateXorKey_Method1(password) theo [MS-OFFCRYPTO] §2.3.7.2, không bao giờ ngẫu nhiên; với secret là $014D
  • Array = byte mật khẩu + pad, XOR byte thấp của key ở vị trí chẵn và byte cao ở vị trí lẻ, rồi XorRor (xoay phải 1)
  • Mã hóa một byte: xoay trái 5, rồi XOR; giải mã: XOR, rồi xoay phải 5 (§2.3.7.3)
  • XorArrayIndex = (offset stream của byte + độ dài record data) mod 16; header và tiền tố plain được tính vào offset
  • BOUNDSHEET giữ 4 byte đầu plain; BOF, FILEPASS và INTERFACEHDR plain toàn bộ
  • Mật khẩu: ASCII, tối đa 15 ký tự
  • HotXLS: chỉ số sửa trong v2.384.47, key và XorRor sửa trong v2.384.54, tệp XOR HotXLS cũ được nhận qua key lệch
  • Muốn bảo vệ thật sự, dùng ít nhất BIFF8 RC4 CryptoAPI, hoặc XLSX Agile Encryption

HotXLS lo bảo vệ mật khẩu BIFF5 và BIFF8, XLSX Standard và Agile Encryption, cùng callback mật khẩu phía đọc từ một thư viện Delphi và C++Builder duy nhất, với các chi tiết interop ở trên đã được lo giùm bạn. Xem HotXLS Delphi spreadsheet component để biết các edition, platform và bản dùng thử