fix(go): accept file-backed unmapped Mach-O debug segments

Validate unmapped segment file ranges without adding virtual mappings or fallback metadata addresses. Keep mapped-segment bounds and reject build-info sections declared in unmapped segments.

Validation: real unstripped Go 1.26.5 Darwin arm64 and amd64 artifacts passed CLI/MCP oracle, Evidence, source-range and unchanged-byte checks. Retained malformed-input regressions; removed the weaker synthetic acceptance test. Full cross-platform verifier not run. Fixes #2167.
This commit is contained in:
morluto
2026-10-13 03:47:05 +08:00
parent c9fed50691
commit 7fb097d72c
3 changed files with 102 additions and 20 deletions
+19 -9
View File
@@ -115,16 +115,25 @@ try {
],
{ GOOS: os, GOARCH: arch },
);
await verify(path, target, oracle);
await verify(path, target, oracle, null, "stripped");
if (os === "linux" && arch === "amd64") {
const sectionless = join(root.path, "linux-amd64-sectionless");
const bytes = Buffer.from(await readFile(path));
bytes.fill(0, 40, 48); // ELF64 e_shoff.
bytes.fill(0, 58, 64); // e_shentsize, e_shnum and e_shstrndx.
await writeFile(sectionless, bytes);
await verify(sectionless, target, oracle);
await verify(sectionless, target, oracle, null, "sectionless");
}
}
for (const target of targets.filter(([os]) => os === "darwin")) {
const [os, arch] = target;
const path = join(root.path, `${os}-${arch}-unstripped`);
await go(["build", "-trimpath", "-buildvcs=false", "-o", path, "."], {
GOOS: os,
GOARCH: arch,
});
await verify(path, target, oracle, null, "unstripped");
}
if (process.platform === "win32")
omittedCoverage.push(
"Compiler-produced non-UTF-8 argv strings were not verified on Windows; Windows argv cannot preserve arbitrary byte arguments.",
@@ -317,7 +326,13 @@ async function inspect(mode, path) {
return { evidence, report };
}
async function verify(path, target, oracle, byteExpected = null) {
async function verify(
path,
target,
oracle,
byteExpected = null,
fileKind = "stripped",
) {
const bytes = await readFile(path);
const expected =
byteExpected ??
@@ -387,12 +402,7 @@ async function verify(path, target, oracle, byteExpected = null) {
);
verified.push({
target: `${target[0]}/${target[1]}`,
file:
byteExpected === null
? path.endsWith("sectionless")
? "sectionless"
: "stripped"
: "byte-valued-strings",
file: byteExpected === null ? fileKind : "byte-valued-strings",
go_version: expected.GoVersion,
...(byteExpected === null
? {}
+26 -9
View File
@@ -63,7 +63,11 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
const maxProtection = reader.u32(offset + (wide ? 56 : 40));
const protection = reader.u32(offset + (wide ? 60 : 44));
const sectionCount = reader.u32(offset + (wide ? 64 : 48));
if (fileSize > memorySize || address + memorySize > 1n << BigInt(bits))
const unmapped = memorySize === 0n && protection === 0;
if (
(!unmapped && fileSize > memorySize) ||
address + memorySize > 1n << BigInt(bits)
)
throw new GoBinaryFormatFailure(
"malformed",
"Mach-O segment addresses or sizes are invalid",
@@ -73,16 +77,19 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
"malformed",
"Mach-O section table exceeds its segment command",
);
const mapping = {
...(fileSize === 0n
? { offset: 0, size: 0 }
: reader.fileRange(fileOffset, fileSize, "Mach-O segment")),
address,
};
const fileRange =
fileSize === 0n
? undefined
: reader.fileRange(fileOffset, fileSize, "Mach-O segment");
const mapping =
unmapped || fileRange === undefined
? undefined
: { ...fileRange, address };
const name = fixedName(
reader.range(offset + 8, 16, "Mach-O segment name"),
);
if (mapping.size !== 0 && name !== "__PAGEZERO") mappings.push(mapping);
if (mapping !== undefined && name !== "__PAGEZERO")
mappings.push(mapping);
if (memorySize > fileSize && name !== "__PAGEZERO")
zeroFills.push({
address: address + fileSize,
@@ -90,8 +97,8 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
});
if (
fallback === null &&
mapping !== undefined &&
address !== 0n &&
mapping.size !== 0 &&
protection === 3 &&
maxProtection === 3
)
@@ -103,6 +110,16 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
"__go_buildinfo"
)
continue;
if (unmapped)
throw new GoBinaryFormatFailure(
"malformed",
"Mach-O build-info section is in an unmapped segment",
);
if (mapping === undefined)
throw new GoBinaryFormatFailure(
"malformed",
"Mach-O build-info section is not file mapped",
);
const sectionFlags = reader.u32(section + (wide ? 64 : 56));
if ([1, 0xc, 0x12].includes(sectionFlags & 0xff))
throw new GoBinaryFormatFailure(
+57 -2
View File
@@ -436,6 +436,59 @@ it("does not treat Mach-O __PAGEZERO as a module string header", () => {
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(/mapped|range/i);
});
it("rejects out-of-file bytes in an unmapped Mach-O segment", () => {
const fixture = createGoBinaryFixture({ format: "macho" });
addLegacyMapping(fixture.bytes, "macho", {
address: 0x20000n,
fileOffset: 3072,
fileSize: 1025,
memorySize: 0,
protection: 0,
});
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(
/outside the file/i,
);
});
it("rejects oversized Mach-O file contents when initial protection is set", () => {
const fixture = createGoBinaryFixture({ format: "macho" });
addLegacyMapping(fixture.bytes, "macho", {
address: 0x20000n,
fileOffset: 3072,
fileSize: 512,
memorySize: 0,
protection: 3,
});
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(
/addresses or sizes are invalid/i,
);
});
it("rejects build-info declared inside an unmapped Mach-O segment", () => {
const fixture = createGoBinaryFixture({ format: "macho" });
const command = 32 + 72 + 80;
const size = 72;
fixture.bytes.writeUInt32LE(2, 16);
fixture.bytes.writeUInt32LE(304, 20);
fixture.bytes.writeUInt32LE(0x19, command);
fixture.bytes.writeUInt32LE(size + 80, command + 4);
fixture.bytes.write("__DWARF", command + 8);
fixture.bytes.writeBigUInt64LE(0x10000n, command + 24);
fixture.bytes.writeBigUInt64LE(0n, command + 32);
fixture.bytes.writeBigUInt64LE(3072n, command + 40);
fixture.bytes.writeBigUInt64LE(512n, command + 48);
fixture.bytes.writeUInt32LE(3, command + 56);
fixture.bytes.writeUInt32LE(0, command + 60);
fixture.bytes.writeUInt32LE(1, command + 64);
const section = command + size;
fixture.bytes.write("__go_buildinfo", section);
fixture.bytes.write("__DWARF", section + 16);
fixture.bytes.writeBigUInt64LE(0x10000n, section + 32);
fixture.bytes.writeBigUInt64LE(0n, section + 40);
fixture.bytes.writeUInt32LE(3072, section + 48);
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(/unmapped/i);
});
it.each(["elf", "pe", "macho"] as const)(
"refuses file aliases overlapping only part of a zero-filled %s module header",
(format) => {
@@ -541,11 +594,13 @@ const addLegacyMapping = (
fileOffset,
fileSize,
memorySize,
protection = 3,
}: {
address: bigint;
fileOffset: number;
fileSize: number;
memorySize: number;
protection?: number;
},
{ bits, little }: { bits: 32 | 64; little: boolean } = {
bits: 64,
@@ -589,7 +644,7 @@ const addLegacyMapping = (
word(entry + (wide ? 32 : 28), BigInt(memorySize));
word(entry + (wide ? 40 : 32), BigInt(fileOffset));
word(entry + (wide ? 48 : 36), BigInt(fileSize));
u32(entry + (wide ? 56 : 40), 3);
u32(entry + (wide ? 60 : 44), 3);
u32(entry + (wide ? 56 : 40), protection);
u32(entry + (wide ? 60 : 44), protection);
}
};