Bài viết kỹ thuật

Tại sao Excel từ chối sổ làm việc đã mã hóa của bạn: ECB và RC4

Bạn tạo một sổ làm việc, mã hóa nó bằng mật khẩu, gửi tệp cho đồng nghiệp và họ mở nó bằng Excel. Excel yêu cầu nhập mật khẩu. Đồng nghiệp của bạn nhập mật khẩu và Excel chấp nhận nó. Cho đến nay, việc mã hóa có vẻ hoàn toàn chính xác. Tuy nhiên, sau đó Excel lại hiển thị một hộp thoại thông báo tệp tin bị hỏng và không thể mở được, hoặc mở ra một trang tính chứa toàn các ô ký tự rác vô nghĩa. Mật khẩu nhập đúng, nhưng tệp tin vẫn bị hỏng. Đây là chế độ lỗi gây bối rối nhất trong cơ chế mã hóa của Office, bởi vì phần kiểm tra mật khẩu đúng và phần lưu giữ dữ liệu của bạn được bảo vệ bởi hai hoạt động khác nhau, và việc thực hiện đúng một phần không đảm bảo phần còn lại sẽ hoạt động bình thường

Cả hai lỗi được mô tả ở đây đều có chính xác hình thái này. Trong mỗi trường hợp, phần xác thực (verifier) đều vượt qua nhưng phần thân dữ liệu (body) thì không, điều này khiến bạn mất thời gian tìm kiếm lỗi ở đường dẫn mật khẩu hoặc thuật toán dẫn xuất khóa vốn không hề bị hỏng. Lỗi thực tế nằm ở hạ nguồn, trong cách các byte của gói dữ liệu được biến đổi. Hai lỗi này độc lập với nhau, một lỗi nằm trên đường dẫn AES và một lỗi nằm trên đường dẫn RC4, nhưng chúng có chung một vấn đề về chẩn đoán lỗi, do đó rất đáng để tìm hiểu tại sao một kết quả đúng một nửa lại là thứ khó chẩn đoán nhất

Tại sao việc mật khẩu đúng không chứng minh được phần thân dữ liệu an toàn

Định dạng mà tệp XLSX mã hóa hiện đại sử dụng là Mã hóa tiêu chuẩn ECMA-376, và nó lưu trữ hai phần được mã hóa nằm song song với nhau. Một phần là EncryptionVerifier: một khối nhỏ giữ một giá trị ngẫu nhiên và mã băm của giá trị đó, được mã hóa bằng khóa dẫn xuất từ mật khẩu. Phần còn lại là EncryptedPackage: toàn bộ thùng chứa ZIP của sổ làm việc, được mã hóa bằng chính khóa đó. Bộ xác thực verifier tồn tại để trình đọc có thể xác nhận mật khẩu nhanh chóng trước khi tốn tài nguyên giải mã hàng megabyte dữ liệu của phần thân. Giải mã verifier, băm giá trị ngẫu nhiên, so sánh nó với mã băm đã lưu trữ, và nếu chúng trùng khớp thì mật khẩu là chính xác

Cái bẫy ở đây là verifier và gói dữ liệu được mã hóa bằng các lượt gọi riêng biệt trên các bộ đệm khác nhau. Một khóa được dẫn xuất chính xác sẽ giải mã verifier thành công bất kể điều gì xảy ra với gói dữ liệu sau đó. Vì vậy, nếu việc dẫn xuất khóa của bạn đúng nhưng việc biến đổi gói dữ liệu bị sai, Excel sẽ xác nhận mật khẩu thành công từ verifier và sau đó báo lỗi ở phần thân dữ liệu. Triệu chứng hiển thị là "mật khẩu đúng, tệp hỏng", điều này định hướng cuộc điều tra vào đường dẫn mật khẩu, vốn là phần duy nhất không hề bị lỗi. Sự phân tách tương tự cũng chi phối trường hợp RC4 cũ: mã băm của verifier được kiểm tra trước, và một phần thân bị lệch pha vẫn không ảnh hưởng đến kết quả của phép kiểm tra đó

Lỗi thứ nhất: AES ở chế độ ECB, chứ không phải CBC

Mục [MS-OFFCRYPTO] §2.3.4.15 quy định rằng Mã hóa tiêu chuẩn thực hiện mã hóa gói dữ liệu bằng thuật toán AES ở chế độ sổ mã điện tử (Electronic Codebook - ECB). Mỗi khối 16-byte của gói dữ liệu đã chèn đệm được mã hóa độc lập bằng cùng một khóa. Không có sự liên kết liên hoàn giữa các khối và không có vector khởi tạo (IV). Đây là một lựa chọn bất thường theo các tiêu chuẩn hiện đại (nơi chế độ ECB thường bị tránh dùng), nhưng việc tương thích hệ thống không phải là chỗ để nghi ngờ đặc tả kỹ thuật. Excel giải mã gói dữ liệu ở chế độ ECB, do đó trình tạo tệp bắt buộc phải mã hóa nó ở chế độ ECB để cả hai đồng nhất

Lỗi xảy ra là do gói dữ liệu đã được mã hóa bằng AES ở chế độ CBC sử dụng một vector khởi tạo toàn ký tự không (all-zero IV). Đây là lý do tại sao nó hoạt động được một phần, và trạng thái "gần đúng" này là thứ tệ nhất để xử lý. Trong chế độ CBC, khối văn bản thuần đầu tiên được thực hiện phép toán XOR với IV trước khi mã hóa. Khi IV toàn số 0, phép XOR này không làm thay đổi gì, do đó khối đầu tiên của CBC-với-IV-bằng-0 tạo ra bản mã chính xác giống hệt như chế độ ECB. Tuy nhiên, từ khối thứ hai trở đi, chế độ CBC đưa khối bản mã trước đó vào khối tiếp theo, vì vậy mọi khối sau khối đầu tiên đều lệch hướng so với chế độ ECB

Bây giờ hãy đối chiếu điều đó lên cấu trúc tệp. Bố cục của gói dữ liệu đặt một tiền tố độ dài dạng little-endian 8 byte ngay ở đầu, do đó các phần của tệp tin được Excel kiểm tra sớm nhất nằm ở một hoặc hai khối đầu tiên. Việc khối đầu tiên trùng khớp ngẫu nhiên có nghĩa là quá trình xác thực sớm nhất vượt qua trong khi mọi khối tiếp theo bị giải mã thành dữ liệu rác vô nghĩa. Giải pháp khắc phục rất rõ ràng một khi xác định được chế độ mã hóa: mã hóa từng khối 16-byte bằng ECB và dừng việc liên kết chuỗi. Trong trình xử lý, hàm XlsEncryptStdPackage duyệt qua bộ đệm đã chèn đệm theo các bước 16-byte và gọi hàm AESEncryptECB128Block trên từng khối, đây là cùng một hàm nguyên thủy đã được sử dụng cho các khối verifier. Mã nguồn chứa một chú thích tại vòng lặp nêu rõ quy tắc: CBC với IV bằng 0 chỉ trùng khớp với ECB ở khối đầu tiên, do đó phần còn lại của gói dữ liệu sẽ bị giải mã thành rác và Excel sẽ từ chối tệp

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;

Lỗi thứ hai: khóa lặp lại của RC4 bị lệch pha

Đường dẫn tệp .xls cũ sử dụng cơ chế RC4 CryptoAPI, và quy tắc của nó có bản chất khác biệt. Mục [MS-OFFCRYPTO] §2.3.6 quy định rằng mật mã được thiết lập lại khóa (re-keyed) tại mỗi ranh giới khối 1024-byte. Luồng dữ liệu được chia thành các khối 1024 byte, một khóa RC4 mới được dẫn xuất cho khối số 0, 1, 2, v.v., và bên trong mỗi khối, luồng khóa (keystream) được tiêu thụ liên tục từ byte này sang byte khác. Hai điều kiện bất biến phải được duy trì đồng thời: thiết lập lại khóa tại mỗi ranh giới khối, và tiêu thụ luồng khóa không có khoảng trống bên trong một khối. RC4 là một thuật toán mã hóa luồng (stream cipher), do đó luồng khóa của nó là một chuỗi tuần tự duy nhất; byte thứ n bạn rút ra được xác định bởi việc bạn đã rút ra bao nhiêu byte trước đó. Giải mã là cùng một phép toán XOR đối chiếu với cùng một chuỗi tuần tự, nghĩa là trình tạo và trình đọc phải rút ra chính xác cùng các byte tại cùng các vị trí

Đó chính là toàn bộ khó khăn. Một thuật toán mã hóa luồng không có khả năng tự đồng bộ lại. Nếu bạn bỏ phí một byte của luồng khóa, mọi byte sau đó sẽ bị XOR với byte luồng khóa sai, và lỗi này không bao giờ tự sửa đổi; nó lan truyền (cascade) đến tận cuối khối và một khi vị trí chạy bị sai, nó ảnh hưởng tới mọi khối phía sau. Lỗi ở đây đã xảy ra chính xác như vậy. Bộ đếm khối bắt đầu từ một giá trị canh gác (sentinel) là âm một, và quy trình bỏ qua (skip routine) giả định rằng bộ đếm đã khớp với khối hiện tại. Bắt đầu từ giá trị canh gác đó, nó thiết lập lại khóa và chạy qua một khối luồng khóa 1024-byte đầy đủ vốn không nên bị tiêu thụ, và trong quá trình đó làm cho số đếm còn lại bị âm. Từ thời điểm đó, trình giải mã bị lệch pha hoàn toàn một khối. Bộ xác thực verifier (được kiểm tra trước toàn bộ tiến trình này) vẫn vượt qua, khiến mật khẩu hiển thị đúng trong khi mọi ô dữ liệu đều trở thành dữ liệu rác

Logic đã được sửa đổi nằm trong TXLSDecrypterRC4. Cả hai hàm SkipDecrypt chia sẻ chung một vòng lặp: chỉ thiết lập lại khóa khi vị trí chạy đi qua một khối mới, trong đó chỉ số khối là vị trí chia cho REKEY_BLOCK_SIZE (1024), sau đó tiêu thụ tối đa phần còn lại của khối hiện tại và không vượt quá. Hàm MakeKey được gọi với chỉ số khối thực tế, không bao giờ dùng chỉ số cũ hoặc chỉ số canh gác, và vị trí dịch chuyển chính xác theo số byte đã được xử lý để SkipDecrypt luôn giữ đồng bộ pha với trình tạo. Bài học rút ra nằm ở đơn vị nhỏ nhất: một byte bị lãng phí không phải là một lỗi nhỏ trong mã hóa luồng, mà là sự hỏng hóc hoàn toàn của mọi thứ phía sau

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;

Tương thích với một đặc tả cố định là sự trùng khớp đến từng byte

Cả hai lỗi đều bắt nguồn từ cùng một nguyên lý cốt lõi, và điều này rất đáng để nêu riêng vì nó thay đổi cách bạn đánh giá các quyết định thiết kế. Khi trình đọc đầu ra của bạn là một chương trình bên ngoài cố định mà bạn không thể thay đổi, chế độ mật mã và tần suất thiết lập lại khóa không phải là các chi tiết triển khai để bạn tối ưu hóa hoặc đơn giản hóa. Chúng là một phần của giao ước truyền tải. Excel giải mã bằng chế độ ECB và thiết lập lại khóa trên ranh giới 1024-byte bất kể các lựa chọn đó có vừa ý bạn hay không, và nhiệm vụ duy nhất của bạn là tạo ra các byte có thể giải mã về dữ liệu gốc dưới quy trình chính xác đó. Một chế độ hiện đại hơn, một vector IV có vẻ vô hại, một bộ đếm bắt đầu từ nơi bạn thấy tự nhiên; bất kỳ điều nào trong số này đều là một lỗi ngay khi nó lệch khỏi những gì trình đọc mong đợi. Sự tương thích với một đặc tả cố định không có chỗ cho sự gần đúng. Nó phải chính xác đến từng byte, nếu không nó sẽ bị hỏng

Đây cũng là lý do tại sao việc chỉ kiểm tra verifier là một bài kiểm thử sơ sài và không đầy đủ. Nó chỉ cho bạn biết việc dẫn xuất khóa hoạt động bình thường, đây là điều kiện cần nhưng chưa đủ. Một bài kiểm thử chỉ mở một tệp được mã hóa và xác nhận mật khẩu vượt qua sẽ báo cáo thành công trong khi phần thân dữ liệu thực tế không thể đọc được. Một bài kiểm thử thực sự phải thực hiện giải mã gói dữ liệu và so sánh các byte khôi phục được với dữ liệu đầu vào ban đầu, hoặc chạy một chu trình mã hóa và giải mã đầy đủ cho một sổ làm việc rồi đọc lại các ô dữ liệu. Verifier chứng minh mật khẩu đúng; chỉ có phần thân dữ liệu mới chứng minh được việc mã hóa thành công

Phương thức được hỗ trợ để đọc và ghi các sổ làm việc được bảo vệ

Các API công khai rất tinh gọn. Để ghi một sổ làm việc hiện đại được bảo vệ bằng mật khẩu, hãy điền dữ liệu hoặc mở một đối tượng TXLSXWorkbook và gọi hàm SaveAsEncrypted với tên tệp và mật khẩu; nó sẽ tuần tự hóa sổ làm việc và chạy đường ống Mã hóa tiêu chuẩn mà bản sửa lỗi đầu tiên đã giải quyết, trả về 1 khi thành công. Để đọc, hãy gọi CanReadEncrypted để kiểm tra xem tệp có phải là một thùng chứa Compound File mã hóa hay không, sau đó phân nhánh: OpenEncrypted xử lý đường dẫn mã hóa và tự động chuyển về Open đối với các tệp tin thông thường, và lệnh gọi Open kèm mật khẩu có thể được sử dụng trực tiếp. Việc xử lý chế độ và vòng lặp thiết lập lại khóa được mô tả ở trên diễn ra ngầm bên dưới các lệnh gọi này; bạn chỉ cần cung cấp mật khẩu cùng tên tệp và trình xử lý sẽ đáp ứng đúng đặc tả kỹ thuật thay cho bạn

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;

Cấu trúc của đầu ra được bảo vệ, luồng EncryptionInfo, các khối verifier và bố cục của gói dữ liệu được trình bày chi tiết trong bài viết tìm hiểu về đầu ra XLSX được bảo vệ bằng AES. Đối với vấn đề riêng biệt về khóa cấp trang tính và cách tính năng bảo vệ tương tác với thiết lập trang và in ấn, hãy xem bài viết về bảo vệ, thiết lập trang và in ấn. Cả hai đều được xây dựng trên nền tảng cơ chế mã hóa được mô tả ở đây, đi kèm như một phần của thành phần bảng tính HotXLS dành cho Delphi và C++Builder bên cạnh các API đọc, ghi và kết xuất dữ liệu được trình bày ở các bài viết khác trên blog này