Skip to content

Commit ec265db

Browse files
Merge commit from fork
1 parent 57fad82 commit ec265db

4 files changed

Lines changed: 148 additions & 35 deletions

File tree

‎src/aes.rs‎

Lines changed: 52 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
77
use crate::aes_ctr::AesCipher;
88
use crate::format::aes::AesMode;
9-
use crate::result::ZipResult;
9+
use crate::result::{ZipResult, invalid};
1010
use crate::{aes_ctr, result::ZipError};
1111
use constant_time_eq::constant_time_eq;
1212
use hmac::{KeyInit, Mac, SimpleHmacReset};
@@ -140,15 +140,18 @@ pub struct AesReader<R> {
140140
}
141141

142142
impl<R: Read> AesReader<R> {
143-
pub const fn new(reader: R, aes_mode: AesMode, compressed_size: u64) -> AesReader<R> {
144-
let data_length = compressed_size
145-
- (PWD_VERIFY_LENGTH + AUTH_CODE_LENGTH + aes_mode.salt_length()) as u64;
146-
147-
Self {
143+
pub fn new(reader: R, aes_mode: AesMode, compressed_size: u64) -> ZipResult<AesReader<R>> {
144+
let metadata_len = (PWD_VERIFY_LENGTH + AUTH_CODE_LENGTH + aes_mode.salt_length()) as u64;
145+
// we accept 0 size data
146+
if compressed_size < metadata_len {
147+
return Err(invalid!("Compressed size of AES data is invalid"));
148+
}
149+
let data_length = compressed_size - metadata_len;
150+
Ok(Self {
148151
reader,
149152
aes_mode,
150153
data_length,
151-
}
154+
})
152155
}
153156

154157
/// Read the AES header bytes and validate the password.
@@ -192,6 +195,7 @@ impl<R: Read> AesReader<R> {
192195

193196
Ok(AesReaderValid {
194197
reader: self.reader,
198+
data_length: self.data_length,
195199
data_remaining: self.data_length,
196200
cipher,
197201
hmac,
@@ -227,6 +231,7 @@ impl<R: Read> AesReader<R> {
227231
#[derive(Debug)]
228232
pub struct AesReaderValid<R: Read> {
229233
reader: R,
234+
data_length: u64,
230235
data_remaining: u64,
231236
cipher: Cipher,
232237
hmac: SimpleHmacReset<Sha1>,
@@ -245,6 +250,10 @@ impl<R: Read> Read for AesReaderValid<R> {
245250
/// practically unusable, since its position after the error is not known.
246251
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
247252
if self.data_remaining == 0 {
253+
// handle case were payload is empty
254+
if self.data_length == 0 {
255+
self.verify_hmac()?;
256+
}
248257
return Ok(0);
249258
}
250259

@@ -262,38 +271,48 @@ impl<R: Read> Read for AesReaderValid<R> {
262271

263272
// if there is no data left to read, check the integrity of the data
264273
if self.data_remaining == 0 {
265-
debug_assert!(
266-
!self.finalized,
267-
"Tried to use an already finalized HMAC. This is a bug!"
268-
);
269-
if self.finalized {
270-
return Err(Error::new(
271-
ErrorKind::InvalidData,
272-
"Tried to use an already finalized HMAC",
273-
));
274-
}
275-
self.finalized = true;
276-
277-
// Zip uses HMAC-Sha1-80, which only uses the first half of the hash
278-
// see https://www.winzip.com/win/en/aes_info.html#auth-faq
279-
let mut read_auth_code = [0; AUTH_CODE_LENGTH];
280-
self.reader.read_exact(&mut read_auth_code)?;
281-
let computed_auth_code = &self.hmac.finalize_reset().into_bytes()[0..AUTH_CODE_LENGTH];
282-
283-
// use constant time comparison to mitigate timing attacks
284-
if !constant_time_eq(computed_auth_code, &read_auth_code) {
285-
return Err(Error::new(
286-
ErrorKind::InvalidData,
287-
"Invalid authentication code, this could be due to an invalid password or errors in the data",
288-
));
289-
}
274+
self.verify_hmac()?;
275+
} else if read == 0 && self.data_remaining > 0 {
276+
return Err(Error::new(
277+
ErrorKind::UnexpectedEof,
278+
"Read 0 bytes of AES data but still contains remaining data",
279+
));
290280
}
291281

292282
Ok(read)
293283
}
294284
}
295285

296286
impl<R: Read> AesReaderValid<R> {
287+
fn verify_hmac(&mut self) -> io::Result<()> {
288+
debug_assert!(
289+
!self.finalized,
290+
"Tried to use an already finalized HMAC. This is a bug!"
291+
);
292+
if self.finalized {
293+
return Err(Error::new(
294+
ErrorKind::InvalidData,
295+
"Tried to use an already finalized HMAC",
296+
));
297+
}
298+
self.finalized = true;
299+
300+
// Zip uses HMAC-Sha1-80, which only uses the first half of the hash
301+
// see https://www.winzip.com/win/en/aes_info.html#auth-faq
302+
let mut read_auth_code = [0; AUTH_CODE_LENGTH];
303+
self.reader.read_exact(&mut read_auth_code)?;
304+
let computed_auth_code = &self.hmac.finalize_reset().into_bytes()[0..AUTH_CODE_LENGTH];
305+
306+
// use constant time comparison to mitigate timing attacks
307+
if !constant_time_eq(computed_auth_code, &read_auth_code) {
308+
return Err(Error::new(
309+
ErrorKind::InvalidData,
310+
"Invalid authentication code, this could be due to an invalid password or errors in the data",
311+
));
312+
}
313+
Ok(())
314+
}
315+
297316
/// Consumes this decoder, returning the underlying reader.
298317
pub fn into_inner(self) -> R {
299318
self.reader
@@ -452,7 +471,7 @@ mod tests {
452471
{
453472
let compressed_length = buf.get_ref().len() as u64;
454473
let mut reader =
455-
AesReader::new(&mut buf, aes_mode, compressed_length).validate(password)?;
474+
AesReader::new(&mut buf, aes_mode, compressed_length)?.validate(password)?;
456475
reader.read_to_end(&mut read_buffer)?;
457476
}
458477

‎src/read/readers.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ pub(crate) fn make_crypto_reader<'a, R: Read + ?Sized>(
215215
}
216216
#[cfg(feature = "aes-crypto")]
217217
(Some(password), Some((aes_mode, _))) => CryptoReader::Aes {
218-
reader: crate::aes::AesReader::new(reader, aes_mode, data.compressed_size)
218+
reader: crate::aes::AesReader::new(reader, aes_mode, data.compressed_size)?
219219
.validate(password)?,
220220
},
221221
(Some(password), None) => {

‎src/read/zip_archive.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ impl<R: Read + Seek> ZipArchive<R> {
248248
None => Ok(None),
249249
Some((aes_mode, _)) => {
250250
let (verification_value, salt) =
251-
crate::aes::AesReader::new(limit_reader, aes_mode, data.compressed_size)
251+
crate::aes::AesReader::new(limit_reader, aes_mode, data.compressed_size)?
252252
.get_verification_value_and_salt()?;
253253
let aes_info = crate::aes::AesInfo {
254254
aes_mode,

‎tests/aes_encryption.rs‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,3 +390,97 @@ fn test_update_aes_version_on_threshold() {
390390
assert_eq!(data[167], 3); // AES Mode
391391
assert_eq!(data[168..170], [0, 0]); // Compression Method
392392
}
393+
394+
const GENUINE: &[u8] = b"balance: 100 USD\n";
395+
const FORGED: &[u8] = b"balance: 999 USD\n";
396+
const AES256_OVERHEAD: usize = 28; // 16-byte salt + 2-byte verifier + 10-byte tag
397+
398+
fn genuine_archive(plaintext: &[u8]) -> Vec<u8> {
399+
use std::io::{Cursor, Write};
400+
use zip::write::SimpleFileOptions;
401+
use zip::{AesMode, CompressionMethod, ZipWriter};
402+
let mut w = ZipWriter::new(Cursor::new(Vec::new()));
403+
let opts = SimpleFileOptions::default()
404+
.compression_method(CompressionMethod::Stored)
405+
.with_aes_encryption_bytes(AesMode::Aes256, PASSWORD);
406+
w.start_file("secret.txt", opts).unwrap();
407+
w.write_all(plaintext).unwrap();
408+
w.finish().unwrap().into_inner()
409+
}
410+
411+
/// What a victim does: open with the correct password and read the entry.
412+
fn victim_reads(archive: &[u8]) -> std::io::Result<Vec<u8>> {
413+
use std::io::{Cursor, Read};
414+
use zip::ZipArchive;
415+
let mut a = ZipArchive::new(Cursor::new(archive.to_vec())).unwrap();
416+
let mut f = a.by_index_decrypt(0, PASSWORD).unwrap();
417+
let mut out = Vec::new();
418+
f.read_to_end(&mut out)?;
419+
Ok(out)
420+
}
421+
422+
fn u16le(b: &[u8], off: usize) -> usize {
423+
u16::from_le_bytes([b[off], b[off + 1]]) as usize
424+
}
425+
426+
/// Payload offset of the single local file header at offset 0.
427+
fn payload_start(a: &[u8]) -> usize {
428+
30 + u16le(a, 26) + u16le(a, 28)
429+
}
430+
431+
/// Control: on a well-formed archive the HMAC does reject forged ciphertext.
432+
#[test]
433+
fn tampering_is_detected_when_the_declared_size_is_honest() {
434+
let mut a = genuine_archive(GENUINE);
435+
let ct = payload_start(&a) + 18;
436+
for i in 0..GENUINE.len() {
437+
a[ct + i] ^= GENUINE[i] ^ FORGED[i]; // AES-CTR is malleable
438+
}
439+
let err = victim_reads(&a).unwrap_err();
440+
assert!(err.to_string().contains("Invalid authentication code"));
441+
}
442+
443+
/// The bypass: chosen plaintext is delivered verbatim, without the password.
444+
#[test]
445+
fn hmac_is_bypassed_when_the_entry_ends_early() {
446+
let genuine = genuine_archive(GENUINE);
447+
let hdr_len = payload_start(&genuine);
448+
let eocd = genuine.len() - 22; // no archive comment yet
449+
let cd_offset = u32::from_le_bytes(genuine[eocd + 16..eocd + 20].try_into().unwrap()) as usize;
450+
451+
// Park a second copy of the entry after the end-of-central-directory record,
452+
// inside the archive comment, so its data ends at true EOF. Header, salt and
453+
// password verifier are copied verbatim — no password knowledge is required.
454+
let mut evil = genuine.clone();
455+
let new_hdr_offset = evil.len() as u32;
456+
evil.extend_from_slice(&genuine[..hdr_len + 18]); // local header + salt + verifier
457+
for i in 0..GENUINE.len() {
458+
// XOR the keystream onto the chosen plaintext; the 10-byte tag is omitted.
459+
evil.push(genuine[hdr_len + 18 + i] ^ GENUINE[i] ^ FORGED[i]);
460+
}
461+
let comment_len = (evil.len() - genuine.len()) as u16;
462+
evil[eocd + 20..eocd + 22].copy_from_slice(&comment_len.to_le_bytes());
463+
464+
// Repoint the central directory at the parked copy and declare more data than
465+
// is present, so `data_remaining` can never reach zero.
466+
let declared = (AES256_OVERHEAD + GENUINE.len() + 64) as u32;
467+
evil[cd_offset + 42..cd_offset + 46].copy_from_slice(&new_hdr_offset.to_le_bytes());
468+
evil[cd_offset + 20..cd_offset + 24].copy_from_slice(&declared.to_le_bytes());
469+
let hdr2 = new_hdr_offset as usize;
470+
evil[hdr2 + 18..hdr2 + 22].copy_from_slice(&declared.to_le_bytes());
471+
472+
let read_result = victim_reads(&evil);
473+
// unauthenticated content was detected
474+
assert!(read_result.is_err());
475+
}
476+
477+
/// Second path: an entry with no payload bytes is never authenticated at all.
478+
#[test]
479+
fn hmac_is_never_checked_for_a_zero_length_entry() {
480+
let mut a = genuine_archive(b"");
481+
let tag = payload_start(&a) + 18;
482+
a[tag] ^= 0xff; // corrupt the 10-byte authentication code
483+
let read_result = victim_reads(&a);
484+
// corrupt authentication code was detected
485+
assert!(read_result.is_err());
486+
}

0 commit comments

Comments
 (0)