fix(pe): accept BER indefinite CMS values

Fixes #6574
This commit is contained in:
morluto committed 2026-10-23 18:07:46 +08:00
1 parent 98a2322998
commit 553262443a
6 files changed
+221 -67

No files matched your search

+8
View File
@@ -52,5 +52,13 @@ share a 64 KiB budget per PKCS#7 payload. Unsupported certificate name
encodings, including TeletexString (T.61) and GeneralString, fail explicitly
rather than returning names decoded with an assumed character set.
The structural CMS reader accepts definite lengths and BER indefinite lengths
for constructed values, including the outer `ContentInfo` sequence. Primitive
values must have definite lengths. Embedded X.509 certificates and signed
attributes must also use definite length encoding, as required by their DER
profiles. This is a bounded structural subset: it does not implement every BER
string form or validate DER canonical encoding, and it does not verify
signatures.
This workflow does not verify signatures, certificate chains, timestamps, or
Authenticode digest coverage of image bytes.
@@ -19,24 +19,26 @@ const OID_NAMES: Readonly<Record<string, string>> = {
"2.16.840.1.101.3.4.2.3": "sha512",
};
export interface DerNode {
export interface Asn1Node {
readonly tag: number;
readonly constructed: boolean;
readonly lengthForm: "definite" | "indefinite";
readonly start: number;
readonly contentEnd: number;
readonly end: number;
readonly content: Buffer;
readonly children: readonly DerNode[];
readonly children: readonly Asn1Node[];
}
export class PeCertificateDerFailure extends Error {
export class PeCertificateAsn1Failure extends Error {
constructor(message: string) {
super(message);
this.name = "PeCertificateDerFailure";
this.name = "PeCertificateAsn1Failure";
}
}
const fail = (message: string): never => {
throw new PeCertificateDerFailure(message);
throw new PeCertificateAsn1Failure(message);
};
const required = <T>(value: T | undefined, message: string): T =>
@@ -44,68 +46,106 @@ const required = <T>(value: T | undefined, message: string): T =>
// Authenticode signatures with full chains and countersignatures decode to a
// few thousand nodes; the budget bounds sibling fan-out, which depth does not.
export const DER_NODE_BUDGET = 16_384;
export const ASN1_NODE_BUDGET = 16_384;
interface DerBudget {
interface Asn1Budget {
remaining: number;
}
/** Decode one DER/BER TLV; constructed nodes own their child sequence. */
export const readDer = (
/** Decode one bounded ASN.1 TLV, including indefinite constructed BER values. */
export const readAsn1 = (
bytes: Buffer,
offset = 0,
end = bytes.length,
depth = 0,
budget: DerBudget = { remaining: DER_NODE_BUDGET },
): DerNode => {
if (depth > 32) return fail("DER nesting exceeds 32 levels.");
budget: Asn1Budget = { remaining: ASN1_NODE_BUDGET },
): Asn1Node => {
if (depth > 32) return fail("ASN.1 nesting exceeds 32 levels.");
if (budget.remaining <= 0)
return fail(
`DER value count exceeds the ${String(DER_NODE_BUDGET)}-node budget.`,
`ASN.1 value count exceeds the ${String(ASN1_NODE_BUDGET)}-node budget.`,
);
budget.remaining -= 1;
if (offset >= end || offset + 2 > end)
return fail("Truncated DER tag or length.");
const tag = required(bytes[offset], "Truncated DER tag.");
return fail("Truncated ASN.1 tag or length.");
const tag = required(bytes[offset], "Truncated ASN.1 tag.");
if (tag === 0)
return fail(
"Unexpected end-of-contents marker outside an indefinite value.",
);
const constructed = (tag & 0x20) !== 0;
let cursor = offset + 1;
const lengthByte = required(bytes[cursor], "Truncated DER length.");
const lengthByte = required(bytes[cursor], "Truncated ASN.1 length.");
cursor += 1;
let length: number;
if ((lengthByte & 0x80) === 0) length = lengthByte;
let length = 0;
const indefinite = lengthByte === 0x80;
if (indefinite) {
if (!constructed)
return fail("Primitive ASN.1 values cannot use indefinite length.");
} else if ((lengthByte & 0x80) === 0) length = lengthByte;
else {
const width = lengthByte & 0x7f;
if (width === 0 || width > 4) return fail("Unsupported DER length form.");
if (cursor + width > end) return fail("Truncated DER long length.");
if (width > 4) return fail("Unsupported ASN.1 long-form length width.");
if (cursor + width > end) return fail("Truncated ASN.1 long length.");
length = 0;
for (let index = 0; index < width; index += 1) {
length = (length << 8) | required(bytes[cursor], "Truncated DER length.");
length =
(length << 8) | required(bytes[cursor], "Truncated ASN.1 length.");
cursor += 1;
}
}
const contentEnd = cursor + length;
if (length < 0 || contentEnd > end)
return fail("DER value exceeds its parent.");
const content = bytes.subarray(cursor, contentEnd);
const children: DerNode[] = [];
if (constructed) {
const children: Asn1Node[] = [];
let contentEnd = cursor;
let encodedEnd = cursor;
if (indefinite) {
let at = cursor;
let terminated = false;
while (at < end) {
if (at + 1 < end && bytes[at] === 0 && bytes[at + 1] === 0) {
contentEnd = at;
encodedEnd = at + 2;
terminated = true;
break;
}
const child = readAsn1(bytes, at, end, depth + 1, budget);
children.push(child);
at = child.end;
}
if (!terminated)
return fail("Indefinite ASN.1 value has no end-of-contents marker.");
} else {
contentEnd = cursor + length;
if (length < 0 || contentEnd > end)
return fail("ASN.1 value exceeds its parent.");
encodedEnd = contentEnd;
}
if (constructed && !indefinite) {
let at = cursor;
while (at < contentEnd) {
const child = readDer(bytes, at, contentEnd, depth + 1, budget);
const child = readAsn1(bytes, at, contentEnd, depth + 1, budget);
children.push(child);
at = child.end;
}
}
const content = bytes.subarray(cursor, contentEnd);
return {
tag,
constructed,
lengthForm: indefinite ? "indefinite" : "definite",
start: offset,
end: contentEnd,
contentEnd,
end: encodedEnd,
content,
children,
};
};
const requireDefiniteEncoding = (node: Asn1Node, label: string): void => {
if (node.lengthForm === "indefinite")
fail(`${label} must use definite DER length encoding.`);
for (const child of node.children) requireDefiniteEncoding(child, label);
};
export const oidName = (oid: string): string | null => OID_NAMES[oid] ?? null;
// One arc grows a BigInt by a shift per byte. Certificate OIDs, including a
@@ -209,7 +249,7 @@ const decodeUtf8 = (bytes: Buffer, budget: DecodedNameBudget): string => {
};
const decodeDirectoryString = (
node: DerNode,
node: Asn1Node,
budget: DecodedNameBudget,
): string => {
switch (node.tag) {
@@ -250,18 +290,18 @@ const requireBitStringContent = (content: Buffer, label: string): void => {
return fail(`${label} BIT STRING is truncated or has invalid unused bits.`);
};
const requireBitString = (node: DerNode | undefined, label: string): void => {
const requireBitString = (node: Asn1Node | undefined, label: string): void => {
if (node === undefined || node.tag !== 0x03)
return fail(`${label} is not a BIT STRING.`);
requireBitStringContent(node.content, label);
};
const sequence = (node: DerNode, label: string): readonly DerNode[] => {
const sequence = (node: Asn1Node, label: string): readonly Asn1Node[] => {
if (node.tag !== 0x30) return fail(`Expected SEQUENCE for ${label}.`);
return node.children;
};
const algorithmOid = (node: DerNode): string => {
const algorithmOid = (node: Asn1Node): string => {
const children = sequence(node, "AlgorithmIdentifier");
const oid = children[0];
if (oid === undefined || oid.tag !== 0x06)
@@ -276,7 +316,7 @@ interface Attribute {
}
const decodeName = (
node: DerNode,
node: Asn1Node,
budget: DecodedNameBudget,
): {
readonly text: string;
@@ -312,7 +352,7 @@ const decodeName = (
return { text, attributes };
};
const validityTime = (node: DerNode, budget: DecodedNameBudget): string => {
const validityTime = (node: Asn1Node, budget: DecodedNameBudget): string => {
if (node.tag !== 0x17 && node.tag !== 0x18)
return fail("Validity instant is not UTCTime or GeneralizedTime.");
// A zero byte becomes "\u0000" (six characters) in the hashed evidence JSON.
@@ -331,7 +371,7 @@ export interface ParsedCertificate {
readonly signature_algorithm_oid: string;
}
const requireSubjectPublicKeyInfo = (node: DerNode): void => {
const requireSubjectPublicKeyInfo = (node: Asn1Node): void => {
const children = sequence(node, "SubjectPublicKeyInfo");
const algorithm = children[0];
const key = children[1];
@@ -346,7 +386,7 @@ const requireSubjectPublicKeyInfo = (node: DerNode): void => {
// issuerUniqueID [1], subjectUniqueID [2], and extensions [3] are optional
// and ordered. Anything else is not a TBSCertificate field.
const finishTbsCertificate = (
children: readonly DerNode[],
children: readonly Asn1Node[],
start: number,
): void => {
let cursor = start;
@@ -383,9 +423,10 @@ const finishTbsCertificate = (
/** Decode one X.509 Certificate SEQUENCE at a known file-relative DER node. */
export const parseCertificate = (
node: DerNode,
node: Asn1Node,
names: DecodedNameBudget = { remaining: MAX_DECODED_NAME_BYTES },
): ParsedCertificate => {
requireDefiniteEncoding(node, "X.509 Certificate");
const children = sequence(node, "Certificate");
const tbs = children[0];
const signatureAlgorithm = children[1];
@@ -467,7 +508,7 @@ export interface ParsedSigner {
}
const signerIdentifier = (
sid: DerNode,
sid: Asn1Node,
names: DecodedNameBudget,
): {
readonly issuer: ReturnType<typeof decodeName> | null;
@@ -505,7 +546,10 @@ const signerIdentifier = (
);
};
const parseSigner = (node: DerNode, names: DecodedNameBudget): ParsedSigner => {
const parseSigner = (
node: Asn1Node,
names: DecodedNameBudget,
): ParsedSigner => {
const children = sequence(node, "SignerInfo");
const version = children[0];
const sid = children[1];
@@ -520,7 +564,10 @@ const parseSigner = (node: DerNode, names: DecodedNameBudget): ParsedSigner => {
);
let cursor = 3;
const authenticated = children[cursor]?.tag === 0xa0;
if (authenticated) cursor += 1;
if (authenticated) {
requireDefiniteEncoding(children[cursor]!, "SignerInfo signedAttrs");
cursor += 1;
}
const signatureAlg = children[cursor];
if (signatureAlg === undefined)
return fail("SignerInfo is missing signatureAlgorithm.");
@@ -551,7 +598,7 @@ export interface ParsedSignedData {
}
const parseSignedData = (
node: DerNode,
node: Asn1Node,
names: DecodedNameBudget,
): ParsedSignedData => {
const children = sequence(node, "SignedData");
@@ -597,7 +644,8 @@ const parseSignedData = (
/** Decode ContentInfo wrapping SignedData; other content types remain unsupported. */
export const parsePkcs7SignedData = (bytes: Buffer): ParsedSignedData => {
const root = readDer(bytes);
const root = readAsn1(bytes);
if (root.end !== bytes.length) return fail("ContentInfo has trailing data.");
const children = sequence(root, "ContentInfo");
const type = children[0];
const content = children[1];
+77 -21
View File
@@ -9,17 +9,18 @@ import {
peCertificateFixture,
peWithoutCertificateTableFixture,
pkcs7SignedDataFixture,
pkcs7SignedDataIndefiniteEnvelopeFixture,
} from "./PeCertificates.fixture.js";
import {
DER_NODE_BUDGET,
ASN1_NODE_BUDGET,
MAX_DECODED_NAME_BYTES,
MAX_OID_ARC_BYTES,
MAX_OID_BYTES,
decodeOid,
parseCertificate,
PeCertificateDerFailure,
readDer,
} from "./PeCertificateDer.js";
PeCertificateAsn1Failure,
readAsn1,
} from "./PeCertificateAsn1.js";
import { parsePeCertificates } from "./PeCertificateParser.js";
const defaults = inspectPeCertificatesInputSchema.parse({
@@ -71,6 +72,61 @@ it("reads PE32 and PE32+ PKCS#7 certificate tables", () => {
}
});
it("reads BER indefinite constructed ContentInfo and preserves certificate bytes", () => {
const payload = pkcs7SignedDataIndefiniteEnvelopeFixture();
const root = readAsn1(payload);
const certificate = root.children[1]?.children[0]?.children[3]?.children[0];
if (certificate === undefined)
throw new Error("Fixture certificate was not decoded");
const report = parse(peCertificateFixture(false, payload));
const decoded = report.entries[0]?.pkcs7?.certificates[0];
expect(decoded).toMatchObject({
location: {
offset: 0x200 + 8 + certificate.start,
bytes: certificate.end - certificate.start,
},
serial_hex: "11223344",
});
expect(decoded?.der_sha256).toBe(
createHash("sha256")
.update(payload.subarray(certificate.start, certificate.end))
.digest("hex"),
);
});
it("matches nested indefinite constructed values and rejects invalid EOC forms", () => {
const nested = readAsn1(
Buffer.from([0x30, 0x80, 0x30, 0x80, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00]),
);
expect(nested).toMatchObject({
lengthForm: "indefinite",
end: 10,
contentEnd: 8,
content: Buffer.from([0x30, 0x80, 0x05, 0x00, 0x00, 0x00]),
children: [
{
lengthForm: "indefinite",
end: 8,
contentEnd: 6,
content: Buffer.from([0x05, 0x00]),
children: [{ tag: 0x05, lengthForm: "definite", end: 6 }],
},
],
});
expect(() => readAsn1(Buffer.from([0x04, 0x80, 0x00, 0x00]))).toThrow(
/Primitive ASN.1 values cannot use indefinite length/u,
);
expect(() => readAsn1(Buffer.from([0x30, 0x02, 0x00, 0x00]))).toThrow(
/Unexpected end-of-contents/u,
);
expect(() => readAsn1(Buffer.from([0x30, 0x80, 0x05, 0x00]))).toThrow(
/no end-of-contents marker/u,
);
expect(() =>
parseCertificate(readAsn1(Buffer.from([0x30, 0x80, 0x00, 0x00]))),
).toThrow(/X.509 Certificate must use definite DER length encoding/u);
});
it("reports an absent security directory without claiming the image is unsigned", () => {
const report = parse(peWithoutCertificateTableFixture());
expect(report.table).toBeNull();
@@ -160,7 +216,7 @@ const contentInfo = (signedData: Buffer) =>
it("rejects extra ContentInfo and signedData wrapper values", () => {
const valid = pkcs7SignedDataFixture();
const root = readDer(valid);
const root = readAsn1(valid);
const type = root.children[0];
const content = root.children[1];
const signedData = content?.children[0];
@@ -313,8 +369,8 @@ it("fails a truncated issuerAndSerialNumber instead of dropping the signer", ()
);
});
it("stops decoding a certificate payload at the DER node budget", () => {
const values = Buffer.alloc(2 * (DER_NODE_BUDGET + 1));
it("stops decoding a certificate payload at the ASN.1 node budget", () => {
const values = Buffer.alloc(2 * (ASN1_NODE_BUDGET + 1));
for (let at = 0; at < values.length; at += 2) values[at] = 0x05;
const payload = contentInfo(derSeq(derInt(1), der(0x31, values)));
expect(() => parse(peCertificateFixture(false, payload))).toThrow(
@@ -379,11 +435,11 @@ it("rejects a certificate missing subjectPublicKeyInfo or signatureValue", () =>
);
expect(() =>
parseCertificate(
readDer(derSeq(tbs(false), SHA256_ALG, der(0x03, Buffer.from([0, 1])))),
readAsn1(derSeq(tbs(false), SHA256_ALG, der(0x03, Buffer.from([0, 1])))),
),
).toThrow(/subjectPublicKeyInfo/u);
expect(() =>
parseCertificate(readDer(derSeq(tbs(true), SHA256_ALG))),
parseCertificate(readAsn1(derSeq(tbs(true), SHA256_ALG))),
).toThrow(/signatureValue/u);
const signer = derSeq(
derInt(1),
@@ -406,7 +462,7 @@ it("rejects a certificate missing subjectPublicKeyInfo or signatureValue", () =>
);
expect(() =>
parseCertificate(
readDer(
readAsn1(
derSeq(
derSeq(
derInt(1),
@@ -426,7 +482,7 @@ it("rejects a certificate missing subjectPublicKeyInfo or signatureValue", () =>
});
it("rejects a UTF8String name that is not valid UTF-8", () => {
const certificate = readDer(
const certificate = readAsn1(
namedCertificate(
directoryName(der(0x0c, Buffer.from([0x80]))),
directoryName(der(0x0c, Buffer.from("A"))),
@@ -435,7 +491,7 @@ it("rejects a UTF8String name that is not valid UTF-8", () => {
expect(() => parseCertificate(certificate)).toThrow(/not valid UTF-8/u);
expect(
parseCertificate(
readDer(
readAsn1(
namedCertificate(
directoryName(der(0x0c, Buffer.from([0xc3, 0xa9]))),
directoryName(der(0x0c, Buffer.from("A"))),
@@ -452,14 +508,14 @@ it.each([
] as const)(
"rejects unsupported %s certificate names",
(_encoding, tag, bytes) => {
const certificate = readDer(
const certificate = readAsn1(
namedCertificate(
directoryName(der(0x0c, Buffer.from("Issuer"))),
directoryName(der(tag, bytes)),
),
);
expect(() => parseCertificate(certificate)).toThrow(
new PeCertificateDerFailure(
new PeCertificateAsn1Failure(
`Unsupported certificate name encoding (DER tag 0x${tag.toString(16).padStart(2, "0")}).`,
),
);
@@ -474,12 +530,12 @@ it("decodes BMPString names once and shares the decoded-byte budget", () => {
);
return der(0x1e, content);
};
const certificate = readDer(
const certificate = readAsn1(
namedCertificate(directoryName(bmp("REA")), directoryName(bmp("Fixture"))),
);
expect(parseCertificate(certificate).subject.text).toBe("commonName=Fixture");
const half = Math.floor((MAX_DECODED_NAME_BYTES - (2 + 13 * 6 * 2)) / 4) * 2;
const within = readDer(
const within = readAsn1(
namedCertificate(
directoryName(der(0x1e, Buffer.alloc(half))),
directoryName(der(0x1e, Buffer.alloc(half))),
@@ -488,7 +544,7 @@ it("decodes BMPString names once and shares the decoded-byte budget", () => {
expect(parseCertificate(within).issuer.text.startsWith("commonName=")).toBe(
true,
);
const over = readDer(
const over = readAsn1(
namedCertificate(
directoryName(der(0x1e, Buffer.alloc(half + 4))),
directoryName(der(0x1e, Buffer.alloc(half))),
@@ -498,7 +554,7 @@ it("decodes BMPString names once and shares the decoded-byte budget", () => {
const universal = Buffer.alloc(MAX_DECODED_NAME_BYTES + 4);
expect(() =>
parseCertificate(
readDer(
readAsn1(
namedCertificate(
directoryName(der(0x1c, universal)),
directoryName(bmp("A")),
@@ -519,11 +575,11 @@ it("decodes UniversalString names as UTF-32BE", () => {
const cn = Buffer.from([0x06, 0x03, 0x55, 0x04, 0x03]);
const name = derSeq(derSet(derSeq(cn, universal("REA"))));
const tbs = derSeq(derInt(1), SHA256_ALG, name, validity, name, spki);
const certificate = readDer(
const certificate = readAsn1(
derSeq(tbs, SHA256_ALG, der(0x03, Buffer.from([0]))),
);
expect(parseCertificate(certificate).subject.text).toBe("commonName=REA");
const emoji = readDer(
const emoji = readAsn1(
namedCertificate(
directoryName(der(0x1c, Buffer.from([0x00, 0x01, 0xf6, 0x00]))),
directoryName(der(0x1c, Buffer.from([0x00, 0x01, 0xf6, 0x00]))),
@@ -531,7 +587,7 @@ it("decodes UniversalString names as UTF-32BE", () => {
);
expect(parseCertificate(emoji).subject.text).toBe("commonName=😀");
const odd = derSeq(derSet(derSeq(cn, der(0x1c, Buffer.from([0, 0, 0])))));
const broken = readDer(
const broken = readAsn1(
derSeq(
derSeq(
derInt(1),
+3 -3
View File
@@ -8,8 +8,8 @@ import { peCertificateTypeName } from "../../domain/native/peCertificates.js";
import {
oidName,
parsePkcs7SignedData,
PeCertificateDerFailure,
} from "./PeCertificateDer.js";
PeCertificateAsn1Failure,
} from "./PeCertificateAsn1.js";
import { readPeHeaders } from "./PeResourceLayout.js";
const fail = (message: string): never => {
@@ -158,7 +158,7 @@ export const parsePeCertificates = (
})),
};
} catch (cause) {
if (cause instanceof PeCertificateDerFailure)
if (cause instanceof PeCertificateAsn1Failure)
fail(`PKCS#7 signedData is malformed: ${cause.message}`);
throw cause;
}
+16
View File
@@ -104,6 +104,22 @@ export const pkcs7SignedDataFixture = (
return seq(SIGNED_DATA, context(0, signedData));
};
/** Same CMS fields with a BER indefinite-length outer ContentInfo SEQUENCE. */
export const pkcs7SignedDataIndefiniteEnvelopeFixture = (): Buffer => {
const definite = pkcs7SignedDataFixture();
const firstLength = definite[1];
if (firstLength === undefined) throw new Error("Missing ContentInfo length");
let contentOffset = 2;
if ((firstLength & 0x80) !== 0) contentOffset += firstLength & 0x7f;
if (contentOffset > definite.length)
throw new Error("Invalid fixture ContentInfo length");
return Buffer.concat([
Buffer.from([0x30, 0x80]),
definite.subarray(contentOffset),
Buffer.from([0, 0]),
]);
};
/** Minimal PE32/PE32+ image with an overlay IMAGE_DIRECTORY_ENTRY_SECURITY table. */
export const peCertificateFixture = (
plus = false,
@@ -6,6 +6,7 @@ import {
peCertificateFixture,
peWithoutCertificateTableFixture,
pkcs7SignedDataFixture,
pkcs7SignedDataIndefiniteEnvelopeFixture,
} from "../../src/native/pe/PeCertificates.fixture.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
@@ -29,6 +30,31 @@ cliTest(
},
);
cliTest(
"inspects SignedData with an indefinite constructed ContentInfo envelope",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-pe-cert-ber-cli-");
const path = join(root, "ber-signed.exe");
const payload = pkcs7SignedDataIndefiniteEnvelopeFixture();
await writeFile(path, peCertificateFixture(false, payload));
const result = await cli.run({
arguments: ["inspect-pe-certificates", path, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
const evidence = toolContract("inspect_pe_certificates").outputSchema.parse(
result.json,
);
const entry = evidence.normalized_result.entries[0];
expect(entry?.pkcs7?.certificates[0]).toMatchObject({
serial_hex: "11223344",
});
expect(entry?.payload.bytes).toBe(payload.length);
expect(entry?.pkcs7?.certificates[0]?.location.bytes).toBeGreaterThan(0);
},
);
cliTest(
"resolves a relative PE path against the working directory",
async ({ cli }) => {