CVE-2026-115906 — Vulnerability intelligence
— zip
Published:
- CVSS
- 6.5
- CISA KEV
- Not listed; not proof of no exploitation
Original source text (not a translation)
zip: AES Entry HMAC Authentication Bypass on Premature EOF
### Summary
An AES-encrypted (AE-x) entry's authentication code is verified only when the reader's byte counter lands exactly on zero. If the bounded entry reader reaches the archive's actual EOF before the attacker-controlled, header-declared `compressed_size`, the counter remains positive, the HMAC is never read or compared, and the truncated, unauthenticated plaintext is returned as a clean end of stream. A zero-payload entry also returns before authentication. An attacker who does not know the password can modify an archive so chosen or modified plaintext is accepted with no error.
### Details
[`src/aes.rs:246-292`](https://github.com/zip-rs/zip2/blob/6f09b3064ec87c3a69ea9f3413f9f9638b4aa446/src/aes.rs#L246-L292):
```rust
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.data_remaining == 0 { // @audit insufficient check
return Ok(0);
}
// get the number of bytes to read, compare as u64 to make sure we can read more than
// 2^32 bytes even on 32 bit systems.
let bytes_to_read = self.data_remaining.min(buf.len() as u64) as usize;
let read = self.reader.read(&mut buf[0..bytes_to_read])?;
self.data_remaining -= read as u64;
// Update the hmac with the encrypted data
self.hmac.update(&buf[0..read]);
// decrypt the data
self.cipher.crypt_in_place(&mut buf[0..read]);
// if there is no data left to read, check the integrity of the data
if self.data_remaining == 0 {
debug_assert!(
!self.finalized,
"Tried to use an already finalized HMAC. This is a bug!"
);
if self.finalized {
return Err(Error::new(
ErrorKind::InvalidData,
"Tried to use an already finalized HMAC",
));
}
self.finalized = true;
// Zip uses HMAC-Sha1-80, which only uses the first half of the hash
// see https://www.winzip.com/win/en/aes_info.html#auth-faq
let mut read_auth_code = [0; AUTH_CODE_LENGTH];
self.reader.read_exact(&mut read_auth_code)?;
let computed_auth_code = &self.hmac.finalize_reset().into_bytes()[0..AUTH_CODE_LENGTH];
// use constant time comparison to mitigate timing attacks
if !constant_time_eq(computed_auth_code, &read_auth_code) {
return Err(Error::new(
ErrorKind::InvalidData,
"Invalid authentication code, this could be due to an invalid password or errors in the data",
));
}
}
Ok(read)
}
```
`data_remaining` starts at `compressed_size - (2 + 10 + salt_length)` ([`src/aes.rs:144-145`](https://github.com/zip-rs/zip2/blob/6f09b3064ec87c3a69ea9f3413f9f9638b4aa446/src/aes.rs#L144-L145)), where `compressed_size` comes straight from the attacker-controlled central directory. Nothing cross-checks the declared data span against the archive's structural boundaries: `ZipFileData::find_content()` is just `reader.take(self.compressed_size)` ([`src/types.rs:214-223`](https://github.com/zip-rs/zip2/blob/6f09b3064ec87c3a69ea9f3413f9f9638b4aa446/src/types.rs#L214-L223)), and `make_crypto_reader()` passes the declared value into `AesReader::new()` ([`src/read/readers.rs`](https://github.com/zip-rs/zip2/blob/6f09b3064ec87c3a69ea9f3413f9f9638b4aa446/src/read/readers.rs#L219-L223)).
When the entry's bounded reader reaches true EOF before the declared size, the inner reader returns `Ok(0)`, the subtraction takes away nothing, and the finalisation block — the only place the 10-byte HMAC-SHA1-80 tag is read and compared — never runs. If bytes such as the central directory follow the entry, they are consumed as payload until the `Take` limit is reached; the HMAC then usually fails. The bypass therefore requires true EOF at the premature end (as in the PoC) or the zero-payload case. The early return at the top of `read()` means an entry whose declared size leaves zero payload bytes is never authenticated either, even though its tag is present in the file.
This defeats the guarantee the type documents: *"After the data has been read and decrypted an HMAC will be checked and provide a final means to check if either the password is invalid or if the data has been changed."*
AE-2 entries skip the CRC-32 check by design ([`src/read/readers.rs`](https://github.com/zip-rs/zip2/blob/6f09b3064ec87c3a69ea9f3413f9f9638b4aa446/src/read/readers.rs#L248-L255) → `Crc32Reader::new(.., should_disable = true)`), so an AE-2 entry on this path has no integrity check of any kind remaining. AES-CTR is malleable, so an attacker who can modify an archive in transit — without the password — can XOR chosen plaintext onto the ciphertext and arrange for the entry to end at true EOF. The password-bearing read paths are affected, including `by_index_decrypt()`, `by_name_decrypt()`, `by_path_decrypt()`, and `by_index_with_options()` when a password is supplied. Bare `extract()` calls `by_index()` without a password and therefore rejects AES-encrypted