기술 문서

비밀번호가 맞는데 Excel이 암호화 워크북을 거부하는 이유

통합 문서를 작성해 비밀번호로 암호화한 뒤 동료에게 전달했고 동료가 엑셀로 이 파일을 열었습니다. Excel이 비밀번호 입력을 요구하고 동료가 비밀번호를 정확히 기입하자 정상 수락되었습니다. 여기까지만 보면 암호화 처리가 문제없이 완벽해 보입니다. 그런데 돌연 Excel 창에 파일이 손상되어 열 수 없다는 에러 대화 상자가 나타나거나, 시트가 열리기는 했는데 모든 셀에 의미를 알 수 없는 깨진 외계어 데이터만 가득 차 있습니다. 비밀번호가 맞았는데도 파일이 파손된 상태인 것입니다. 이는 오피스 파일 암호화 과정에서 가장 개발자를 당혹스럽게 만드는 오류 유형입니다. 왜냐하면 비밀번호의 일치 여부를 판별해 주는 암호 영역과 실제 시트 본문 데이터를 담아 보호하는 암호 영역이 서로 다른 두 개의 메커니즘으로 이원화되어 작동하므로, 한쪽을 맞게 처리했더라도 다른 한쪽의 호환성이 깨지면 이와 같은 현상이 초래되기 때문입니다

여기서 다루는 두 함정은 정확히 이 모양입니다. 두 사례 모두 검증기는 통과하는데 본문은 그렇지 않아서, 존재하지 않는 비밀번호나 키 유도 문제를 찾아 헤매게 만듭니다. 진짜 원인은 그 하류, 패키지 바이트를 변환하는 방식에 있습니다. 두 함정은 서로 독립적입니다. 하나는 AES 경로, 다른 하나는 RC4 경로의 문제지만, 진단이 어렵다는 문제는 공유하므로 반쯤 맞은 결과가 왜 가장 읽기 어려운지 볼 가치가 있습니다

비밀번호 검증 성공이 본문 데이터의 안전을 보증하지 못하는 이유

최신 엑셀 포맷인 XLSX가 사용하는 암호화 표준 규격은 ECMA-376 표준 암호화(Standard Encryption)로, 파일 내부에 암호화된 두 개의 데이터 저장소를 병렬로 보관합니다. 하나는 비밀번호에서 유도된 임시 키로 암호화되며 난수(random value) 값과 해당 난수의 해시 값을 저장하는 'EncryptionVerifier(비밀번호 검증기)' 블록입니다. 다른 하나는 동일한 키로 파일 전체 zip 압축 컨테이너 데이터를 고스란히 암호화해 보관하는 'EncryptedPackage(암호화된 패키지)' 본문입니다. 검증기 블록을 별도로 두는 이유는, 메가바이트 크기의 무거운 본문 데이터를 일일이 해독해 보기 전에 먼저 비밀번호의 일치 여부를 고속 검사해 겉에서 걸러내기 위함입니다. 검증기를 복호화하고 난수 해시를 계산해 기록된 해시 값과 같으면 올바른 비밀번호로 즉각 판정하는 원리입니다

여기에 함정이 존재합니다. 검증기 영역과 패키지 본문 영역은 서로 완전히 독립된 버퍼 환경에서 개별 API 호출을 통해 암호화가 진행됩니다. 따라서 비밀번호에서 키를 유도하는 연산이 완벽하다면 본문 데이터가 완전히 손상되어 깨져 있더라도 검증기 복호화는 늘 정상으로 통과하게 됩니다. 즉, 키 유도는 맞았으나 본문 인코딩 과정에 오류가 있으면 엑셀 프로그램은 비밀번호 검증에 합격하고 본문 구문 분석에서 실패를 내뱉습니다. 결과적으로 '비밀번호는 맞는데 파일이 깨진' 상태가 되며, 개발자는 무죄인 키 유도 로직 소스만 쥐 잡듯 뒤지며 시간만 낭비하게 됩니다. 구형 RC4 암호화 규격도 이와 동일한 구조로 가동됩니다. 검증기 해시가 우선 체크되므로, 본문 데이터 정렬이 어긋나더라도 검증 절차 자체는 멀쩡하게 통과하게 됩니다

Delphi에서 HotXLS가 처리하는 ECMA-376 Standard Encryption 다이어그램: 하나의 유도 키가 별개 호출로 작은 검증자와 전체 EncryptedPackage를 보호
검증자와 패키지는 별개 버퍼에 걸친 별개 호출로 암호화되므로, 올바른 비밀번호가 본문에 대해 아무것도 증명하지 못합니다

함정 하나: CBC가 아닌 ECB AES

암호화 규격서 [MS-OFFCRYPTO] §2.3.4.15에서는 표준 암호화 진행 시 본문 패키지를 전자 코드북(ECB) 방식의 AES 알고리즘으로 암호화하도록 명시하고 있습니다. 즉, 패딩 처리된 본문 파일 데이터를 16바이트 크기 블록으로 쪼갠 뒤 각 블록을 동일한 키로 개별 독립 암호화해야 합니다. 블록 간에 연쇄 고리를 거는 체이닝(chaining)이나 복호화 시작점인 초기화 벡터(IV)는 두지 않습니다. 암호학적으로 취약해 오늘날 보안 설계에서는 가급적 피하는 ECB 방식을 규격 표준으로 지정한 점이 독특하지만, 타 프로그램과의 상호 호환성을 확보해야 하는 개발 단계에서 표준의 타당성을 논쟁할 여유는 없습니다. Excel 프로그램이 본문을 무조건 ECB 방식으로 간주해 복호화하므로 생성하는 엔진도 똑같이 ECB 모드로 암호화해 제공해야 합니다

빠져들기 쉬운 실수는 패키지를 초기화 벡터가 전부 0인 CBC 모드 AES로 암호화하는 것입니다. 이것이 왜 거의 작동하고, 왜 '거의'가 최악의 지점인지 보겠습니다. CBC에서 첫 평문 블록은 암호화 전에 IV와 XOR됩니다. IV가 전부 0이면 그 XOR은 아무 것도 바꾸지 않으므로, 0-IV CBC의 첫 블록은 ECB와 정확히 같은 암호문을 냅니다. 두 번째 블록부터는 CBC가 이전 암호문 블록을 다음 블록에 먹이므로, 첫 블록 다음의 모든 블록이 ECB와 갈라집니다

이제 이것을 구조 위에 겹쳐 보겠습니다. 패키지 레이아웃은 맨 앞에 8바이트 리틀엔디안 길이 접두를 두므로, Excel이 가장 먼저 검사하는 부분이 첫 블록 안쪽에 들어갑니다. 우연히 일치하는 첫 블록은 가장 이른 검증을 통과시키지만, 그 뒤의 모든 블록은 노이즈로 복호화됩니다. 모드 이름이 밝혀지면 처방은 간단합니다. 각 16바이트 블록을 ECB로 암호화하고 체이닝하지 않습니다. 엔진의 XlsEncryptStdPackage는 패딩된 버퍼를 16바이트씩 걸어가며 각 조각에 AESEncryptECB128Block을 호출하는데, 이는 검증기 블록에 이미 쓰이던 같은 프리미티브입니다. 소스에는 그 루프에 규칙을 못박은 주석이 달려 있습니다. 0-IV CBC는 첫 블록에서만 ECB와 일치하므로 패키지의 나머지는 가비지로 복호화되고 Excel은 거부할 것이라고

HotXLS 다이어그램: 암호화된 XLSX 패키지의 16바이트 블록에서 제로 IV AES ECB 대 CBC로, 첫 블록만 같고 이후 모든 블록은 갈라짐
0 IV를 쓰는 CBC는 첫 블록에서만 ECB를 재현합니다. 첫 블록은 Excel이 가장 먼저 검증하는 길이 접두사를 담고, 그 뒤의 모든 블록은 노이즈로 해독됩니다
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('report.xlsx');
    // SaveAsEncrypted는 통합 문서를 직렬화한 다음,
    // [MS-OFFCRYPTO] 2.3.4.15에 따라 패키지에 AES-128 ECB를 적용하는
    // ECMA-376 표준 암호화 파이프라인을 실행합니다. 성공 시 1을 반환합니다.
    if Book.SaveAsEncrypted('report_secure.xlsx', 'S3cret!') <> 1 then
      raise Exception.Create('Encryption failed');
  finally
    Book.Free;
  end;
end;

함정 둘: RC4 재키가 한 박자씩 어긋난다

구형 .xls 포맷용 RC4 CryptoAPI 암호화 방식은 아예 처리 메커니즘이 다릅니다. [MS-OFFCRYPTO] §2.3.6 명세에서는 스트림 데이터를 1024바이트 블록 크기로 등분하고, 블록이 넘어갈 때마다(블록 0, 1, 2...) 키를 매번 갱신(re-keyed)하도록 지정하고 있습니다. 그리고 하나의 1024바이트 블록 내에서는 스트림 암호 키 바이트들을 연속적으로 계속 소비하며 가동해야 합니다. 즉, 두 가지 연산 규칙이 엄격히 준수되어야 합니다. 블록 경계를 지나갈 때 정확히 키를 갱신해야 하며, 하나의 블록 내에서는 끊김이나 공백 없이 스트림 키 바이트를 차례대로 가져다 써야 한다는 점입니다. RC4는 스트림 암호(stream cipher)이므로, 생성되는 스트림 키 바이트들은 고유한 선형 순서를 가집니다. 즉, n번째로 가져오는 암호 바이트의 값은 그보다 앞서 몇 개의 바이트를 뽑아 썼는지에 의해 결정됩니다. 복호화 역시 원래의 순차적 바이트 흐름에 대조해 XOR 연산으로 풀어내야 하므로, 기입하는 엔진과 해독하는 엑셀 프로그램의 바이트 읽기 오프셋 위치가 1바이트의 오차도 없이 완벽히 일치해야 합니다

여기가 난점의 전부입니다. 스트림 암호에는 재동기화가 없습니다. 키스트림 1바이트를 낭비하면 그 뒤의 모든 바이트가 엉뚱한 키스트림 바이트와 XOR되고, 오차는 스스로 복구되지 않습니다. 블록 끝까지, 그리고 실행 위치가 어긋난 뒤에는 그 뒤의 모든 블록까지 사태가 번집니다. 사소한 장부 실수가 정확히 그렇게 합니다. 감시 값 -1에서 시작하는 블록 카운터와, 카운터가 이미 현재 블록과 일치한다고 가정하는 스킵 루틴을 상상해 보세요. 그 감시 값에서 출발하면 루틴은 소비되어서는 안 될 1024바이트짜리 키스트림 블록을 통째로 재키하고 돌려서, 남은 카운트를 음수로 밀어 넣습니다. 그 순간부터 복호화기는 정확히 한 블록 위상이 어긋납니다. 이보다 먼저 검사된 검증기는 여전히 통과하므로, 비밀번호는 맞는데 모든 데이터 셀이 가비지로 나옵니다

HotXLS에서 이 로직은 TXLSDecrypterRC4에 있습니다. Skip과 Decrypt가 하나의 루프를 공유합니다. 실행 위치가 새 블록으로 넘어설 때만 재키하는데, 블록 인덱스는 위치를 REKEY_BLOCK_SIZE(1024)로 나눈 값이고, 그다음에는 현재 블록의 잔여분만큼만 소비합니다. MakeKey는 블록 인덱스로 호출되지, 낡은 인덱스나 감시 값으로 호출되지 않으며, 위치는 처리한 바이트 수만큼 정확히 진행되어 Skip과 Decrypt가 생산자와 위상을 맞춥니다. 교훈은 가장 작은 단위에 있습니다. 스트림 암호에서 낭비된 1바이트는 작은 오차가 아니라 하류 전부의 완전한 손실입니다

Delphi에서 읽는 레거시 Excel 암호화의 RC4 재키 드리프트 다이어그램: 생산자는 1024바이트마다 재키하는 반면, 센티널 시작 소비자는 키스트림 블록 하나를 태워 위상이 어긋난 채 유지
스트림 암호는 재동기화가 없으므로, 한 번 태워버린 1024바이트 키스트림 블록이 모든 해독 셀을 쓰레기로 만들어도 검증자는 여전히 통과합니다
var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create;
  try
    // RC4나 RC4 CryptoAPI로 보호된 레거시 .xls는 Open 안에서
    // 복호화됩니다. 비밀번호를 넘기면 스트림 복호화기가 재키
    // 주기를 생산자와 맞춰 유지합니다.
    Book.Open('legacy.xls', 'S3cret!');
    // 여기서 셀을 읽습니다
  finally
    Book.Free;
  end;
end;

불변의 규격 표준과의 호환은 바이트 단위의 완벽한 일치입니다

두 함정은 같은 근본 원리로 귀결되고, 그것은 설계 선택의 무게를 재는 방식을 바꾸므로 따로 적어 둘 가치가 있습니다. 출력의 소비자가 여러분이 바꿀 수 없는 고정된 외부 프로그램일 때, 암호 모드와 재키 주기는 최적화하거나 단순화할 수 있는 구현 디테일이 아니라 유선 계약의 일부입니다. Excel은 그 선택들이 마음에 들든 아니든 ECB로 복호화하고 1024바이트 경계에서 재키합니다. 여러분의 일은 그 정확한 절차 아래에서 원본으로 복호화되는 바이트를 만드는 것뿐입니다. 더 현대적인 모드, 무해해 보이는 IV, 자연스러운 곳에서 시작하는 카운터. 어느 하나든 읽는 쪽의 기대에서 벗어나는 순간 결함입니다. 얼어 붙은 명세에 대한 상호 운용은 근사치를 허용하지 않습니다. 바이트 단위로 정확하거나, 아니면 깨진 것입니다

비밀번호 검증 성공 여부만을 관찰하는 테스트가 무의미한 이유가 여기에 있습니다. 키 유도 자체는 정상 구동 중임을 알리는 1차 신호에 불과하며 이것만으로는 본문 데이터 무결성을 보증할 수 없습니다. 암호화된 파일을 열어 비밀번호 다이얼로그 수락 단계까지만 체크하는 검증 로직은 본문 데이터가 깨진 상태이더라도 성공으로 합격을 주는 오진을 하게 됩니다. 완벽한 연계 테스트를 위해서는 암호화 후 복호화를 거쳐 추출된 최종 바이트를 원본 문서와 비교 대조하거나, 암호 파일 기입 후 다시 코드를 통해 읽어 들여 특정 셀 값을 정상 수집할 수 있는지까지 끝단 검증을 수행해야 합니다. 검증기 블록은 비밀번호를 증명할 뿐, 암호화 신뢰성을 증명하는 것은 실제 본문 데이터 영역입니다

비밀번호로 보호된 Excel 파일을 읽고 쓰는 공식 구현법

공개 표면은 작습니다. 비밀번호로 보호된 최신 워크북을 쓰려면 TXLSXWorkbook을 채우거나 연 뒤 파일 이름과 비밀번호로 SaveAsEncrypted를 호출합니다. 워크북을 직렬화하고 위에서 기술한 표준 암호화 파이프라인을 돌려 성공 시 1을 반환합니다. SaveAsEncryptedAgile은 같은 인수를 받고 Excel 2010 이상이 기본으로 쓰는 Agile 방식, 즉 SHA-512 키 유도와 AES-256 패키지 암호화를 기록합니다. 읽을 때는 CanReadEncrypted로 파일이 Compound File 컨테이너인지 검사한 뒤 분기합니다. OpenEncrypted는 표준과 Agile 패키지를 모두 복호화하고 평문 파일은 Open으로 폴백하며, 비밀번호가 틀리거나 컨테이너가 다른 방식을 쓰면 EXlsxEncryptionNotImplemented를 던집니다. 레거시 .xls 파일도 Compound File이므로 비밀번호를 곁들여 TXLSWorkbook.Open으로 갑니다. 위에서 기술한 모드 처리와 재키 루프는 이 호출들 아래에 깔려 있습니다. 비밀번호와 파일 이름만 주면 엔진이 여러분을 대신해 명세에 맞춥니다

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('quarterly.xlsx');
    Book.SaveAsEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
    // 소비자 쪽에서 다시 엽니다
    Book.OpenEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
  finally
    Book.Free;
  end;
end;

보안 적용된 최종 파일 레이아웃 구성, EncryptionInfo 명세 구성, 비밀번호 검증기 블록 상세 스펙은 AES 보안 XLSX 파일 출력 가이드에서 세밀하게 살펴볼 수 있습니다. 이와는 별개로 시트 단위 쓰기 잠금 설정이나 인쇄 페이지 레이아웃과의 호환성 조율 방법은 보호 설정, 페이지 인쇄 설정 안내서를 참고하십시오. 이 사양들은 본 문서에서 고친 암호화 복호화 엔진을 기반으로 실행되며, 이 모든 기술 사양들은 Delphi 및 C++Builder용 기본 스프레드시트 코어인 HotXLS Delphi Component에 일체형으로 수록되어 함께 배포됩니다