fix(apk): classify damaged Zip64 tail framing

Separate malformed locators and fixed records from unsupported disk layouts or numeric representation limits at the ZIP tail owner. Preserve the classic 65535-entry fallback and existing downstream parsed facts.

Add public CLI and stdio MCP valid, damaged, restored and unsupported controls; document the bounded validation scope.

Fixes #6471
This commit is contained in:
morluto committed 2026-10-24 05:05:23 +08:00
1 parent a8609d562f
commit a2235b09ac
4 files changed
+291 -13

No files matched your search

+14 -5
View File
@@ -214,9 +214,9 @@ Not yet read: v4 `.apk.idsig` (a sibling file), verity (`0x0421`/`0x0423`/`0x042
## Bounds and errors
The archive is read once, bounded at 512 MiB, without following symlinks and
with change detection. Every size and offset in the ZIP tail and signing block
is checked against its containing section before use, including the complete
central-directory extent before ZIP end records. A process-wide queue permits
with change detection. ZIP tail offsets, the central-directory size and signing
block spans used by the reader are checked against their containing sections,
including the complete central-directory extent before ZIP end records. A process-wide queue permits
one active whole-file inspection and eight pending scans across provider
instances; pending scans retain no APK bytes and can be cancelled with their
original reason. Further requests receive a capacity error. The active slot
@@ -243,8 +243,17 @@ 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
its bytes, or a malformed signing block or signer is an input error naming the
scheme and reason. Signer framing includes signature records, the public-key
field and matching signed/outer v3 SDK ranges. Suffix bytes beyond known
offset or scheme and reason. Zip64 tail damage includes a missing required
locator, an impossible locator disk/count pair, a fixed end record that does
not fit before its locator, or an incorrect end-record signature. These are
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);
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
fields are counted as uninterpreted, as Android readers may ignore extensions;
their presence does not establish malformed signing.
When the central directory is not immediately followed by
+38 -8
View File
@@ -142,19 +142,49 @@ const centralDirectoryOffset = (
if (offset !== ZIP64_SENTINEL && size !== ZIP64_SENTINEL)
return ok({ offset, size, entries, end: eocdOffset });
return err({
kind: "zip64-unsupported",
kind: "malformed-zip",
offset: eocdOffset,
detail: "missing Zip64 EOCD locator",
});
}
const recordOffset = u64(dv, locator + 8);
if (
recordOffset === null ||
recordOffset + 56 > locator ||
dv.getUint32(recordOffset, true) !== ZIP64_EOCD_SIGNATURE
)
// APPNOTE 6.3.10 §4.3.15 makes the record offset relative to its disk.
// A selected file cannot resolve another disk's offset as a local span.
const recordDisk = dv.getUint32(locator + 4, true);
const totalDisks = dv.getUint32(locator + 16, true);
if (totalDisks === 0)
return err({
kind: "malformed-zip",
offset: locator + 16,
detail: "Zip64 locator declares zero disks",
});
if (recordDisk >= totalDisks)
return err({
kind: "malformed-zip",
offset: locator + 4,
detail: "Zip64 EOCD disk is outside the declared disk count",
});
if (totalDisks > 1)
return err({
kind: "zip64-unsupported",
detail: "malformed Zip64 EOCD record",
detail: "multi-disk Zip64 archives are not supported",
});
const recordOffset = u64(dv, locator + 8);
if (recordOffset === null)
return err({
kind: "zip64-unsupported",
detail: "Zip64 EOCD offset exceeds safe range",
});
if (recordOffset + 56 > locator)
return err({
kind: "malformed-zip",
offset: locator + 8,
detail: "Zip64 EOCD record extends beyond its locator",
});
if (dv.getUint32(recordOffset, true) !== ZIP64_EOCD_SIGNATURE)
return err({
kind: "malformed-zip",
offset: recordOffset,
detail: "invalid Zip64 EOCD signature",
});
const z64Offset = u64(dv, recordOffset + 48);
const z64Size = u64(dv, recordOffset + 40);
+193
View File
@@ -0,0 +1,193 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js";
import { apkSignatureResultSchema } from "../../src/domain/apkSignature/apkSignatureReport.js";
import { parseEvidence } from "../../src/domain/evidence.js";
import { zip64Tail } from "../fixtures/apkSignature/zip64Tail.js";
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest, type TestCli } from "../support/cli/cliFixture.js";
const inspectBoth = async (cli: TestCli, path: string) => {
const output = await cli.sharedCliRun!({
arguments: [
"inspect-apk-signature",
path,
"--detail",
"complete",
"--json",
],
});
const call = await cli.sharedMcpCall!();
const response = await call("inspect_apk_signature", {
path,
detail: "complete",
});
return { output, response };
};
const expectUnsignedZip64 = async (
cli: TestCli,
path: string,
bytes: Uint8Array,
) => {
await writeFile(path, bytes);
const { output, response } = await inspectBoth(cli, path);
expect(output.exitCode, output.stderr).toBe(0);
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const sha256 = createHash("sha256").update(bytes).digest("hex");
for (const value of [output.json, response.structuredContent]) {
const evidence = parseEvidence(value);
expect(evidence.subject).toMatchObject({
local_path: path,
digest: { sha256 },
});
expect(
apkSignatureResultSchema.parse(evidence.normalized_result),
).toMatchObject({
artifact: { path, bytes: bytes.length, sha256 },
schemes: {
v1: { present: false },
v2: { present: false },
v3: { present: false },
"v3.1": { present: false },
},
runtime_execution: "not-performed",
});
}
expect(await readFile(path)).toEqual(Buffer.from(bytes));
};
const expectRefusal = async (
cli: TestCli,
path: string,
bytes: Uint8Array,
expected: Record<string, unknown>,
) => {
await writeFile(path, bytes);
const { output, response } = await inspectBoth(cli, path);
expect(output.exitCode, output.stderr).toBe(1);
const cliError = analysisErrorProjectionSchema.parse(output.json);
expect(cliError).toMatchObject(expected);
const mcpError = parseMcpToolError(response).error;
expect(mcpError).toMatchObject(expected);
expect(mcpError.message).toBe(cliError.message);
expect(await readFile(path)).toEqual(Buffer.from(bytes));
};
cliTest(
"classifies damaged Zip64 framing as input errors through CLI and stdio MCP",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-apk-zip64-framing-");
const path = join(root, "selected.apk");
const fixture = await zip64Tail();
const { recordOffset, locatorOffset, eocdOffset } = fixture;
await expectUnsignedZip64(cli, path, fixture.bytes);
const cases = [
{
at: locatorOffset,
value: 0x07064b51,
diagnosticOffset: eocdOffset,
detail: "missing Zip64 EOCD locator",
},
{
at: recordOffset,
value: 0x06064b51,
diagnosticOffset: recordOffset,
detail: "invalid Zip64 EOCD signature",
},
{
at: locatorOffset + 8,
value: recordOffset + 1,
diagnosticOffset: locatorOffset + 8,
detail: "Zip64 EOCD record extends beyond its locator",
},
{
at: locatorOffset + 16,
value: 0,
diagnosticOffset: locatorOffset + 16,
detail: "Zip64 locator declares zero disks",
},
{
at: locatorOffset + 4,
value: 1,
diagnosticOffset: locatorOffset + 4,
detail: "Zip64 EOCD disk is outside the declared disk count",
},
];
for (const scenario of cases) {
const bytes = Uint8Array.from(fixture.bytes);
new DataView(bytes.buffer).setUint32(scenario.at, scenario.value, true);
await expectRefusal(cli, path, bytes, {
code: "invalid_request",
category: "invalid_input",
retryable: false,
recovery: {
kind: "inspect_input_contract",
operation: "inspect_apk_signature",
},
details: {
issues: [
{
path: ["path"],
reason: "invalid_value",
message: `Damaged ZIP/APK at byte ${String(scenario.diagnosticOffset)}: ${scenario.detail}.`,
},
],
},
});
}
// Restore the exact original bytes at the same caller-selected path.
await expectUnsignedZip64(cli, path, fixture.bytes);
},
);
cliTest(
"keeps Zip64 representation limits and non-ZIP targets distinct from damaged input",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-apk-zip64-unsupported-");
const path = join(root, "selected.apk");
const fixture = await zip64Tail();
for (const at of [
fixture.locatorOffset + 8,
fixture.recordOffset + 32,
fixture.recordOffset + 40,
fixture.recordOffset + 48,
]) {
const bytes = Uint8Array.from(fixture.bytes);
new DataView(bytes.buffer).setBigUint64(at, 1n << 53n, true);
await expectRefusal(cli, path, bytes, {
code: "unsupported_target",
category: "unsupported_target",
message: expect.stringContaining("safe range"),
details: { path },
});
}
const split = Uint8Array.from(fixture.bytes);
const view = new DataView(split.buffer);
view.setUint32(fixture.locatorOffset + 4, 1, true);
view.setUint32(fixture.locatorOffset + 16, 2, true);
view.setBigUint64(fixture.locatorOffset + 8, 1000000n, true);
// Only locator metadata is authored here; this is not a real split-archive oracle.
await expectRefusal(cli, path, split, {
code: "unsupported_target",
category: "unsupported_target",
message: expect.stringContaining("multi-disk Zip64"),
details: { path },
});
await expectRefusal(
cli,
path,
new TextEncoder().encode("not a ZIP archive at all"),
{
code: "unsupported_target",
category: "unsupported_target",
message: expect.stringContaining("not a ZIP/APK"),
details: { path },
},
);
},
);
+46
View File
@@ -0,0 +1,46 @@
import { zipWith } from "./v1SignatureBlock.js";
/** A real stored entry with an authored APPNOTE 6.3.10 §4.3.14–15 Zip64 tail. */
export const zip64Tail = async () => {
const classic = await zipWith(
[{ name: "entry.txt", data: "inert", stored: true }],
{ dataDescriptor: false },
);
const original = new DataView(
classic.buffer,
classic.byteOffset,
classic.byteLength,
);
const recordOffset = classic.length - 22;
const locatorOffset = recordOffset + 56;
const eocdOffset = locatorOffset + 20;
const bytes = new Uint8Array(classic.length + 76);
bytes.set(classic.subarray(0, recordOffset));
bytes.set(classic.subarray(recordOffset), eocdOffset);
const view = new DataView(bytes.buffer);
view.setUint32(recordOffset, 0x06064b50, true);
view.setBigUint64(recordOffset + 4, 44n, true);
view.setUint16(recordOffset + 12, 45, true);
view.setUint16(recordOffset + 14, 45, true);
const entries = BigInt(original.getUint16(recordOffset + 10, true));
view.setBigUint64(recordOffset + 24, entries, true);
view.setBigUint64(recordOffset + 32, entries, true);
view.setBigUint64(
recordOffset + 40,
BigInt(original.getUint32(recordOffset + 12, true)),
true,
);
view.setBigUint64(
recordOffset + 48,
BigInt(original.getUint32(recordOffset + 16, true)),
true,
);
view.setUint32(locatorOffset, 0x07064b50, true);
view.setBigUint64(locatorOffset + 8, BigInt(recordOffset), true);
view.setUint32(locatorOffset + 16, 1, true);
view.setUint16(eocdOffset + 8, 0xffff, true);
view.setUint16(eocdOffset + 10, 0xffff, true);
view.setUint32(eocdOffset + 12, 0xffffffff, true);
view.setUint32(eocdOffset + 16, 0xffffffff, true);
return { bytes, recordOffset, locatorOffset, eocdOffset };
};