통합 문서를 작성해 비밀번호로 암호화한 뒤 동료에게 전달했고 동료가 엑셀로 이 파일을 열었습니다. Excel이 비밀번호 입력을 요구하고 동료가 비밀번호를 정확히 기입하자 정상 수락되었습니다. 여기까지만 보면 암호화 처리가 문제없이 완벽해 보입니다. 그런데 돌연 Excel 창에 파일이 손상되어 열 수 없다는 에러 대화 상자가 나타나거나, 시트가 열리기는 했는데 모든 셀에 의미를 알 수 없는 깨진 외계어 데이터만 가득 차 있습니다. 비밀번호가 맞았는데도 파일이 파손된 상태인 것입니다. 이는 오피스 파일 암호화 과정에서 가장 개발자를 당혹스럽게 만드는 오류 유형입니다. 왜냐하면 비밀번호의 일치 여부를 판별해 주는 암호 영역과 실제 시트 본문 데이터를 담아 보호하는 암호 영역이 서로 다른 두 개의 메커니즘으로 이원화되어 작동하므로, 한쪽을 맞게 처리했더라도 다른 한쪽의 호환성이 깨지면 이와 같은 현상이 초래되기 때문입니다
본 문서에서 설명하는 두 가지 버그 역시 정확히 이러한 형태였습니다. 두 사례 모두 비밀번호 검증기(verifier) 확인 단계는 무사 통과했으나 본문 복호화 단계에서 오류를 일으켰는데, 이는 개발자가 존재하지도 않는 키 유도 알고리즘 결함을 쫓아 헛물켜게 만듭니다. 진짜 오류는 그 하위 파이프라인 단계인 패키지 바이트 암호 변환 메커니즘에 숨어 있었습니다. 하나는 AES 경로에서, 다른 하나는 RC4 경로에서 발생한 개별적이고 무관한 버그이지만, 디버깅을 어렵게 만드는 현상은 같으므로 왜 이 '절반만 맞은' 결과가 디버깅하기 까다로운지 원인을 분석해 보는 것은 큰 가치가 있습니다
비밀번호 검증 성공이 본문 데이터의 안전을 보증하지 못하는 이유
최신 엑셀 포맷인 XLSX가 사용하는 암호화 표준 규격은 ECMA-376 표준 암호화(Standard Encryption)로, 파일 내부에 암호화된 두 개의 데이터 저장소를 병렬로 보관합니다. 하나는 비밀번호에서 유도된 임시 키로 암호화되며 난수(random value) 값과 해당 난수의 해시 값을 저장하는 'EncryptionVerifier(비밀번호 검증기)' 블록입니다. 다른 하나는 동일한 키로 파일 전체 zip 압축 컨테이너 데이터를 고스란히 암호화해 보관하는 'EncryptedPackage(암호화된 패키지)' 본문입니다. 검증기 블록을 별도로 두는 이유는, 메가바이트 크기의 무거운 본문 데이터를 일일이 해독해 보기 전에 먼저 비밀번호의 일치 여부를 고속 검사해 겉에서 걸러내기 위함입니다. 검증기를 복호화하고 난수 해시를 계산해 기록된 해시 값과 같으면 올바른 비밀번호로 즉각 판정하는 원리입니다
여기에 함정이 존재합니다. 검증기 영역과 패키지 본문 영역은 서로 완전히 독립된 버퍼 환경에서 개별 API 호출을 통해 암호화가 진행됩니다. 따라서 비밀번호에서 키를 유도하는 연산이 완벽하다면 본문 데이터가 완전히 손상되어 깨져 있더라도 검증기 복호화는 늘 정상으로 통과하게 됩니다. 즉, 키 유도는 맞았으나 본문 인코딩 과정에 오류가 있으면 엑셀 프로그램은 비밀번호 검증에 합격하고 본문 구문 분석에서 실패를 내뱉습니다. 결과적으로 '비밀번호는 맞는데 파일이 깨진' 상태가 되며, 개발자는 무죄인 키 유도 로직 소스만 쥐 잡듯 뒤지며 시간만 낭비하게 됩니다. 구형 RC4 암호화 규격도 이와 동일한 구조로 가동됩니다. 검증기 해시가 우선 체크되므로, 본문 데이터 정렬이 어긋나더라도 검증 절차 자체는 멀쩡하게 통과하게 됩니다
첫 번째 오류: CBC가 아닌 ECB 방식의 AES 암호화 오판
암호화 규격서 [MS-OFFCRYPTO] §2.3.4.15에서는 표준 암호화 진행 시 본문 패키지를 전자 코드북(ECB) 방식의 AES 알고리즘으로 암호화하도록 명시하고 있습니다. 즉, 패딩 처리된 본문 파일 데이터를 16바이트 크기 블록으로 쪼갠 뒤 각 블록을 동일한 키로 개별 독립 암호화해야 합니다. 블록 간에 연쇄 고리를 거는 체이닝(chaining)이나 복호화 시작점인 초기화 벡터(IV)는 두지 않습니다. 암호학적으로 취약해 오늘날 보안 설계에서는 가급적 피하는 ECB 방식을 규격 표준으로 지정한 점이 독특하지만, 타 프로그램과의 상호 호환성을 확보해야 하는 개발 단계에서 표준의 타당성을 논쟁할 여유는 없습니다. Excel 프로그램이 본문을 무조건 ECB 방식으로 간주해 복호화하므로 생성하는 엔진도 똑같이 ECB 모드로 암호화해 제공해야 합니다
이전의 결함은 본문 데이터를 0 바이트로 가득 채운 초기화 벡터(IV)를 사용하여 CBC(Cipher Block Chaining) 모드로 인코딩한 것이 원인이었습니다. 이 방식이 왜 '절반만 맞게' 오작동하는지 분석해 보면 오류의 원인이 명확해집니다. CBC 복호화 구조상 맨 처음 1번째 블록은 복호화 착수 전에 초기화 벡터(IV)와 XOR 연산을 거칩니다. 이때 초기화 벡터 값이 온통 0이면 XOR을 거쳐도 데이터가 그대로 보존되므로, 0-IV 기반의 CBC 1번째 블록 복호화 결과물은 공교롭게도 ECB 방식으로 복호화한 것과 완벽히 일치하게 됩니다. 그러나 2번째 블록부터는 이전 블록의 복호화 결과를 다음 블록 연산에 엮어서 풀어나가는 체이닝 로직이 가동되므로, 2번째 블록 이후의 모든 데이터가 ECB 기반의 Excel 복호화 연산 결과와 완전히 어긋나 깨지게 됩니다
여기에 파일 물리 구조를 결합해 보십시오. XLSX 파일 규격상 시작 위치에 리틀엔디안 형식의 8바이트 파일 길이 헤더가 배치되므로, 엑셀이 가장 먼저 대조하는 선두 데이터 영역은 1~2번째 블록 영역에 한정됩니다. 1번째 블록이 우연히 일치했기 때문에 맨 앞부분의 크기 정합성 유효 검사는 가볍게 패스하지만, 그 이후의 나머지 모든 셀 데이터들은 외계어 같은 가비지 데이터로 변조되어 열리는 것입니다. 원인을 파악했다면 대처는 간단합니다. 체이닝을 중단하고 16바이트 단위를 개별 ECB 방식으로 순수 암호화하는 것입니다. 교정된 엔진 내에서 XlsEncryptStdPackage 루프는 버퍼 데이터를 16바이트씩 훑으며 각각의 조각에 대해 검증기 검사 시와 동일한 기본 함수인 AESEncryptECB128Block을 호출합니다. 소스 코드 주석에도 이 문제를 명확히 기록해 두었습니다. 즉, 0-IV CBC는 오직 1번째 블록에서만 ECB와 결과가 같으므로 나머지 버퍼 데이터를 위해 반드시 정형화된 ECB 함수를 써서 복호화 노이즈를 예방해야 합니다
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create(nil);
try
Book.Open('report.xlsx');
// SaveAsEncrypted serializes the workbook, then runs the
// ECMA-376 Standard Encryption pipeline: AES-128 ECB over the
// package per [MS-OFFCRYPTO] 2.3.4.15. Returns 1 on success.
if Book.SaveAsEncrypted('report_secure.xlsx', 'S3cret!') <> 1 then
raise Exception.Create('Encryption failed');
finally
Book.Free;
end;
end;
두 번째 오류: RC4 재키(re-key) 위치 편차 오류
구형 .xls 포맷용 RC4 CryptoAPI 암호화 방식은 아예 처리 메커니즘이 다릅니다. [MS-OFFCRYPTO] §2.3.6 명세에서는 스트림 데이터를 1024바이트 블록 크기로 등분하고, 블록이 넘어갈 때마다(블록 0, 1, 2...) 키를 매번 갱신(re-keyed)하도록 지정하고 있습니다. 그리고 하나의 1024바이트 블록 내에서는 스트림 암호 키 바이트들을 연속적으로 계속 소비하며 가동해야 합니다. 즉, 두 가지 연산 규칙이 엄격히 준수되어야 합니다. 블록 경계를 지나갈 때 정확히 키를 갱신해야 하며, 하나의 블록 내에서는 끊김이나 공백 없이 스트림 키 바이트를 차례대로 가져다 써야 한다는 점입니다. RC4는 스트림 암호(stream cipher)이므로, 생성되는 스트림 키 바이트들은 고유한 선형 순서를 가집니다. 즉, n번째로 가져오는 암호 바이트의 값은 그보다 앞서 몇 개의 바이트를 뽑아 썼는지에 의해 결정됩니다. 복호화 역시 원래의 순차적 바이트 흐름에 대조해 XOR 연산으로 풀어내야 하므로, 기입하는 엔진과 해독하는 엑셀 프로그램의 바이트 읽기 오프셋 위치가 1바이트의 오차도 없이 완벽히 일치해야 합니다
이것이 암호 해독을 어렵게 만드는 점입니다. 스트림 암호에는 위치 정렬 복구(resynchronization) 기능이 없습니다. 도중에 단 1바이트라도 건너뛰거나 엇나가면 그 이후의 모든 복호화 데이터가 엉뚱한 암호 바이트와 XOR 연산 처리되며, 이 오차는 복구되지 않고 블록 전체로 눈사태처럼 파급되어 파일 전체를 가비지로 파괴합니다. 이전의 결함이 정확히 이 문제를 안고 있었습니다. 내부 블록 카운터 변수가 감시용 초기 값인 -1 상태에서 시작되었는데, 셀 건너뛰기(skip) 함수가 이 카운터 값을 현재 블록 위치로 착각해 오작동했던 것입니다. 이 초기 값 상태에서 함수는 작동하지 말았어야 할 1024바이트 분량의 암호 바이트 연산을 한 바퀴 강제 실행해 버렸고 내부 카운터를 엉뚱한 음수 값으로 떨어뜨렸습니다. 결과적으로 복호화 오프셋 위상이 정상 위치보다 정확히 1블록 통째로 어긋나 엇갈리게 되었습니다. 이 단계보다 먼저 체크를 마친 비밀번호 검증기는 이미 통과된 상태였기 때문에, 패스워드는 정상 수락되지만 시트의 셀 내용은 온통 깨진 쓰레기 데이터로 가득 찬 상태가 되었던 것입니다
교정 완료된 복호화 로직은 TXLSDecrypterRC4 클래스에 통합되어 탑재되었습니다. Skip 및 Decrypt 함수는 단일 루프를 완벽히 공유합니다. 즉, 현재 복호화 오프셋 위치가 새로운 1024바이트 단위를 넘어설 때만(오프셋 위치를 REKEY_BLOCK_SIZE(1024)로 나눈 블록 인덱스 기준) 키 갱신(re-key)을 유연하게 처리하며, 블록 내부에서는 오직 잔여 바이트 영역만큼만 엄격히 계산해 동작합니다. 키 갱신에 쓰이는 MakeKey 함수는 항상 신선하게 보존되는 실시간 블록 인덱스를 인자로 전달받으며, 연산 완료된 정확한 바이트 수만큼 복호화 오프셋 위치가 진행되도록 교정되어 Skip 및 Decrypt 처리가 파일 생성 측과 완전한 동기화를 유지합니다. 스트림 암호 연산에서는 단 1바이트의 처리 누락도 미세한 오차가 아닌, 그 하위의 모든 데이터를 영구 소실시키는 전면 파손으로 이어진다는 교훈을 담고 있습니다
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create(nil);
try
// CanReadEncrypted checks the Compound File (OLE2) signature so
// you can branch before attempting a normal Open. OpenEncrypted
// routes plain files to Open and handles the encrypted container.
if Book.CanReadEncrypted('legacy.xls') then
Book.OpenEncrypted('legacy.xls', 'S3cret!')
else
Book.Open('legacy.xls');
// read cells here
finally
Book.Free;
end;
end;
불변의 규격 표준과의 호환은 바이트 단위의 완벽한 일치입니다
두 버그 모두 동일한 근본적인 진리로 귀결되며, 이는 개발 방향성을 정립해 주므로 상기해 둘 가치가 있습니다. 생성할 파일의 수신처가 개발자가 임의로 수정할 수 없는 고정된 상용 프로그램(Excel)인 환경에서, 암호 모드 지정이나 키 갱신 빈도는 개발자의 주관에 따라 단순화하거나 최적화할 수 있는 구현 세부 사양이 아닙니다. 이는 파일 포맷 호환성을 규정짓는 핵심 약속입니다. 개발자가 선호하든 그렇지 않든 Excel 프로그램은 언제나 ECB 모드로 복호화를 시도하며, 1024바이트 단위로 키 갱신을 실행할 것입니다. 개발자에게 주어진 유일한 임무는 이 엄격한 검증식에 대조했을 때 정상 해독되는 정교한 바이트 흐름을 올바르게 기입해 주는 것뿐입니다. 더 세련된 암호 모드 사용, 아무런 문제가 없을 것 같은 초기화 벡터의 임의 지정, 자연스럽게 시작하도록 임의 설정한 임시 변수 카운터 등, 표준 규격과 단 1바이트라도 엇나가는 변형은 그 즉시 호환성을 파괴하는 버그가 됩니다. 고정된 표준 규격과의 연계 개발은 적당히 비슷해서는 안 되며, 바이트 단위까지 한 치의 오차도 없이 정교해야만 작동합니다
비밀번호 검증 성공 여부만을 관찰하는 테스트가 무의미한 이유가 여기에 있습니다. 키 유도 자체는 정상 구동 중임을 알리는 1차 신호에 불과하며 이것만으로는 본문 데이터 무결성을 보증할 수 없습니다. 암호화된 파일을 열어 비밀번호 다이얼로그 수락 단계까지만 체크하는 검증 로직은 본문 데이터가 깨진 상태이더라도 성공으로 합격을 주는 오진을 하게 됩니다. 완벽한 연계 테스트를 위해서는 암호화 후 복호화를 거쳐 추출된 최종 바이트를 원본 문서와 비교 대조하거나, 암호 파일 기입 후 다시 코드를 통해 읽어 들여 특정 셀 값을 정상 수집할 수 있는지까지 끝단 검증을 수행해야 합니다. 검증기 블록은 비밀번호를 증명할 뿐, 암호화 신뢰성을 증명하는 것은 실제 본문 데이터 영역입니다
비밀번호로 보호된 Excel 파일을 읽고 쓰는 공식 구현법
제공되는 API 규격은 매우 간결합니다. 비밀번호 설정된 최신형 XLSX 통합 문서를 쓰려면, 일반 TXLSXWorkbook 객체를 채우거나 로드한 뒤 파일명과 비밀번호를 인수로 전달하여 SaveAsEncrypted 함수를 호출하면 됩니다. 이 함수는 시트를 직렬화하고 ECB 암호 교정이 완료된 표준 암호화 파이프라인을 통과시켜 안전한 보안 파일을 출력해 내며 성공 시 1을 리턴합니다. 판독할 때는 CanReadEncrypted API를 호출해 파일이 OLE 복합 암호 컨테이너인지 간편 검사한 후 코드를 분기 처리하면 됩니다. OpenEncrypted를 가동하면 암호화된 파일 복호화를 시도하며 일반 평문 문서의 경우 기존의 Open 경로로 fall-back 처리해 주며, 물론 Open 함수에 비밀번호 매개변수를 직접 입력해 열 수도 있습니다. 상세한 ECB 모드 처리와 1024바이트 키 갱신 루직은 이 함수들 밑단에서 자동으로 조율되어 작동하므로, 비밀번호와 파일명만 기재하면 복잡한 표준 규격 연산은 라이브러리가 알아서 처리합니다
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create(nil);
try
Book.Open('quarterly.xlsx');
Book.SaveAsEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
// Reopen on the consumer side
Book.OpenEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
finally
Book.Free;
end;
end;
보안 적용된 최종 파일 레이아웃 구성, EncryptionInfo 명세 구성, 비밀번호 검증기 블록 상세 스펙은 AES 보안 XLSX 파일 출력 가이드에서 세밀하게 살펴볼 수 있습니다. 이와는 별개로 시트 단위 쓰기 잠금 설정이나 인쇄 페이지 레이아웃과의 호환성 조율 방법은 보호 설정, 페이지 인쇄 설정 안내서를 참고하십시오. 이 사양들은 본 문서에서 고친 암호화 복호화 엔진을 기반으로 실행되며, 이 모든 기술 사양들은 Delphi 및 C++Builder용 기본 스프레드시트 코어인 HotXLS Component에 일체형으로 수록되어 함께 배포됩니다