fix(pe): accept WIN_CERTIFICATE padding counted in dwLength (#6721)

* fix(pe): accept WIN_CERTIFICATE padding counted in dwLength

* perf(pe): reuse decoded PKCS#7 ASN.1 tree

* style: format files inherited from main for CI

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Deepak Kambala
2026-10-24 03:34:41 +05:30
committed by GitHub
parent 1252f17f0e
commit ba9c5bc686
6 changed files with 116 additions and 63 deletions
+3 -2
View File
@@ -30,8 +30,9 @@ revision, type, and the payload SHA-256.
Type-2 entries may count their zero alignment padding in `dwLength`. The PE
entry owner accepts only the exact gap to the next eight-byte boundary after
the parsed ContentInfo; extra data or nonzero counted padding fails. Entry and
payload ranges and hashes retain all declared bytes, including counted padding.
the parsed ContentInfo; extra data or nonzero counted padding fails. Entry
ranges retain all declared bytes, including counted padding. CMS payload
ranges and hashes exclude counted padding.
For `WIN_CERTIFICATE` type 2 (PKCS#7 signedData) `pkcs7` contains digest
algorithm OIDs, embedded X.509 serial/issuer/subject/validity fields, and
+80 -35
View File
@@ -72,45 +72,82 @@ it("reads PE32 and PE32+ PKCS#7 certificate tables", () => {
}
});
it("accepts only zero eight-byte alignment padding included in dwLength", () => {
for (const plus of [false, true]) {
const bytes = peCertificateFixture(plus, undefined, undefined, true);
const originalDigest = createHash("sha256").update(bytes).digest("hex");
const encodedPayload = pkcs7SignedDataFixture();
const report = parse(bytes);
expect(report.artifact.sha256).toBe(originalDigest);
expect(report.artifact.bytes).toBe(bytes.length);
expect(report.format).toBe(plus ? "pe32-plus" : "pe32");
expect(report.entries[0]).toMatchObject({
location: { offset: 0x200, bytes: bytes.readUInt32LE(0x200) },
length: bytes.readUInt32LE(0x200),
payload: {
location: { offset: 0x208, bytes: encodedPayload.length },
bytes: encodedPayload.length,
sha256: createHash("sha256").update(encodedPayload).digest("hex"),
},
});
expect(report.coverage).toEqual({
status: "complete",
examined_entries: 1,
certificates: 1,
});
expect(report.entries[0]?.pkcs7?.certificates).toHaveLength(1);
expect(report.entries[0]?.pkcs7?.signers).toHaveLength(1);
}
});
it.each([false, true])(
"preserves PE certificate bytes when dwLength counts alignment padding (PE32+ = %s)",
"reads all counted alignment gaps and consecutive entries (PE32+ = %s)",
(plus) => {
// Choose a fixture with nonzero alignment padding so the control distinguishes
// an ASN.1 terminator from the end of the WIN_CERTIFICATE payload.
const payload = ["Padding", "Paddingx"]
.map((name) => pkcs7SignedDataFixture(name))
.find((value) => (value.length + 8) % 8 !== 0)!;
const bytes = peCertificateFixture(plus, payload);
const control = parse(bytes);
const entry = control.entries[0]!;
const aligned = Math.ceil(entry.length / 8) * 8;
expect(aligned).toBeGreaterThan(entry.length);
bytes.writeUInt32LE(aligned, entry.location.offset);
const report = peCertificatesSchema.parse(parse(bytes));
expect(report.entries[0]?.pkcs7).toEqual(entry.pkcs7);
expect(report.entries[0]?.length).toBe(aligned);
expect(report.entries[0]?.payload.sha256).toBe(
createHash("sha256")
.update(
bytes.subarray(
entry.payload.location.offset,
entry.location.offset + aligned,
),
)
.digest("hex"),
);
bytes[entry.location.offset + aligned - 1] = 1;
expect(() => parse(bytes)).toThrow(/ContentInfo has trailing data/);
const gaps = new Set<number>();
for (let size = 1; size <= 32; size += 1) {
const payload = pkcs7SignedDataFixture("x".repeat(size));
const gap = (8 - ((8 + payload.length) % 8)) % 8;
if (gaps.has(gap)) continue;
gaps.add(gap);
const single = peCertificateFixture(plus, payload, 2, true);
const table = single.subarray(0x200);
const bytes = Buffer.concat([single, table]);
const directoryAt = 0x98 + (plus ? 112 : 96) + 4 * 8;
bytes.writeUInt32LE(table.length * 2, directoryAt + 4);
const report = peCertificatesSchema.parse(parse(bytes));
expect(report.coverage).toEqual({
status: "complete",
examined_entries: 2,
certificates: 2,
});
for (const [index, entry] of report.entries.entries()) {
expect(entry.location).toEqual({
offset: 0x200 + index * table.length,
bytes: table.length,
});
expect(entry.payload.bytes).toBe(payload.length);
expect(entry.payload.sha256).toBe(
createHash("sha256").update(payload).digest("hex"),
);
expect(entry.pkcs7?.signers).toHaveLength(1);
}
}
expect([...gaps].sort()).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
},
);
it("refuses surplus zero bytes beyond the PE entry alignment gap", () => {
it("rejects nonzero or excessive bytes after the encoded PKCS#7 value", () => {
const payload = pkcs7SignedDataFixture();
expect(() =>
parse(
peCertificateFixture(false, Buffer.concat([payload, Buffer.alloc(8)])),
),
).toThrow(/ContentInfo has trailing data/);
const nonzero = peCertificateFixture(false, payload, 2, true);
nonzero[0x200 + 8 + payload.length] = 1;
expect(() => parse(nonzero)).toThrow(/invalid PKCS#7 alignment padding/u);
const extra = peCertificateFixture(
false,
Buffer.concat([payload, Buffer.alloc(8)]),
2,
);
expect(() => parse(extra)).toThrow(/invalid PKCS#7 alignment padding/u);
});
it("reads BER indefinite constructed ContentInfo and preserves certificate bytes", () => {
@@ -220,14 +257,22 @@ it("reports an absent security directory without claiming the image is unsigned"
);
});
const securityDirectoryAt = 0x98 + 96 + 4 * 8;
it("fails truncated WIN_CERTIFICATE payloads without partial success", () => {
const bytes = peCertificateFixture();
const tableOffset = bytes.readUInt32LE(0x80 + 24 + 96 + 32);
bytes.writeUInt32LE(4, tableOffset);
expect(() => parse(bytes)).toThrow(ArtifactReaderFailure);
});
const securityDirectoryAt = 0x98 + 96 + 4 * 8;
const overlong = peCertificateFixture();
const overlongTableOffset = overlong.readUInt32LE(securityDirectoryAt);
const tableSize = overlong.readUInt32LE(securityDirectoryAt + 4);
overlong.writeUInt32LE(tableSize + 1, overlongTableOffset);
expect(() => parse(overlong)).toThrow(
/dwLength exceeds the certificate table/u,
);
});
it("rejects a short security table instead of reporting it complete", () => {
for (const size of [4, 7]) {
+15 -9
View File
@@ -112,20 +112,26 @@ export const parsePeCertificates = (
fail("WIN_CERTIFICATE dwLength is shorter than its header.");
if (at + length > table.length)
fail("WIN_CERTIFICATE dwLength exceeds the certificate table.");
const payload = table.subarray(at + 8, at + length);
const entryPayload = table.subarray(at + 8, at + length);
let payload = entryPayload;
let pkcs7: PeCertificates["entries"][number]["pkcs7"] = null;
if (peCertificateTypeName(certificateType) === "pkcs7") {
try {
const content = readAsn1(payload);
// The PE entry owns alignment outside the ASN.1 ContentInfo. Accept
// either uncounted padding or the exact counted zero-padding extent.
// dwLength may include the zero bytes that align the complete
// WIN_CERTIFICATE to eight bytes. Bound CMS to its encoded ASN.1 root;
// tolerate only the corresponding alignment bytes, never arbitrary
// trailing data.
const root = readAsn1(entryPayload);
const paddingBytes = entryPayload.length - root.end;
const alignedLength = align8(8 + root.end);
if (
content.end !== payload.length &&
(payload.length !== align8(8 + content.end) - 8 ||
!payload.subarray(content.end).every((byte) => byte === 0))
paddingBytes !== 0 &&
(8 + entryPayload.length !== alignedLength ||
!entryPayload.subarray(root.end).every((byte) => byte === 0))
)
throw new PeCertificateAsn1Failure("ContentInfo has trailing data.");
const parsed = parsePkcs7SignedData(content);
fail("WIN_CERTIFICATE has invalid PKCS#7 alignment padding.");
payload = entryPayload.subarray(0, root.end);
const parsed = parsePkcs7SignedData(root);
teletexStringCompatibilityUsed ||=
parsed.teletexStringCompatibilityUsed;
pkcs7 = {
+2 -1
View File
@@ -126,13 +126,14 @@ export const peCertificateFixture = (
plus = false,
payload: Buffer = pkcs7SignedDataFixture(),
type = 0x0002,
countAlignmentInLength = false,
): Buffer => {
const headerBytes = 0x200;
const alignedPayload = payload;
const entryLength = 8 + alignedPayload.length;
const padded = align8(entryLength);
const table = Buffer.alloc(padded);
table.writeUInt32LE(entryLength, 0);
table.writeUInt32LE(countAlignmentInLength ? padded : entryLength, 0);
table.writeUInt16LE(0x0200, 4);
table.writeUInt16LE(type, 6);
alignedPayload.copy(table, 8);
+12 -15
View File
@@ -91,7 +91,7 @@ cliTest("reports a truncated PE as invalid input", async ({ cli }) => {
});
cliTest(
"rejects TeletexString names without returning corrupted identity text",
"reports TeletexString compatibility text with its decoding limitation",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-pe-cert-cli-teletex-");
const path = join(root, "teletex.exe");
@@ -104,19 +104,16 @@ cliTest(
const result = await cli.run({
arguments: ["inspect-pe-certificates", path, "--json"],
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: {
operation: "inspect_pe_certificates",
issues: [
{
message: expect.stringContaining(
"Unsupported certificate name encoding (DER tag 0x14)",
),
},
],
},
});
expect(result.exitCode, result.stderr).toBe(0);
const evidence = toolContract("inspect_pe_certificates").outputSchema.parse(
result.json,
);
expect(
evidence.normalized_result.entries[0]?.pkcs7?.certificates[0]?.issuer
.text,
).toBe("commonName=Âe, organizationName=REA Tests");
expect(evidence.normalized_result.limitations).toContain(
"TeletexString values use OpenSSL's ISO-8859-1 compatibility mapping; this is not a full T.61 decoder.",
);
},
);
@@ -77,7 +77,10 @@ it.skipIf(process.platform === "win32")(
path: "container",
kind: "directory",
tree_state: "unreadable",
limitations: [expect.stringContaining(readFailure.message)],
limitations: [
expect.stringContaining("This directory could not be read"),
],
local_diagnostics: [expect.stringContaining(readFailure.message)],
}),
expect.objectContaining({
path: "container/hidden",