diff --git a/scripts/ci/plan.mjs b/scripts/ci/plan.mjs index a93872181b..acd7ceebbe 100644 --- a/scripts/ci/plan.mjs +++ b/scripts/ci/plan.mjs @@ -22,6 +22,9 @@ const GUARDED_WORKFLOWS = new Set([ // Sources compiled by build-conformance-fixtures.mjs. The readiness journey // analyzes the built C fixture as well as its own applications. const FIXTURE_SOURCES = [ + "scripts/build-conformance-fixtures.mjs", + "scripts/verify-conformance-fixtures.mjs", + "scripts/lib/conformance-fixtures.mjs", "tests/conformance/c/**", "tests/conformance/napi/**", "tests/conformance/objc/**", diff --git a/scripts/lib/conformance-fixtures.d.mts b/scripts/lib/conformance-fixtures.d.mts index 607c572f2a..4c34ecb5a0 100644 --- a/scripts/lib/conformance-fixtures.d.mts +++ b/scripts/lib/conformance-fixtures.d.mts @@ -13,3 +13,12 @@ export function generateLargeFixture(): string; export function sourceDigest( sources: readonly { readonly path: string; readonly content: string }[], ): string; + +export function resolveConformanceFixturePath( + root: string, + declaredPath: unknown, +): Promise; +export function bindConformanceFixtureArtifact( + path: string, + fixture: unknown, +): Promise<{ readonly path: string; readonly sha256: string }>; diff --git a/scripts/lib/conformance-fixtures.mjs b/scripts/lib/conformance-fixtures.mjs index 5b74ad9e5a..5c54f6edf4 100644 --- a/scripts/lib/conformance-fixtures.mjs +++ b/scripts/lib/conformance-fixtures.mjs @@ -1,4 +1,6 @@ import { createHash } from "node:crypto"; +import { readFile, realpath } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; export const LARGE_FIXTURE_COUNT = 1_205; @@ -36,3 +38,35 @@ export const sourceDigest = (sources) => { .join(""); return sha256(canonical); }; + +/** Resolve manifest-relative paths once, including their real symlink destination. */ +export const resolveConformanceFixturePath = async (root, declaredPath) => { + if (typeof declaredPath !== "string") + throw new Error("Conformance manifest path is invalid"); + const path = await realpath(resolve(root, declaredPath)); + const relation = relative(root, path); + if ( + relation === ".." || + relation.startsWith(`..${sep}`) || + isAbsolute(relation) + ) + throw new Error(`Conformance path escapes its root: ${declaredPath}`); + return path; +}; + +/** Read a resolved artifact and bind its exact bytes to the manifest digest. */ +export const bindConformanceFixtureArtifact = async (path, fixture) => { + if ( + fixture === null || + typeof fixture !== "object" || + typeof fixture.artifactSha256 !== "string" || + !/^[0-9a-f]{64}$/u.test(fixture.artifactSha256) + ) + throw new Error("Invalid conformance artifact declaration"); + const actual = sha256(await readFile(path)); + if (actual !== fixture.artifactSha256) + throw new Error( + `Fixture ${fixture.name} digest did not match its manifest`, + ); + return { path, sha256: actual }; +}; diff --git a/scripts/lib/real-hopper-fixture.mjs b/scripts/lib/real-hopper-fixture.mjs index 8d8ad67a54..b12989f7f0 100644 --- a/scripts/lib/real-hopper-fixture.mjs +++ b/scripts/lib/real-hopper-fixture.mjs @@ -1,6 +1,9 @@ -import { createHash } from "node:crypto"; import { readFile, realpath } from "node:fs/promises"; -import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { dirname } from "node:path"; +import { + bindConformanceFixtureArtifact, + resolveConformanceFixturePath, +} from "./conformance-fixtures.mjs"; /** Bind the real-Hopper semantic verifier to source-owned manifest artifacts. */ export async function loadRealHopperFixtureTargets(manifestPath) { @@ -15,19 +18,22 @@ export async function loadRealHopperFixtureTargets(manifestPath) { const largeOracle = requireLargeOracle(large.expectations); const root = dirname(canonicalManifest); const [primaryArtifact, secondaryArtifact, largeArtifact] = await Promise.all( - [ - bindFixtureArtifact(root, primary), - bindFixtureArtifact(root, secondary), - bindFixtureArtifact(root, large), - ], + [primary, secondary, large].map(async (fixture) => + bindConformanceFixtureArtifact( + await resolveConformanceFixturePath(root, fixture.artifact), + fixture, + ), + ), ); const objc = manifest.fixtures.find((fixture) => fixture?.name === "objc"); + const unicode = + objc === undefined ? undefined : fixtureEntry(manifest.fixtures, "objc"); const unicodeArtifact = - objc === undefined + unicode === undefined ? undefined - : await bindFixtureArtifact( - root, - fixtureEntry(manifest.fixtures, "objc"), + : await bindConformanceFixtureArtifact( + await resolveConformanceFixturePath(root, unicode.artifact), + unicode, ); requireDistinctArtifacts([ primaryArtifact, @@ -100,28 +106,3 @@ function fixtureEntry(fixtures, name) { if (matches.length !== 1) throw new Error(`Missing fixture ${name}`); return matches[0]; } - -async function bindFixtureArtifact(root, fixture) { - if ( - typeof fixture.artifact !== "string" || - typeof fixture.artifactSha256 !== "string" || - !/^[0-9a-f]{64}$/u.test(fixture.artifactSha256) - ) - throw new Error(`Invalid artifact declaration for ${fixture.name}`); - const path = await realpath(resolve(root, fixture.artifact)); - const relation = relative(root, path); - if ( - relation === ".." || - relation.startsWith(`..${sep}`) || - isAbsolute(relation) - ) - throw new Error(`Fixture ${fixture.name} escapes its manifest root`); - const actual = sha256(await readFile(path)); - if (actual !== fixture.artifactSha256) - throw new Error( - `Fixture ${fixture.name} digest did not match its manifest`, - ); - return { path, sha256: actual }; -} - -const sha256 = (value) => createHash("sha256").update(value).digest("hex"); diff --git a/scripts/verify-conformance-fixtures.mjs b/scripts/verify-conformance-fixtures.mjs index a02c17f6c4..93efb6c3fe 100644 --- a/scripts/verify-conformance-fixtures.mjs +++ b/scripts/verify-conformance-fixtures.mjs @@ -1,9 +1,11 @@ import { execFile } from "node:child_process"; import { readFile, realpath } from "node:fs/promises"; -import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { dirname, resolve } from "node:path"; import { promisify } from "node:util"; import { + bindConformanceFixtureArtifact, + resolveConformanceFixturePath, generateLargeFixture, sha256, sourceDigest, @@ -63,7 +65,10 @@ async function resolveSourceRoot(manifestPath, declaredSourceRoot) { } async function verifyFixture(fixture) { - const artifactPath = await confinedPath(root, fixture.artifact); + const artifactPath = await resolveConformanceFixturePath( + root, + fixture.artifact, + ); const sources = []; for (const source of fixture.sources ?? []) { if (typeof source.path !== "string" || typeof source.sha256 !== "string") @@ -71,7 +76,9 @@ async function verifyFixture(fixture) { const content = source.path === "generated/large.c" ? generateLargeFixture() - : await readFile(await confinedPath(sourceRoot, source.path)); + : await readFile( + await resolveConformanceFixturePath(sourceRoot, source.path), + ); if (sha256(content) !== source.sha256) throw new Error( `${fixture.name}: source hash mismatch for ${source.path}`, @@ -80,9 +87,7 @@ async function verifyFixture(fixture) { } if (sourceDigest(sources) !== fixture.sourceSha256) throw new Error(`${fixture.name}: source manifest digest mismatch`); - const artifact = await readFile(artifactPath); - if (sha256(artifact) !== fixture.artifactSha256) - throw new Error(`${fixture.name}: artifact hash mismatch`); + await bindConformanceFixtureArtifact(artifactPath, fixture); const fileOutput = (await exec("file", [artifactPath])).stdout; if (!fileOutput.includes(fileMarker)) throw new Error(`${fixture.name}: not ${fileMarker}`); @@ -115,20 +120,6 @@ async function verifyFixture(fixture) { ); } -async function confinedPath(rootPath, declaredPath) { - if (typeof declaredPath !== "string") - throw new Error("Conformance manifest path is invalid"); - const path = await realpath(resolve(rootPath, declaredPath)); - const relation = relative(rootPath, path); - if ( - relation === ".." || - relation.startsWith(`..${sep}`) || - isAbsolute(relation) - ) - throw new Error(`Conformance path escapes its root: ${declaredPath}`); - return path; -} - function verifyCount(output, prefix, expected, fixture) { if (prefix === undefined || expected === undefined) return; const count = output diff --git a/src/application/MipsElfMetadata.ts b/src/application/MipsElfMetadata.ts index 33ed40fb63..a4c2b2a0d9 100644 --- a/src/application/MipsElfMetadata.ts +++ b/src/application/MipsElfMetadata.ts @@ -1,3 +1,4 @@ +import { readElfProgramHeader } from "../domain/elfProgramHeader.js"; import { readElfRawTableDeclarations } from "../domain/elfRawTableDeclarations.js"; import type { FileHandle } from "node:fs/promises"; import type { @@ -102,9 +103,6 @@ export const readMipsElfMetadata = async ( count: programs.count, stride: programs.width, entrySize: 32, - typeOffset: 0, - dataOffset: 4, - sizeOffset: 16, recordType: 0x70000003, // PT_MIPS_ABIFLAGS optional: false, }, @@ -114,9 +112,6 @@ export const readMipsElfMetadata = async ( count: sections.count, stride: sections.width, entrySize: 40, - typeOffset: 4, - dataOffset: 16, - sizeOffset: 20, recordType: 0x7000002a, // SHT_MIPS_ABIFLAGS optional: true, }, @@ -163,7 +158,17 @@ export const readMipsElfMetadata = async ( for (let i = 0; i < count; i += 1) { checkCancelled(); const position = i * table.entrySize; - const type = u32(contents.value, position + table.typeOffset); + const parsedProgram = !table.optional + ? readElfProgramHeader( + contents.value.subarray(position, position + table.entrySize), + 32, + little, + ) + : undefined; + if (parsedProgram !== undefined && !parsedProgram.ok) + return err(parsedProgram.error.message); + const program = parsedProgram?.value; + const type = program?.type ?? u32(contents.value, position + 4); if (table.optional && type === SHT_GNU_ATTRIBUTES) { if (gnuAttributes !== null) return err("duplicate MIPS GNU attribute sections"); @@ -197,8 +202,14 @@ export const readMipsElfMetadata = async ( if (type !== table.recordType) continue; if (found) return err(`duplicate MIPS ABI records in ${table.label}`); found = true; - const offset = u32(contents.value, position + table.dataOffset); - const size = u32(contents.value, position + table.sizeOffset); + const offset = + program !== undefined + ? Number(program.offset) + : u32(contents.value, position + 16); + const size = + program !== undefined + ? Number(program.fileSize) + : u32(contents.value, position + 20); if (size !== 24) return err("unsupported MIPS ABI record size; expected 24 bytes"); const candidate = await read(offset, 24, "ABI record"); diff --git a/src/application/MipsElfSharedLoad.ts b/src/application/MipsElfSharedLoad.ts index 2a4e4b7817..2fe8b68ff9 100644 --- a/src/application/MipsElfSharedLoad.ts +++ b/src/application/MipsElfSharedLoad.ts @@ -1,3 +1,4 @@ +import { readElfProgramHeader } from "../domain/elfProgramHeader.js"; import type { MipsElfMetadata } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -38,12 +39,19 @@ export const readMipsElfSharedLoad = async ( let dynamic: { offset: number; address: number; size: number } | undefined; for (let position = 0; position < programHeaders.length; position += 32) { checkCancelled(); - const type = u32(programHeaders, position); - const offset = u32(programHeaders, position + 4); - const size = u32(programHeaders, position + 16); + const parsedProgram = readElfProgramHeader( + programHeaders.subarray(position, position + 32), + 32, + little, + ); + if (!parsedProgram.ok) return err(parsedProgram.error.message); + const program = parsedProgram.value; + const { type } = program; + const offset = Number(program.offset); + const size = Number(program.fileSize); if (type === 1) { - const address = u32(programHeaders, position + 8); - const memorySize = u32(programHeaders, position + 20); + const address = Number(program.virtualAddress); + const memorySize = Number(program.memorySize); if ( offset > fileSize - size || size > memorySize || @@ -54,7 +62,7 @@ export const readMipsElfSharedLoad = async ( } if (type === 2) { if (dynamic !== undefined) return err("duplicate MIPS PT_DYNAMIC tables"); - dynamic = { offset, address: u32(programHeaders, position + 8), size }; + dynamic = { offset, address: Number(program.virtualAddress), size }; } } if (dynamic === undefined) diff --git a/src/artifacts/nodeSea/NodeSeaLocation.ts b/src/artifacts/nodeSea/NodeSeaLocation.ts index 432c333cb8..8adc94ed36 100644 --- a/src/artifacts/nodeSea/NodeSeaLocation.ts +++ b/src/artifacts/nodeSea/NodeSeaLocation.ts @@ -1,3 +1,4 @@ +import { readElfProgramHeader } from "../../domain/elfProgramHeader.js"; import { readElfRawTableDeclarations } from "../../domain/elfRawTableDeclarations.js"; import { createHash } from "node:crypto"; import { parsePeResources } from "../../native/pe/PeResourceParser.js"; @@ -353,18 +354,28 @@ const locateElfNotes = async ( if (programs === undefined) opaque = phnum > 0; else for (const entry of programs) { - if (readU32(entry, 0, little) !== PT_NOTE) continue; - const note = elf64 - ? { - offset: readU64(entry, 8, little), - length: readU64(entry, 32, little), - align: readU64(entry, 48, little), - } - : { - offset: readU32(entry, 4, little), - length: readU32(entry, 16, little), - align: readU32(entry, 28, little), - }; + const parsedProgram = readElfProgramHeader( + entry, + header.value.elfClass, + little, + ); + if (!parsedProgram.ok) continue; + const program = parsedProgram.value; + if (program.type !== PT_NOTE) continue; + const note = { + offset: + program.offset <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(program.offset) + : undefined, + length: + program.fileSize <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(program.fileSize) + : undefined, + align: + program.alignment <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(program.alignment) + : undefined, + }; const scanned = await scanElfNoteTable( carrier, note, diff --git a/src/domain/elfDeclaredRanges.ts b/src/domain/elfDeclaredRanges.ts index 13425525b1..9c2a16666d 100644 --- a/src/domain/elfDeclaredRanges.ts +++ b/src/domain/elfDeclaredRanges.ts @@ -1,3 +1,4 @@ +import { readElfProgramHeader } from "./elfProgramHeader.js"; import { readElfRawTableDeclarations } from "./elfRawTableDeclarations.js"; import { readElfFixedHeader, type ElfFixedHeader } from "./elfFixedHeader.js"; import { err, ok, type Result } from "./result.js"; @@ -208,14 +209,21 @@ function* inspectTableRanges( const r = reader(bytes, header); for (let item = 0; item < count; item++) { const at = item * table.width; - const type = r.u32(at + (kind === "program" ? 0 : 4)); + const program = + kind === "program" + ? readElfProgramHeader( + bytes.subarray(at, at + table.width), + header.elfClass, + header.byteOrder === "little", + ) + : undefined; + if (program !== undefined && !program.ok) + return err(program.error.message); + const facts = program?.value; + const type = facts?.type ?? r.u32(at + 4); if (type === 0 || (kind === "section" && type === 8)) continue; - const offset = r.word( - at + (kind === "program" ? (wide ? 8 : 4) : wide ? 24 : 16), - ); - const length = r.word( - at + (kind === "program" ? (wide ? 32 : 16) : wide ? 32 : 20), - ); + const offset = facts?.offset ?? r.word(at + (wide ? 24 : 16)); + const length = facts?.fileSize ?? r.word(at + (wide ? 32 : 20)); const failure = rangeFailure( `${kind} ${String(index + item)}`, offset, diff --git a/src/domain/elfProgramHeader.test.ts b/src/domain/elfProgramHeader.test.ts new file mode 100644 index 0000000000..d082a7cb7a --- /dev/null +++ b/src/domain/elfProgramHeader.test.ts @@ -0,0 +1,87 @@ +import { expect, it } from "vitest"; +import { readElfProgramHeader } from "./elfProgramHeader.js"; + +const formats = [ + { bits: 32, littleEndian: false }, + { bits: 32, littleEndian: true }, + { bits: 64, littleEndian: false }, + { bits: 64, littleEndian: true }, +] as const; + +it.each(formats)( + "preserves exact ELF$bits raw program fields (littleEndian=$littleEndian)", + ({ bits, littleEndian }) => { + const wide = bits === 64; + const length = wide ? 56 : 32; + const backing = Buffer.alloc(length + 19, 0xa5); + const bytes = backing.subarray(7, 7 + length); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const values = wide + ? [ + 0xffffffffffffffffn, + 0xfedcba9876543210n, + 0x8000000000000001n, + 0x123456789abcdef0n, + 0x8888888888888888n, + 0x7777777777777777n, + ] + : [ + 0xffffffffn, + 0xfedcba98n, + 0x80000001n, + 0x12345678n, + 0x88888888n, + 0x77777777n, + ]; + view.setUint32(0, 0xfedcba98, littleEndian); + view.setUint32(wide ? 4 : 24, 0x89abcdef, littleEndian); + const offsets = wide ? [8, 16, 24, 32, 40, 48] : [4, 8, 12, 16, 20, 28]; + values.forEach((value, index) => { + if (wide) view.setBigUint64(offsets[index]!, value, littleEndian); + else view.setUint32(offsets[index]!, Number(value), littleEndian); + }); + const before = Buffer.from(backing); + const parsed = readElfProgramHeader( + backing.subarray(7), + bits, + littleEndian, + ); + expect(backing.equals(before)).toBe(true); + backing.fill(0); + expect(parsed).toEqual({ + ok: true, + value: { + type: 0xfedcba98, + flags: 0x89abcdef, + offset: values[0], + virtualAddress: values[1], + physicalAddress: values[2], + fileSize: values[3], + memorySize: values[4], + alignment: values[5], + }, + }); + }, +); + +it.each(formats)( + "refuses each incomplete ELF$bits program record (littleEndian=$littleEndian)", + ({ bits, littleEndian }) => { + const length = bits === 64 ? 56 : 32; + const backing = Buffer.alloc(length + 7); + for (let observed = 0; observed < length; observed++) + expect( + readElfProgramHeader( + backing.subarray(7, 7 + observed), + bits, + littleEndian, + ), + ).toEqual({ + ok: false, + error: { + kind: "malformed", + message: "ELF program header is truncated", + }, + }); + }, +); diff --git a/src/domain/elfProgramHeader.ts b/src/domain/elfProgramHeader.ts new file mode 100644 index 0000000000..52475cb86d --- /dev/null +++ b/src/domain/elfProgramHeader.ts @@ -0,0 +1,30 @@ +import { err, ok } from "./result.js"; + +/** Fixed ELF32/ELF64 program declarations; consumers own type, precision and mapping admission. */ +export const readElfProgramHeader = ( + bytes: Uint8Array, + bits: 32 | 64, + littleEndian: boolean, +) => { + const wide = bits === 64; + if (bytes.byteLength < (wide ? 56 : 32)) + return err({ + kind: "malformed" as const, + message: "ELF program header is truncated", + }); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const word = (at: number): bigint => + wide + ? view.getBigUint64(at, littleEndian) + : BigInt(view.getUint32(at, littleEndian)); + return ok({ + type: view.getUint32(0, littleEndian), + flags: view.getUint32(wide ? 4 : 24, littleEndian), + offset: word(wide ? 8 : 4), + virtualAddress: word(wide ? 16 : 8), + physicalAddress: word(wide ? 24 : 12), + fileSize: word(wide ? 32 : 16), + memorySize: word(wide ? 40 : 20), + alignment: word(wide ? 48 : 28), + }); +}; diff --git a/src/flutter/DartElf.test.ts b/src/flutter/DartElf.test.ts index ac5cef7b31..28e6e67f3d 100644 --- a/src/flutter/DartElf.test.ts +++ b/src/flutter/DartElf.test.ts @@ -545,3 +545,24 @@ it("checks raw symbol precision only after definition and requested-name selecti ], }); }); + +it("keeps precision admission limited to consumed ELF program fields and types", async () => { + const original = buildElf64Image([{ name: "selected", value: 64, size: 4 }]); + const expected = await readElfSymbols(original, ["selected"]); + const bytes = appendElfRanges(original, "program", [ + { type: 0x70000001, offset: 0, size: 0 }, + ]); + const table = Number(bytes.readBigUInt64LE(32)); + const width = bytes.readUInt16LE(54); + for (const relative of [8, 16, 24, 32, 40, 48]) + bytes.writeBigUInt64LE(0xffffffffffffffffn, table + width + relative); + // The load mapping does not consume physical address, memory size or alignment. + for (const relative of [24, 40, 48]) + bytes.writeBigUInt64LE(0xffffffffffffffffn, table + relative); + expect(await readElfSymbols(bytes, ["selected"])).toEqual(expected); + bytes.writeBigUInt64LE(0xffffffffffffffffn, table + 32); + expect(await readElfSymbols(bytes, ["selected"])).toEqual({ + symbols: [], + failures: [{ reason: "ELF segment 0 is not file-backed" }], + }); +}); diff --git a/src/flutter/DartElf.ts b/src/flutter/DartElf.ts index b8a1131d01..6665e5de36 100644 --- a/src/flutter/DartElf.ts +++ b/src/flutter/DartElf.ts @@ -1,3 +1,4 @@ +import { readElfProgramHeader } from "../domain/elfProgramHeader.js"; import { readElfSymbol } from "../domain/elfSymbol.js"; import { readElfRawTableDeclarations } from "../domain/elfRawTableDeclarations.js"; import { Buffer } from "node:buffer"; @@ -199,15 +200,25 @@ export const readElfSymbols = async ( signal?.throwIfAborted(); } const base = e_phoff + index * e_phentsize; - const type = reader.u32(base); + const parsedProgram = readElfProgramHeader( + bytes.subarray(base, base + e_phentsize), + is64 ? 64 : 32, + true, + ); + if (!parsedProgram.ok) return failure(parsedProgram.error.message); + const program = parsedProgram.value; + const { type } = program; if (type !== PT_LOAD && type !== PT_DYNAMIC) continue; - const vaddr = is64 ? reader.u64(base + 0x10) : reader.u32(base + 8); - const offset = is64 ? reader.u64(base + 8) : reader.u32(base + 4); - const filesz = is64 ? reader.u64(base + 0x20) : reader.u32(base + 0x10); if ( - vaddr === null || - offset === null || - filesz === null || + program.virtualAddress > BigInt(Number.MAX_SAFE_INTEGER) || + program.offset > BigInt(Number.MAX_SAFE_INTEGER) || + program.fileSize > BigInt(Number.MAX_SAFE_INTEGER) + ) + return failure(`ELF segment ${String(index)} is not file-backed`); + const vaddr = Number(program.virtualAddress); + const offset = Number(program.offset); + const filesz = Number(program.fileSize); + if ( !Number.isSafeInteger(vaddr + filesz) || offset > bytes.length || filesz > bytes.length - offset diff --git a/src/flutter/ElfBuildId.ts b/src/flutter/ElfBuildId.ts index 0d6f9a453d..1b882ed72e 100644 --- a/src/flutter/ElfBuildId.ts +++ b/src/flutter/ElfBuildId.ts @@ -1,3 +1,4 @@ +import { readElfProgramHeader } from "../domain/elfProgramHeader.js"; import { MAX_FLUTTER_BUILD_ID_BYTES } from "../domain/flutter/flutterBuildAnalysis.js"; import type { FileHandle } from "node:fs/promises"; import { setImmediate as yieldToEventLoop } from "node:timers/promises"; @@ -97,21 +98,33 @@ export const readElfBuildIds = async ( signal?.throwIfAborted(); } const base = index * table.width; + const parsedProgram = table.program + ? readElfProgramHeader( + bytes.subarray(base, base + table.width), + h.is64 ? 64 : 32, + true, + ) + : undefined; + if (parsedProgram !== undefined && !parsedProgram.ok) + return failure(parsedProgram.error.message); + const program = parsedProgram?.value; if ( - bytes.readUInt32LE(base + (table.program ? 0 : 4)) !== + (program?.type ?? bytes.readUInt32LE(base + 4)) !== (table.program ? 4 : 7) ) continue; - const offsetAt = - base + (table.program ? (h.is64 ? 8 : 4) : h.is64 ? 0x18 : 0x10); - const sizeAt = - base + (table.program ? (h.is64 ? 0x20 : 0x10) : h.is64 ? 0x20 : 0x14); - const offset = h.is64 - ? Number(bytes.readBigUInt64LE(offsetAt)) - : bytes.readUInt32LE(offsetAt); - const size = h.is64 - ? Number(bytes.readBigUInt64LE(sizeAt)) - : bytes.readUInt32LE(sizeAt); + const offset = + program !== undefined + ? Number(program.offset) + : h.is64 + ? Number(bytes.readBigUInt64LE(base + 0x18)) + : bytes.readUInt32LE(base + 0x10); + const size = + program !== undefined + ? Number(program.fileSize) + : h.is64 + ? Number(bytes.readBigUInt64LE(base + 0x20)) + : bytes.readUInt32LE(base + 0x14); if ( !Number.isSafeInteger(offset) || !Number.isSafeInteger(size) || @@ -133,12 +146,11 @@ export const readElfBuildIds = async ( } } if (size === 0) continue; - const alignmentAt = - base + (table.program ? (h.is64 ? 48 : 28) : h.is64 ? 48 : 32); const alignment = elfNoteAlignment( - h.is64 - ? bytes.readBigUInt64LE(alignmentAt) - : bytes.readUInt32LE(alignmentAt), + program?.alignment ?? + (h.is64 + ? bytes.readBigUInt64LE(base + 48) + : bytes.readUInt32LE(base + 32)), ); const key = `${offset}:${size}:${alignment}`; if (ranges.has(key)) continue; diff --git a/src/native/elf/ElfNoteParser.test.ts b/src/native/elf/ElfNoteParser.test.ts index 5401b8c8bf..c7f16deb77 100644 --- a/src/native/elf/ElfNoteParser.test.ts +++ b/src/native/elf/ElfNoteParser.test.ts @@ -625,3 +625,15 @@ it("keeps unsafe table offsets ahead of unsupported extended numbering", () => { "ELF offset or size exceeds a safe integer.", ); }); + +it("does not apply note precision admission to unrelated program records", () => { + const bytes = elf64({ phoff: 64, phnum: 1 }, Buffer.alloc(56)); + bytes.writeUInt32LE(0x70000001, 64); + for (const relative of [8, 16, 24, 32, 40, 48]) + bytes.writeBigUInt64LE(0xffffffffffffffffn, 64 + relative); + expect(parse(bytes).tables).toEqual([]); + bytes.writeUInt32LE(4, 64); + expect(() => parse(bytes)).toThrow( + "ELF offset or size exceeds a safe integer.", + ); +}); diff --git a/src/native/elf/ElfNoteParser.ts b/src/native/elf/ElfNoteParser.ts index 9fe1876b3f..3dd994fd3a 100644 --- a/src/native/elf/ElfNoteParser.ts +++ b/src/native/elf/ElfNoteParser.ts @@ -1,3 +1,4 @@ +import { readElfProgramHeader } from "../../domain/elfProgramHeader.js"; import { readElfRawTableDeclarations } from "../../domain/elfRawTableDeclarations.js"; import { createHash } from "node:crypto"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; @@ -412,10 +413,23 @@ export const parseElfNotes = ( return fail("ELF program header table overlaps the native header."); for (let index = 0; index < phnum; index += 1) { const at = phoff + index * phentsize; - if (reader.u32(at) !== PT_NOTE) continue; - const fileOffset = reader.word(at + (wide ? 8 : 4)); - const fileSize = reader.word(at + (wide ? 32 : 16)); - const alignment = noteAlignment(reader.word(at + (wide ? 48 : 28))); + const parsedProgram = readElfProgramHeader( + bytes.subarray(at, at + phentsize), + header.elfClass, + reader.little, + ); + if (!parsedProgram.ok) return fail(parsedProgram.error.message); + const program = parsedProgram.value; + if (program.type !== PT_NOTE) continue; + if ( + program.offset > BigInt(Number.MAX_SAFE_INTEGER) || + program.fileSize > BigInt(Number.MAX_SAFE_INTEGER) || + program.alignment > BigInt(Number.MAX_SAFE_INTEGER) + ) + return fail("ELF offset or size exceeds a safe integer."); + const fileOffset = Number(program.offset); + const fileSize = Number(program.fileSize); + const alignment = noteAlignment(Number(program.alignment)); tables.push({ kind: "program", index, diff --git a/src/native/metadata/NativeSectionDirectory.ts b/src/native/metadata/NativeSectionDirectory.ts index 4a6dd31484..4aac10b5a3 100644 --- a/src/native/metadata/NativeSectionDirectory.ts +++ b/src/native/metadata/NativeSectionDirectory.ts @@ -1,3 +1,4 @@ +import { readElfProgramHeader } from "../../domain/elfProgramHeader.js"; import { readPeCoffHeader } from "../../domain/peCoffHeader.js"; import { readPeSectionHeader } from "../../domain/peSectionHeader.js"; import { readElfTableDeclarations } from "../../domain/elfDeclaredRanges.js"; @@ -115,33 +116,37 @@ export function* readElfSectionDirectory( } = sectionTable; const programDeclarations: ElfProgramDeclaration[] = []; if (program_count !== 0) { - const programs = new BinaryReader( - yield request( - fileSize, - program_offset, - BigInt(program_count) * BigInt(program_width), - ), - little, + const programBytes = yield request( + fileSize, + program_offset, + BigInt(program_count) * BigInt(program_width), ); // At most one record per table entry, bounded before allocation by the // existing 16 MiB table allowance and minimum 32/56-byte ELF record width. for (let index = 0; index < program_count; index++) { const at = index * program_width; - const type = programs.u32(at); + const parsedProgram = readElfProgramHeader( + programBytes.subarray(at, at + program_width), + bits, + little, + ); + if (!parsedProgram.ok) return reject(parsedProgram.error.message); + const program = parsedProgram.value; + const { type } = program; if (type === 1) programDeclarations.push({ type, - flags: programs.u32(at + (wide ? 4 : 24)), - file_offset: programs.word(at + (wide ? 8 : 4), bits), - virtual_address: programs.word(at + (wide ? 16 : 8), bits), - file_size: programs.word(at + (wide ? 32 : 16), bits), - memory_size: programs.word(at + (wide ? 40 : 20), bits), + flags: program.flags, + file_offset: program.offset, + virtual_address: program.virtualAddress, + file_size: program.fileSize, + memory_size: program.memorySize, }); else if (type === 2) programDeclarations.push({ type, - offset: programs.word(at + (wide ? 8 : 4), bits), - size: programs.word(at + (wide ? 32 : 16), bits), + offset: program.offset, + size: program.fileSize, }); } } diff --git a/src/native/pwntools/RecordedCrashStructureBindings.ts b/src/native/pwntools/RecordedCrashStructureBindings.ts index 9da9a64caf..524fa9c591 100644 --- a/src/native/pwntools/RecordedCrashStructureBindings.ts +++ b/src/native/pwntools/RecordedCrashStructureBindings.ts @@ -1,3 +1,4 @@ +import { readElfProgramHeader } from "../../domain/elfProgramHeader.js"; import { readElfRawTableDeclarations } from "../../domain/elfRawTableDeclarations.js"; import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { @@ -50,25 +51,26 @@ const segmentMatches = ( raw: Buffer, kinds: ReadonlyMap, ): boolean => { - const type = raw.readUInt32LE(0); + const parsed = readElfProgramHeader(raw, 64, true); + if (!parsed.ok) return false; + const program = parsed.value; + const { type } = program; return ( type === segment.type_id && matchesKind(type, segment.type, kinds) && - BigInt(segment.flags) === BigInt(raw.readUInt32LE(4)) && + BigInt(segment.flags) === BigInt(program.flags) && segment.file_backing === - (type === 0 || raw.readBigUInt64LE(32) === 0n ? "none" : "file") && + (type === 0 || program.fileSize === 0n ? "none" : "file") && ( [ - [segment.offset, 8], - [segment.virtual_address, 16], - [segment.physical_address, 24], - [segment.file_size, 32], - [segment.memory_size, 40], - [segment.alignment, 48], + [segment.offset, program.offset], + [segment.virtual_address, program.virtualAddress], + [segment.physical_address, program.physicalAddress], + [segment.file_size, program.fileSize], + [segment.memory_size, program.memorySize], + [segment.alignment, program.alignment], ] as const - ).every( - ([field, relative]) => BigInt(field) === raw.readBigUInt64LE(relative), - ) + ).every(([field, value]) => BigInt(field) === value) ); }; diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 19965f2b67..2206324447 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -1257,6 +1257,18 @@ it.each([ "tests/conformance/c/fixture.c", { code: true, fixtures: true, readiness: true, package: false }, ], + [ + "scripts/lib/conformance-fixtures.mjs", + { code: true, fixtures: true, readiness: true, package: false }, + ], + [ + "scripts/build-conformance-fixtures.mjs", + { code: true, fixtures: true, readiness: true, package: false }, + ], + [ + "scripts/verify-conformance-fixtures.mjs", + { code: true, fixtures: true, readiness: true, package: false }, + ], [ "tests/conformance/swift/fixture.swift", { code: true, fixtures: true, readiness: true, package: false }, diff --git a/tests/conformance/package/conformanceFixtureBinding.test.ts b/tests/conformance/package/conformanceFixtureBinding.test.ts new file mode 100644 index 0000000000..fd656e922b --- /dev/null +++ b/tests/conformance/package/conformanceFixtureBinding.test.ts @@ -0,0 +1,78 @@ +import { createHash } from "node:crypto"; +import { realpath, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { + bindConformanceFixtureArtifact, + resolveConformanceFixturePath, +} from "../../../scripts/lib/conformance-fixtures.mjs"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("binds canonical manifest-relative artifacts without changing declared names", async () => { + const root = await createTestTempDirectory("rea-conformance-binding-"); + const name = "caller α source artifact"; + const bytes = Buffer.from("inert fixture content"); + await writeFile(join(root, name), bytes); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const path = await realpath(join(root, name)); + const declaration = { + name: "fixture", + artifact: name, + artifactSha256: sha256, + }; + expect(await bindConformanceFixtureArtifact(path, declaration)).toEqual({ + path, + sha256, + }); + expect(declaration.artifact).toBe(name); + expect(await resolveConformanceFixturePath(root, name)).toBe(path); + await expect( + bindConformanceFixtureArtifact(path, { + ...declaration, + artifactSha256: "0".repeat(64), + }), + ).rejects.toThrow("digest did not match"); +}); + +it.each([ + null, + { name: "fixture", artifact: "artifact", artifactSha256: null }, + { name: "fixture", artifact: "artifact", artifactSha256: "A".repeat(64) }, +])( + "rejects malformed artifact declarations before reading files: %j", + async (declaration) => { + const root = await createTestTempDirectory("rea-conformance-declaration-"); + await expect( + bindConformanceFixtureArtifact(join(root, "artifact"), declaration), + ).rejects.toThrow("Invalid conformance artifact declaration"); + }, +); + +it("rejects paths outside the canonical manifest root", async () => { + const root = await createTestTempDirectory("rea-conformance-root-"); + const outside = await createTestTempDirectory("rea-conformance-outside-"); + const path = join(outside, "artifact"); + await writeFile(path, "outside"); + await expect(resolveConformanceFixturePath(root, path)).rejects.toThrow( + "escapes its root", + ); + await expect(resolveConformanceFixturePath(root, null)).rejects.toThrow( + "manifest path is invalid", + ); +}); + +it.skipIf(process.platform === "win32")( + "rejects artifact symlinks escaping their manifest root", + async () => { + const root = await createTestTempDirectory("rea-conformance-symlink-"); + const outside = await createTestTempDirectory( + "rea-conformance-symlink-outside-", + ); + const bytes = Buffer.from("outside"); + await writeFile(join(outside, "artifact"), bytes); + await symlink(join(outside, "artifact"), join(root, "artifact")); + await expect( + resolveConformanceFixturePath(root, "artifact"), + ).rejects.toThrow("escapes its root"); + }, +);