From 7fb097d72c9931dd5dfae481ad2b9d3bb6029f15 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:05 +0800 Subject: [PATCH] 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. --- scripts/verify-go-binary.mjs | 28 ++++++++---- src/go/GoMachoImage.ts | 35 +++++++++++---- tests/boundary/go/goBinaryImage.test.ts | 59 ++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 20 deletions(-) diff --git a/scripts/verify-go-binary.mjs b/scripts/verify-go-binary.mjs index 3652c35f87..0724c5f4da 100644 --- a/scripts/verify-go-binary.mjs +++ b/scripts/verify-go-binary.mjs @@ -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 ? {} diff --git a/src/go/GoMachoImage.ts b/src/go/GoMachoImage.ts index 3a2a2eab76..a178a06163 100644 --- a/src/go/GoMachoImage.ts +++ b/src/go/GoMachoImage.ts @@ -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( diff --git a/tests/boundary/go/goBinaryImage.test.ts b/tests/boundary/go/goBinaryImage.test.ts index f3793335de..0dc486a969 100644 --- a/tests/boundary/go/goBinaryImage.test.ts +++ b/tests/boundary/go/goBinaryImage.test.ts @@ -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); } };