fix(apk): validate classic ZIP disk declarations

Squashed from #7471 and rebased onto main.

Fixes #7433
Supersedes #7471

Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
wunianze666-netizenandgensokyo-dev authored and Ysopwnable committed 2026-11-14 16:31:24 +01:00
1 parent 14de30ff97
commit 5e1ddec2da
5 files changed
+160 -10

No files matched your search

+8 -1
View File
@@ -264,6 +264,13 @@ file SHA-256 and byte count. These facts describe the acquired file; they do not
establish any decoded signing facts. Failed cleanup remains visible until the
provider releases the descriptor on a cleanup retry.
A classic EOCD's disk declarations are checked before using its directory offset
as a span in the selected file. Nonzero current or central-directory disk numbers
are unsupported; differing per-disk and total entry counts in a single-disk
archive are malformed. This also applies to exactly 65,535 classic entries when
no Zip64 locator is present. A refused layout does not establish that signing
schemes are absent; select a complete single-disk archive for inspection.
A file that is not a ZIP is refused as an unsupported target. A damaged ZIP
(local headers but no end-of-central-directory), a central directory that
cannot be walked or whose declared record count does not account for all of
@@ -275,7 +282,7 @@ input errors, not evidence of an unsupported format. Coherent multi-disk
locator metadata and 64-bit offsets/counts/sizes beyond the reader's safe
integer range remain unsupported. The locator offset is disk-relative, so
multi-disk metadata is checked before applying local file bounds. This follows
the record fields in [PKWARE APPNOTE 6.3.10, sections 4.3.14–4.3.15](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT);
the record fields in [PKWARE APPNOTE 6.3.10, sections 4.3.14–4.3.16 and 4.4.19–4.4.22](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT);
it does not establish full validation of Zip64 versions or extensible data.
Signer framing includes signature records, the public-key field and matching
signed/outer v3 SDK ranges. Suffix bytes beyond known
+7
View File
@@ -54,6 +54,13 @@ export const mapApkBlockError = (
path,
`This archive uses a Zip64 structure this reader does not decode (${error.detail}).`,
);
case "multi-disk-unsupported":
return new AnalysisUnsupportedTargetError(
OPERATION,
path,
`The selected archive declares an unsupported multi-disk ZIP layout (${error.detail}).`,
{ remediationAction: "Select a complete single-disk APK/ZIP archive." },
);
case "malformed-zip":
return inputError(
`Damaged ZIP/APK at byte ${String(error.offset)}: ${error.detail}.`,
@@ -360,3 +360,62 @@ describe("EOCD that cannot describe the archive", () => {
expect(tail.error.kind).toBe("malformed-zip");
});
});
describe("classic ZIP disk declarations", () => {
it.each([
[1, 0],
[0, 1],
[1, 1],
])(
"refuses disk %s and directory disk %s before selecting a local span",
(disk, directoryDisk) => {
const bytes = apkWithBlock([]);
const eocd = bytes.length - 22;
const view = new DataView(bytes.buffer);
view.setUint16(eocd + 4, disk, true);
view.setUint16(eocd + 6, directoryDisk, true);
expect(parseZipTail(bytes)).toMatchObject({
ok: false,
error: { kind: "multi-disk-unsupported" },
});
},
);
it.each([0, 2])(
"refuses a single-disk record declaring %s of its one entry",
(entriesOnDisk) => {
const bytes = apkWithBlock([]);
const eocd = bytes.length - 22;
new DataView(bytes.buffer).setUint16(eocd + 8, entriesOnDisk, true);
expect(parseZipTail(bytes)).toMatchObject({
ok: false,
error: { kind: "malformed-zip", offset: eocd + 8 },
});
},
);
it("checks disk ownership in the count-saturated classic fallback", () => {
const bytes = apkWithBlock([]);
const eocd = bytes.length - 22;
const view = new DataView(bytes.buffer);
view.setUint16(eocd + 4, 1, true);
view.setUint16(eocd + 8, 0xffff, true);
view.setUint16(eocd + 10, 0xffff, true);
expect(parseZipTail(bytes)).toMatchObject({
ok: false,
error: { kind: "multi-disk-unsupported" },
});
});
it("checks per-disk counts in the count-saturated classic fallback", () => {
const bytes = apkWithBlock([]);
const eocd = bytes.length - 22;
const view = new DataView(bytes.buffer);
view.setUint16(eocd + 8, 0xfffe, true);
view.setUint16(eocd + 10, 0xffff, true);
expect(parseZipTail(bytes)).toMatchObject({
ok: false,
error: { kind: "malformed-zip", offset: eocd + 8 },
});
});
});
+34 -6
View File
@@ -42,6 +42,7 @@ export const APK_BLOCK_ID = {
export type ApkSigningBlockError =
| { readonly kind: "not-a-zip"; readonly detail: string }
| { readonly kind: "zip64-unsupported"; readonly detail: string }
| { readonly kind: "multi-disk-unsupported"; readonly detail: string }
| {
/** A ZIP whose end-of-central-directory is damaged or inconsistent. */
readonly kind: "malformed-zip";
@@ -101,14 +102,41 @@ const hasLocalFileHeader = (bytes: Uint8Array): boolean =>
bytes[2] === 0x03 &&
bytes[3] === 0x04;
interface CentralDirectory {
readonly offset: number;
readonly size: number;
readonly entries: number;
readonly end: number;
}
/** Admit a classic directory only when its offset describes this selected disk. */
const classicDirectory = (
eocd: ZipEndOfCentralDirectory,
): Result<CentralDirectory, ApkSigningBlockError> => {
if (eocd.disk !== 0 || eocd.directoryDisk !== 0)
return err({
kind: "multi-disk-unsupported",
detail: `classic EOCD names disk ${String(eocd.disk)} and central-directory disk ${String(eocd.directoryDisk)}`,
});
if (eocd.entriesOnDisk !== eocd.totalEntries)
return err({
kind: "malformed-zip",
offset: eocd.offset + 8,
detail: "single-disk ZIP entry counts disagree",
});
return ok({
offset: eocd.directoryOffset,
size: eocd.directorySize,
entries: eocd.totalEntries,
end: eocd.offset,
});
};
/** Resolve the central-directory offset, following a Zip64 EOCD when present. */
const centralDirectoryOffset = (
bytes: Uint8Array,
eocd: ZipEndOfCentralDirectory,
): Result<
{ offset: number; size: number; entries: number; end: number },
ApkSigningBlockError
> => {
): Result<CentralDirectory, ApkSigningBlockError> => {
const dv = view(bytes);
const {
offset: eocdOffset,
@@ -120,7 +148,7 @@ const centralDirectoryOffset = (
offset === ZIP64_SENTINEL ||
size === ZIP64_SENTINEL ||
entries === ZIP64_ENTRIES_SENTINEL;
if (!saturated) return ok({ offset, size, entries, end: eocdOffset });
if (!saturated) return classicDirectory(eocd);
// Zip64: a locator precedes the EOCD, pointing at the Zip64 EOCD record.
const locator = eocdOffset - 20;
if (
@@ -130,7 +158,7 @@ const centralDirectoryOffset = (
// Exactly 65,535 entries saturates the count without Zip64; that archive
// is still described completely by its 32-bit EOCD fields.
if (offset !== ZIP64_SENTINEL && size !== ZIP64_SENTINEL)
return ok({ offset, size, entries, end: eocdOffset });
return classicDirectory(eocd);
return err({
kind: "malformed-zip",
offset: eocdOffset,
+52 -3
View File
@@ -7,6 +7,7 @@ import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.
import { apkSignatureResultSchema } from "../../src/domain/apkSignature/apkSignatureReport.js";
import { parseEvidence } from "../../src/domain/evidence.js";
import { zip64Tail } from "../fixtures/apkSignature/zip64Tail.js";
import { zipWith } from "../fixtures/apkSignature/v1SignatureBlock.js";
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest, type TestCli } from "../support/cli/cliFixture.js";
@@ -29,7 +30,7 @@ const inspectBoth = async (cli: TestCli, path: string) => {
return { output, response };
};
const expectUnsignedZip64 = async (
const expectUnsignedArchive = async (
cli: TestCli,
path: string,
bytes: Uint8Array,
@@ -72,6 +73,8 @@ const expectRefusal = async (
expect(output.exitCode, output.stderr).toBe(1);
const cliError = analysisErrorProjectionSchema.parse(output.json);
expect(cliError).toMatchObject(expected);
expect(output.json).not.toHaveProperty("normalized_result");
expect(response).not.toHaveProperty("structuredContent.normalized_result");
const mcpError = parseMcpToolError(response).error;
expect(mcpError).toMatchObject(expected);
expect(mcpError.message).toBe(cliError.message);
@@ -85,7 +88,7 @@ cliTest(
const path = join(root, "selected.apk");
const fixture = await zip64Tail();
const { recordOffset, locatorOffset, eocdOffset } = fixture;
await expectUnsignedZip64(cli, path, fixture.bytes);
await expectUnsignedArchive(cli, path, fixture.bytes);
const cases = [
{
at: locatorOffset,
@@ -153,7 +156,53 @@ cliTest(
});
}
// Restore the exact original bytes at the same caller-selected path.
await expectUnsignedZip64(cli, path, fixture.bytes);
await expectUnsignedArchive(cli, path, fixture.bytes);
},
);
cliTest(
"refuses classic disk declarations before returning signing Evidence through CLI and MCP",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-apk-classic-disks-");
const path = join(root, "selected.apk");
const original = await zipWith(
[
{ name: "classes.dex", data: "inert", stored: true },
{ name: "AndroidManifest.xml", data: "<manifest/>", stored: true },
],
{ dataDescriptor: false },
);
await expectUnsignedArchive(cli, path, original);
const eocd = original.length - 22;
for (const at of [4, 6]) {
const bytes = Uint8Array.from(original);
new DataView(bytes.buffer).setUint16(eocd + at, 1, true);
// Only disk declarations change; these are not genuine split APK sets.
await expectRefusal(cli, path, bytes, {
code: "unsupported_target",
category: "unsupported_target",
message: expect.stringContaining("multi-disk ZIP"),
details: { path },
});
}
for (const entriesOnDisk of [0, 3]) {
const bytes = Uint8Array.from(original);
new DataView(bytes.buffer).setUint16(eocd + 8, entriesOnDisk, true);
await expectRefusal(cli, path, bytes, {
code: "invalid_request",
category: "invalid_input",
details: {
issues: [
{
path: ["path"],
reason: "invalid_value",
message: expect.stringContaining(`byte ${String(eocd + 8)}`),
},
],
},
});
}
await expectUnsignedArchive(cli, path, original);
},
);