HotXLS는 Excel 16이 열려면 세 가지 세부가 [MS-OFFCRYPTO]와 정확히 일치해야 하는 Excel 5.0/95(BIFF5) XOR 난독화 통합 문서를 씁니다. FILEPASS 키는 CreateXorKey_Method1(password)여야 하고, 16바이트 XOR 배열은 XorRor(1비트 오른쪽 회전)로 만들어야 하며, 각 바이트는 XorArrayIndex = (stream offset + record length) mod 16을 써야 합니다. HotXLS는 v2.384.47에서 인덱스를 맞췄고 v2.384.54에서 키와 회전을 맞췄습니다. 그 전에는 이것이 생산한 모든 암호 보호 BIFF5 파일이 HotXLS에서는 잘 열리고 Excel에서는 실패했습니다
마지막 문장이 이야기 전체의 축소판입니다. 같은 잘못된 생각을 공유하는 reader와 writer는 서로 완벽하게 합의하므로, 왕복 테스트는 계속 초록불인 동안 유일하게 중요한 소비자는 거절합니다. Excel 16은 두 번, 서로 다른 메시지로 거절했고 각 메시지는 스킴의 서로 다른 층을 가리켰습니다. 이 글은 Excel이 검사하는 순서대로 그 층들을 훑습니다. HotXLS를 호출하든 자기 BIFF reader를 쓰든 쓸 수 있는 바이트 수준 세부와 함께요
BIFF XOR 난독화는 실제로 무엇을 저장할까?
BIFF XOR 난독화는 파일에 16비트 워드 두 개만 저장하고 나머지는 모두 암호에서 다시 계산합니다. FILEPASS 레코드($002F)는 통합 문서 globals BOF 바로 뒤에 놓이고, BIFF5 파일에서 본문은 정확히 4바이트입니다. XOR 키 뒤에 암호 verifier가 옵니다. salt도 알고리즘 식별자도, RC4와 AES 스킴이 싣는 종류의 암호화된 verifier blob도 없습니다
reader는 그 두 워드에서 세 가지를 다시 만들어 냅니다:
- verifier. 암호 바이트를
$CE4B와 XOR한 16비트 해시입니다. 저장된 워드와 비교하는 것이 암호 검사이자 유일한 검사입니다 - XOR 키. [MS-OFFCRYPTO] §2.3.7.2의
CreateXorKey_Method1에서 나오는 16비트 값으로, 두 상수 테이블(InitialCode, 15 워드와XorMatrix, 105 워드)이 구동합니다 - XOR 배열. 암호 바이트에 고정 16바이트 패드를 채워 만든 16바이트로, 각각은 낮은 키 바이트(짝수 위치)나 높은 키 바이트(홀수 위치)와 XOR된 뒤 1비트 오른쪽 회전됩니다
레코드 헤더는 평문으로 남고 스킴이 면제하는 소수의 레코드 전체도 그렇습니다. BOF, FILEPASS, INTERFACEHDR이 그 예다. 그 밖의 모든 레코드 본문은 바이트별로 변환됩니다. 5비트 왼쪽 회전 뒤 16바이트 배열의 엔트리 하나와 XOR합니다. [MS-OFFCRYPTO] §2.3.7.3이 DecryptData_Method1로 적어 둔 복호화는 거울상입니다. 먼저 XOR하고 그다음 5비트 오른쪽 회전입니다
HotXLS에서는 이것을 직접 만질 일이 없습니다. 암호를 정하고 형식을 고르면 SaveAs가 FILEPASS를 내보내고 스트림을 변환합니다:
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는 XOR 난독화만 지원하며, xletAuto도 이것을 고름
Wb.EncryptionType := xletXor;
// ASCII로 최대 15자를 유지할 것(아래 참조)
Wb.EncryptionPassword := 'secret';
if Wb.SaveAs(FileName, xlExcel5) <> 1 then
raise Exception.Create('BIFF5 save failed');
end;
verifier가 일치하는데 Excel은 왜 암호가 틀렸다고 할까?
Excel은 저장된 키를 신뢰하지 않으므로 암호를 거부합니다. Excel은 입력한 암호에서 CreateXorKey_Method1로 키를 유도해 FILEPASS 키 워드와 비교하므로, 키가 다른 무엇이든 가진 파일은 verifier가 올바른데도 암호 검사에 실패합니다. 명세는 키를 자유 매개변수가 아니라 암호의 출력으로 기술하며, Excel 16은 그 해석을 강제합니다
v2.384.54 전의 HotXLS writer는 키 워드를 무작위 바이트 두 개로 채웠습니다. 종이 위에서는 무해해 보입니다. verifier가 문서화된 암호 검사이고 배열은 파일이 선언한 키 무엇이든지로 만들어지니까요. HotXLS 자신은 그 파일들을 문제없이 읽었습니다. reader가 FILEPASS의 키를 주어진 것으로 받았기 때문입니다. 같은 파일과 올바른 암호를 받은 Excel 16은 암호가 올바르지 않다고 답했습니다. v2.384.54부터 키는 유도되므로, 암호 secret의 FILEPASS는 언제나 키 $014D와 verifier $DAA7을 담습니다. 명세의 독립 구현과 교차 검증된 값입니다
두 테이블이 갖춰지면 유도 자체는 짧습니다. 암호를 뒤에서부터 걸으며 각 바이트를 왼쪽으로 시프트하면서 비트 6을 일곱 번 보고, 비트가 켜져 있을 때마다 XorMatrix 엔트리 하나를 XOR합니다. 다음은 명세 알고리즘을 재현하고 HotXLS 구현과 일치하는 원리 스케치입니다. HotXLS API가 아닙니다:
// [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1의 원리 스케치
// 및 CreateXorArray_Method1(설명용일 뿐, HotXLS API가 아님)
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; // 키는 15바이트만 봄
if Len = 0 then
Exit;
Result := InitialCode[Len - 1];
Element := $68; // 마지막 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)); // 1비트 오른쪽 회전
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;
secret에 대해 이것은 배열 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF를 만들어 냅니다. 자기 reader를 테스트하는 데 유용한 픽스처입니다
올바른 키로도 손상된 파일이 만들어지는 이유는?
올바른 키로도 XOR 배열이 엉뚱한 방향으로 회전되면 손상된 파일이 만들어집니다. [MS-OFFCRYPTO]는 배열 단계를 1비트 오른쪽 회전인 XorRor로 정의하며, 왼쪽 2 회전된 배열은 모든 레코드 본문을 잡음으로 복호화합니다. 키를 고치자 Excel 16은 암호 프롬프트를 넘어 곧바로 다른 오류, 파일에 문제가 있어 열 수 없다는 보고로 들어갔습니다
옛 HotXLS 코드는 배열 바이트마다 왼쪽 2비트 회전을 적용했는데, 이 형태는 여러 BIFF 구현에 돌아 다닙니다. HotXLS가 양쪽에서 같은 회전을 썼으므로 자기 reader는 결코 눈치채지 못했습니다. Excel 16은 더 이상 Excel 5.0/95 파일을 저장할 수 없고 BIFF8 저장 때 XOR을 제공하지도 않으므로, 비교할 네이티브 Excel 샘플이 없었습니다. 증거는 반대 방향에서 나와야 했습니다. 평문 BIFF5 스트림 하나를 여덟 가지 방식으로 다시 인코딩하고 Excel 16으로 각 변형을 열게 하는 것입니다. 여덟 변형은 세 가지 독립 선택을 교차했습니다:
| 선택지 | 옵션 A | 옵션 B |
|---|---|---|
| 배열 회전 | XorRor (오른쪽 1 회전) | 왼쪽 2 회전 |
| 배열 인덱스 | (offset + record length) mod 16 | offset mod 16 |
| 바이트 변환 순서 | 왼쪽 5 회전, 그다음 XOR | XOR, 그다음 왼쪽 5 회전 |
Excel 16은 여덟 중 정확히 둘을 열었습니다. 회전 후 XOR과 레코드 길이 인덱스를 쓴 XorRor, 그리고 겉보기만 다른 변형 하나입니다. XOR 후 회전과 같은 인덱스를 쓴 왼쪽-2-회전은 변장한 같은 함수입니다. 회전은 XOR 위에 분배되므로 rol5(p xor rol2(b))는 rol5(p) xor rol7(b)와 같고, 8비트 값에서 왼쪽 7 회전은 오른쪽 1 회전입니다. 요컨대 rol5 ∘ rol2 = ror1이며, 그래서 왼쪽-2-회전 배열이 단독으로는 그럴듯해 보입니다. 반대 변환 순서와 함께일 때만 올바른 것이죠. 명세 순서와 짝지으면 변환된 모든 바이트를 망칩니다
같은 실험이 두 번째 질문도 정리했습니다. 인덱스에서 레코드 길이를 뺀 변형들은 모두 실패했고, 이는 HotXLS가 한 릴리스 전에 명세 텍스트만으로 채택했던 인덱스 규칙을 확인해 주었습니다
바이트마다 XorArrayIndex는 어떻게 계산될까?
바이트의 XorArrayIndex는 통합 문서 스트림에서의 오프셋에 그것이 속한 레코드 데이터 전체 길이를 더한 값의 mod 16입니다. 따라서 인덱스는 레코드마다 레코드 의존적인 값에서 다시 시작해 그 안에서 바이트당 하나씩 증가합니다. 명세 의사코드는 입력을 FileOffset과 Data.Length로 부르는데, 레코드 시작 오프셋만으로 읽기 쉽고 그 오독이 v2.384.47까지 HotXLS가 배송한 바로 그것입니다
인덱스가 Excel과 맞물리는지를 결정하는 세부 세 가지:
- 4바이트 레코드 헤더는 결코 변환되지 않지만 여전히 스트림 위치를 차지하므로, 레코드의 첫 본문 바이트는 헤더 오프셋 + 4에 놓입니다
- 길이 항은 실제로 변환된 바이트 수가 아니라 레코드 데이터 전체 길이입니다
- BOUNDSHEET는 부분적으로 평문입니다. 첫 4바이트인
lbPlyPos, 시트 BOF의 스트림 오프셋은 파서가 시트를 찾을 수 있도록 읽을 수 있게 남습니다. 그 4바이트는 변환에서 건너뛰지만 오프셋과 레코드 길이 양쪽에 여전히 셉니다
모으면 레코드별 변환은 몇 줄입니다. 다시 말하지만 이것은 규칙의 스케치이지 호출해야 할 무언가가 아닙니다:
function Rol8(B: Byte; N: Integer): Byte;
begin
Result := Byte((B shl N) or (B shr (8 - N)));
end;
// 레코드 본문 하나를 제자리에서 난독화. BodyPos는 Body[0]의 스트림
// 오프셋, 즉 레코드 헤더 오프셋 + 4. PlainPrefix는 BOUNDSHEET가 4,
// BOF / FILEPASS는 전체 길이, 대부분의 레코드는 0
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;
// 읽기는 거울상: B := Body[I] xor Arr[...];
// 그다음 5비트 오른쪽 회전, 즉 Rol8(B, 3)
v2.384.47 전의 HotXLS reader는 인덱스를 스트림 위치만으로 계산했고 writer는 평문 바이트 오프셋을 썼습니다. 둘 다 레코드 길이를 무시했으므로, 다시 한번 두 절반은 서로 합의하고 다른 누구와도 합의하지 않았습니다. 독자적으로 작성된 디코더는 v2.384.47 출력을 올바르게 읽었고 더 오래된 출력은 쓰레기로 읽었으며, 여덟 변형 Excel 16 테스트가 나중에 규칙을 진짜 대상에 대해 확인했습니다
더 오래된 HotXLS 버전이 쓴 XOR 파일은 어떻게 되나?
HotXLS는 FILEPASS 키를 검사해 자기 v2.384.54 이전 XOR 파일도 계속 읽습니다. 저장된 키가 암호에서 유도한 키와 같으면 reader는 명세의 XorRor 배열을 만들고, 다르면 그 파일을 더 오래된 HotXLS 파일로 취급해 왼쪽-2-회전 배열을 다시 만듭니다. Excel이 쓴 파일은 언제나 유도된 키를 가지므로 언제나 명세 경로를 탑니다
이 검사는 정확한 실패율이 있는 휴리스틱입니다. 무작위 키가 우연히 유도된 키와 같아진 옛 파일은 잘못된 배열로 읽히며, 그 확률은 65,536분의 1입니다. 폴백은 배열 회전만 커버합니다. 인덱스 규칙은 전환되지 않으므로, 그것이 구해 주는 파일은 v2.384.47과 v2.384.53 사이에 쓰인 것들입니다. 그 창의 BIFF5 XOR 파일을 아직 가지고 있다면 현재 HotXLS로 열어 다시 저장해 Excel이 받아들이는 파일을 얻으세요
오래되었든 새것이든 모든 파일에 적용되는 암호 세부 두 가지:
- 길이.
CreateXorKey_Method1는 처음 15개의 암호 바이트만 읽으며, 이것이 명세 한도입니다. HotXLS는 키에 그 상한을 적용하고 verifier와 배열은 평소의 전체 길이 및 16바이트 규칙을 유지합니다. 양쪽 모두 일관되게요. Excel 자신도 이 형식에서 15자를 넘는 암호를 거부하므로, 15를 실제 최대로 다루세요 - 문자 집합. HotXLS는 암호를 시스템 ANSI 코드 페이지로 바이트로 변환합니다. 명세는 각 UTF-16 문자의 낮은 바이트를 취한다고 기술하는데, ASCII에서는 일치합니다. 비 ASCII 암호로 보호된 Excel 샘플이 없어 나머지에 대한 정답 기준이 없으므로, XOR 파일에는 ASCII 암호를 고수하세요
읽기 쪽에서는 Open이 FILEPASS 레코드를 만났을 때 TXLSWorkbook.OnPassword로 암호를 물을 수 있습니다. 이벤트는 var PassWord: WideString과 var Retry: Boolean을 가진 TXLSPasswordEvent입니다. Retry를 True로 하면 다시 시도하며, 최대 세 번까지 재시도합니다:
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: 암호가 필요하지만 제공되지 않음, -1005: 잘못된 암호
if Rc <> 1 then
raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;
암호를 이미 알고 있다면 Open(FileName, APassWord)가 이벤트를 완전히 건너뜁니다
XOR 난독화는 무엇에든 충분히 안전할까?
BIFF XOR 난독화는 암호화가 아니며 의욕 있는 reader에게 아무것도 보호하지 않습니다. 암호 검사는 16비트 verifier이고 키는 16비트이며, 16바이트 배열은 스트림 전체에 반복되므로 예측 가능한 BIFF 레코드 내용은 암호 없이도 배열 바이트를 드러냅니다. HotXLS가 XOR을 쓰는 것은 Excel 5.0/95 파일에 다른 선택지가 없기 때문이고, 오늘날 그런 파일을 생산할 이유는 더 새로운 것을 읽지 못하는 레거시 소비자입니다
클래식 엔진은 TXLSWorkbook.EncryptionType으로 스킴을 고르고, 저장 형식과의 조합은 엄격히 검사합니다:
xletAuto(기본)는xlExcel97에는 RC4 CryptoAPI를,xlExcel5에는 XOR을 씁니다. Excel 자신이 각 형식에 쓴 것과 일치합니다xletXor는 BIFF5에서만 유효합니다.xlExcel97에서는 조용히 폴백하는 대신 저장이 예외를 일으킵니다xletRC4와xletRC4CryptoAPI는 BIFF8 전용이며, BIFF5 저장에서 요구하면 예외가 일어납니다
RC4도 낡았고 상호 운용을 만드는 세부는 Excel이 올바른 암호로 암호화된 통합 문서를 거부하는 이유에서 다룹니다. 수신자가 XLSX를 읽을 수 있다면 대신 XLSX 엔진을 쓰세요. TXLSXWorkbook.SaveAsEncryptedAgile은 Agile Encryption(SHA-512 암호 해싱에 100,000회 반복 spin count, AES-256-CBC), 즉 Excel 2010 이상이 기본으로 쓰는 형식을 쓰고, SaveAsEncrypted는 더 오래된 AES-128 Standard Encryption을 씁니다. 둘 사이의 트레이드오프는 Delphi에서 AES로 XLSX 파일 암호화하기에, 읽기 쪽은 HotXLS로 Agile 암호화 Excel 파일 읽기에서 다룹니다
빠른 참조: Excel 16이 받아들이는 BIFF XOR 난독화
- FILEPASS(
$002F)는 globals BOF를 따릅니다. BIFF5에서 본문은 4바이트입니다. 키, 그다음 verifier - 키 = [MS-OFFCRYPTO] §2.3.7.2에 따른
CreateXorKey_Method1(password), 결코 무작위가 아닙니다.secret에 대해서는$014D - 배열 = 암호 바이트 + 패드, 짝수 위치에서 낮은 키 바이트와 홀수 위치에서 높은 키 바이트를 XOR한 뒤 XorRor(오른쪽 1 회전)
- 바이트 암호화: 왼쪽 5 회전 후 XOR. 복호화: XOR 후 오른쪽 5 회전(§2.3.7.3)
- XorArrayIndex = (바이트 스트림 오프셋 + 레코드 데이터 길이) mod 16. 헤더와 평문 접두도 오프셋에 셉니다
- BOUNDSHEET는 첫 4바이트를 평문으로 유지합니다. BOF, FILEPASS, INTERFACEHDR은 완전히 평문입니다
- 암호: ASCII, 최대 15자
- HotXLS: 인덱스는 v2.384.47에서, 키와 XorRor는 v2.384.54에서 수정. 더 오래된 HotXLS XOR 파일은 키 불일치로 탐지
- 진짜 보호가 필요하면 최소한 BIFF8 RC4 CryptoAPI, 아니면 XLSX Agile Encryption을 쓰세요
HotXLS는 하나의 Delphi와 C++Builder 라이브러리에서 BIFF5와 BIFF8 암호 보호, XLSX Standard와 Agile Encryption, 읽기 쪽 암호 콜백을 처리하며, 위의 상호 운용 세부는 여러분 대신 다뤄집니다. 에디션, 플랫폼, 평가판 다운로드는 HotXLS Delphi 스프레드시트 컴포넌트를 참조하세요