diff --git a/scripts/lib/browser-scenario-storage-e2e.mjs b/scripts/lib/browser-scenario-storage-e2e.mjs index 601a10053..9cba53763 100644 --- a/scripts/lib/browser-scenario-storage-e2e.mjs +++ b/scripts/lib/browser-scenario-storage-e2e.mjs @@ -176,6 +176,9 @@ const startStorageSite = async () => { origin: `http://127.0.0.1:${port}`, close: async () => { server.close(); + // Chrome can leave speculative TCP connections without an HTTP request; + // server.close() alone waits indefinitely for those owned sockets. + server.closeAllConnections(); await once(server, "close"); }, }; diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs index 224b20780..48c9bec08 100644 --- a/scripts/verify-real-browser.mjs +++ b/scripts/verify-real-browser.mjs @@ -277,11 +277,13 @@ try { throw new Error("Scenario attachment terminated its external browser"); const profilesBefore = await scenarioProfiles(); + process.stderr.write("Browser verifier: scenario environment\n"); const scenarioEnvironment = await verifyScenarioEnvironment( endpoint, target, site.origin, ); + process.stderr.write("Browser verifier: scenario storage\n"); const scenarioStorage = await verifyScenarioStorage({ executable, endpoint, @@ -339,7 +341,9 @@ try { `Browser scenario included unselected structured value: ${unselectedValue}`, ); + process.stderr.write("Browser verifier: large screenshot\n"); const largeScreenshot = await verifyLargeScreenshotE2e(endpoint, site.origin); + process.stderr.write("Browser verifier: popup coverage\n"); const popupEvents = await verifyPopupEventCoverage(executable); const networkEvidence = await verifyBrowserNetworkEvidence( executable, diff --git a/src/native/pe/PeResourceLayout.ts b/src/native/pe/PeResourceLayout.ts index 521c27083..9c3ac4a63 100644 --- a/src/native/pe/PeResourceLayout.ts +++ b/src/native/pe/PeResourceLayout.ts @@ -28,6 +28,11 @@ interface Section { readonly size: number; } +// The image loader maps VirtualSize bytes, using SizeOfRawData only when +// VirtualSize is zero. Raw bytes past that extent are file-alignment padding. +const virtualExtent = (section: Section): number => + section.virtualSize === 0 ? section.size : section.virtualSize; + /** Header admission and unambiguous file-backed RVA mapping, independent of section names. */ export const readPeResourceLayout = (bytes: Buffer) => { requirePeRange(bytes, 0, 64); @@ -64,7 +69,7 @@ export const readPeResourceLayout = (bytes: Buffer) => { offset: bytes.readUInt32LE(at + 20), size: bytes.readUInt32LE(at + 16), }; - if (section.rva + Math.max(section.virtualSize, section.size) > 0x100000000) + if (section.rva + virtualExtent(section) > 0x100000000) peFailure("PE section RVA range overflows."); if (section.size > 0) { requirePeRange(bytes, section.offset, section.size); @@ -90,15 +95,14 @@ export const readPeResourceLayout = (bytes: Buffer) => { if (rva + size > 0x100000000) peFailure("Resource RVA range overflows."); const candidates = sections.filter( (section) => - rva >= section.rva && - rva - section.rva < Math.max(section.size, section.virtualSize), + rva >= section.rva && rva - section.rva < virtualExtent(section), ); if (rva < headerSize) { if ( sections.some( (section) => section.rva < rva + size && - section.rva + Math.max(section.size, section.virtualSize) > rva, + section.rva + virtualExtent(section) > rva, ) || size > headerSize - rva ) @@ -111,6 +115,8 @@ export const readPeResourceLayout = (bytes: Buffer) => { const section = candidates[0]; if (section === undefined) return peFailure("Missing resource section."); const within = rva - section.rva; + if (size > virtualExtent(section) - within) + peFailure("Resource range leaves its section's virtual size."); if (within > section.size || size > section.size - within) peFailure("Resource range is not entirely file-backed in one section."); // A second section starting inside the requested interval is ambiguous too. @@ -119,7 +125,7 @@ export const readPeResourceLayout = (bytes: Buffer) => { (other) => other !== section && other.rva < rva + size && - other.rva + Math.max(other.size, other.virtualSize) > rva, + other.rva + virtualExtent(other) > rva, ) ) peFailure("Resource interval intersects overlapping virtual sections."); diff --git a/src/native/pe/PeResourceParser.test.ts b/src/native/pe/PeResourceParser.test.ts index 5c1b8626d..2e221430b 100644 --- a/src/native/pe/PeResourceParser.test.ts +++ b/src/native/pe/PeResourceParser.test.ts @@ -259,3 +259,62 @@ describe("PE header admission and bounded traversal", () => { await expect(parsing).rejects.toThrow(); }); }); + +describe("PE resource section virtual extent", () => { + const sectionSizes = (fixture: ReturnType) => { + const virtualSize = fixture.bytes.readUInt32LE(fixture.sectionAt + 8); + const rawSize = fixture.bytes.readUInt32LE(fixture.sectionAt + 16); + expect(rawSize - virtualSize).toBeGreaterThanOrEqual(8); + return { virtualSize, rawSize }; + }; + + it.each([ + [0, "Resource RVA has missing or overlapping section mappings."], + [-2, "Resource range leaves its section's virtual size."], + ])( + "rejects a payload reaching raw alignment padding past VirtualSize (start %i)", + async (start, message) => { + const fixture = peResourceFixture(); + const { virtualSize } = sectionSizes(fixture); + const rva = 0x2000 + virtualSize + start; + fixture.bytes.writeUInt32LE(rva, fixture.dataOffsets[0]); + fixture.bytes.writeUInt32LE(4, (fixture.dataOffsets[0] ?? 0) + 4); + await expect(parse(fixture.bytes)).rejects.toMatchObject({ + reason: "format", + message, + }); + }, + ); + + it("maps raw bytes when VirtualSize is zero, as the image loader does", async () => { + const fixture = peResourceFixture(); + fixture.bytes.writeUInt32LE(0, fixture.sectionAt + 8); + expect(await parse(fixture.bytes)).toMatchObject({ + coverage: { status: "complete", resources: 5 }, + }); + }); + + it("selects a later section that starts inside earlier raw padding", async () => { + const fixture = peResourceFixture(); + const { virtualSize } = sectionSizes(fixture); + const laterRva = 0x2000 + Math.ceil(virtualSize / 4) * 4; + const rawOffset = fixture.bytes.length; + const payload = Buffer.from("LATE"); + const bytes = Buffer.concat([fixture.bytes, payload]); + bytes.writeUInt16LE(2, 0x86); + const later = fixture.sectionAt + 40; + bytes.writeUInt32LE(payload.length, later + 8); + bytes.writeUInt32LE(laterRva, later + 12); + bytes.writeUInt32LE(payload.length, later + 16); + bytes.writeUInt32LE(rawOffset, later + 20); + bytes.writeUInt32LE(laterRva, fixture.dataOffsets[0]); + bytes.writeUInt32LE(payload.length, (fixture.dataOffsets[0] ?? 0) + 4); + + const result = peResourcesSchema.parse(await parse(bytes)); + expect(result.resources[0]?.payload).toMatchObject({ + rva: laterRva, + location: { offset: rawOffset, bytes: payload.length }, + sha256: createHash("sha256").update(payload).digest("hex"), + }); + }); +});