From 93872822745e4e91ae0516d306053c32db8181cf Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:46:35 +0800 Subject: [PATCH] fix(ghidra): reconcile GNU MIPS ABI declarations Parse bounded GNU file-scope FP/MSA attributes once, retain absent versus uninspected declarations, reject conflicts and unsupported argument ABIs, and include observed facts in profile identity. LE/BE filesystem and profile regressions pass; real Ghidra/MIPS behavior remains unverified. Fixes #3096 Co-authored-by: Aurelio Bernal --- docs/ghidra-mips.md | 20 +- src/application/BinaryTargetResolver.ts | 1 + src/application/MipsElfMetadata.ts | 41 +++- src/application/MipsGnuAttributes.test.ts | 121 +++++++++++ src/application/MipsGnuAttributes.ts | 115 ++++++++++ src/domain/binaryTargetTypes.ts | 10 + src/ghidra/GhidraMipsProfile.test.ts | 12 +- src/ghidra/GhidraMipsProfile.ts | 40 +++- src/ghidra/GhidraPspProfile.ts | 8 +- .../filesystem/ghidraPspExtension.test.ts | 13 ++ .../boundary/filesystem/mipsAbiFlags.test.ts | 197 ++++++++++++++++-- .../filesystem/mipsSharedLoad.test.ts | 1 + 12 files changed, 545 insertions(+), 34 deletions(-) create mode 100644 src/application/MipsGnuAttributes.test.ts create mode 100644 src/application/MipsGnuAttributes.ts diff --git a/docs/ghidra-mips.md b/docs/ghidra-mips.md index 397741a6ad..45969f262b 100644 --- a/docs/ghidra-mips.md +++ b/docs/ghidra-mips.md @@ -52,9 +52,23 @@ section names or the record being inside the initial 4 KiB header probe. Program and section declarations must agree; duplicate, conflicting, truncated or out-of-file records are rejected. Stripped section tables and section-only records are supported. Extended table numbering is explicitly unsupported. -GNU `SHT_GNU_ATTRIBUTES` sections are refused by numeric section type until REA -parses and reconciles their declarations; section names and payload sizes do not -control detection or allocation. For carved ELFs whose section table extends +GNU `SHT_GNU_ATTRIBUTES` sections are located by numeric section type; section +names never control detection. REA reads at most 4096 payload bytes and parses +format version `A`, the `gnu` vendor's file-scope `Tag_GNU_MIPS_ABI_FP` (4) and +`Tag_GNU_MIPS_ABI_MSA` (8) only. Unknown vendors, scopes or tags, repeated +declarations, malformed lengths, and out-of-file or oversized payloads remain +refusals. A nonzero FP tag must match the separate ABI flags `fp_abi` record; +value 0 (`ANY`) declares no requirement. The [GNU Object Attributes v1 +format](https://sourceware.org/binutils/docs/as/Object-Attributes-v1.html) and +[GNU MIPS tag definitions](https://sourceware.org/binutils/docs/as/GNU-Object-Attributes.html) +are the format references. The MSA tag describes the vector +argument ABI, while the ABI flags ASE field describes instruction-set +extensions; REA does not treat those as interchangeable. The 128-bit MSA +argument ABI and unknown nonzero MSA values remain unsupported. Without a +separate ABI flags record, FP attribute values are admitted only when absent, +`ANY` (0), double precision (1), or FPXX (5), the values covered by the +recordless profile. Parsed values are retained in the MIPS analysis profile. +For carved ELFs whose section table extends beyond the captured file, REA examines every complete captured section header and records the unavailable headers as a limitation. The provider refuses an incomplete ABI declaration inspection even when a program-header ABI record is diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 7189ed7dec..355c7c12b5 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -551,6 +551,7 @@ const readExecutableMetadata = async ( mips: { ...parsed.value.mips, abiFlags: declarations.value.abiFlags, + gnuAttributes: declarations.value.gnuAttributes, ...(declarations.value.sharedLoad === undefined ? {} : { sharedLoad: declarations.value.sharedLoad }), diff --git a/src/application/MipsElfMetadata.ts b/src/application/MipsElfMetadata.ts index 38b2bccb8d..ae3a6ae916 100644 --- a/src/application/MipsElfMetadata.ts +++ b/src/application/MipsElfMetadata.ts @@ -2,6 +2,7 @@ import type { FileHandle } from "node:fs/promises"; import type { MipsAbiFlags, MipsElfMetadata, + MipsGnuAttributes, } from "../domain/binaryTargetTypes.js"; import { readElfFixedHeader } from "../domain/elfFixedHeader.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -9,12 +10,17 @@ import { readMipsElfSharedLoad, type MipsElfRelocationSection, } from "./MipsElfSharedLoad.js"; +import { + MIPS_GNU_ATTRIBUTES_MAX_BYTES, + parseMipsGnuAttributes, +} from "./MipsGnuAttributes.js"; const SHT_GNU_ATTRIBUTES = 0x6ffffff5; /** Observed ABI and load declarations, retaining unavailable source coverage. */ export interface MipsElfInspection { readonly abiFlags: MipsAbiFlags | null; + readonly gnuAttributes: MipsGnuAttributes | null; readonly limitations: readonly string[]; readonly sharedLoad?: MipsElfMetadata["sharedLoad"]; } @@ -120,6 +126,7 @@ export const readMipsElfMetadata = async ( let programHeaders: Buffer | undefined; const relocationSections: MipsElfRelocationSection[] = []; const limitations: string[] = []; + let gnuAttributes: MipsGnuAttributes | null = null; for (const table of tables) { if (table.count === 0) { if (table.offset !== 0) @@ -153,10 +160,25 @@ export const readMipsElfMetadata = async ( checkCancelled(); const position = i * table.entrySize; const type = u32(contents.value, position + table.typeOffset); - if (table.optional && type === SHT_GNU_ATTRIBUTES) - return err( - "MIPS ELF declares GNU ABI attributes that REA does not inspect; refusing provider selection until the declared ABI is parsed and reconciled", + if (table.optional && type === SHT_GNU_ATTRIBUTES) { + if (gnuAttributes !== null) + return err("duplicate MIPS GNU attribute sections"); + const size = u32(contents.value, position + 20); + if (size === 0 || size > MIPS_GNU_ATTRIBUTES_MAX_BYTES) + return err( + `MIPS GNU attribute section size ${size} is outside REA's inspected bound of ${MIPS_GNU_ATTRIBUTES_MAX_BYTES} bytes`, + ); + const section = await read( + u32(contents.value, position + 16), + size, + "GNU attribute section", ); + if (!section.ok) return section; + const parsed = parseMipsGnuAttributes(section.value, little); + if (!parsed.ok) return parsed; + gnuAttributes = parsed.value; + continue; + } if (metadata.type === 3 && table.optional && (type === 4 || type === 9)) relocationSections.push({ type, @@ -208,7 +230,7 @@ export const readMipsElfMetadata = async ( } : {}; if (record === undefined) - return ok({ abiFlags: null, limitations, ...shared }); + return ok({ abiFlags: null, gnuAttributes, limitations, ...shared }); // Preserve declarations verbatim, including unknown versions/values. Provider // admission, not parsing, decides which interpretations have been verified. const abiFlags: MipsAbiFlags = { @@ -224,5 +246,14 @@ export const readMipsElfMetadata = async ( flags1: u32(record, 16), flags2: u32(record, 20), }; - return ok({ abiFlags, limitations, ...shared }); + if ( + gnuAttributes !== null && + gnuAttributes.fpAbi !== null && + gnuAttributes.fpAbi !== 0 && + gnuAttributes.fpAbi !== abiFlags.fpAbi + ) + return err( + "conflicting MIPS ABI flags and GNU floating-point ABI declarations", + ); + return ok({ abiFlags, gnuAttributes, limitations, ...shared }); }; diff --git a/src/application/MipsGnuAttributes.test.ts b/src/application/MipsGnuAttributes.test.ts new file mode 100644 index 0000000000..309d076e67 --- /dev/null +++ b/src/application/MipsGnuAttributes.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { parseMipsGnuAttributes } from "./MipsGnuAttributes.js"; + +const u32 = (value: number, little: boolean): number[] => { + const bytes = Buffer.alloc(4); + if (little) bytes.writeUInt32LE(value); + else bytes.writeUInt32BE(value); + return [...bytes]; +}; + +const section = ( + attributes: readonly number[], + { + little = true, + vendor = "gnu", + scope = 1, + }: { + readonly little?: boolean; + readonly vendor?: string; + readonly scope?: number; + } = {}, +): Buffer => { + const scoped = [ + scope, + ...u32(1 + 4 + attributes.length, little), + ...attributes, + ]; + const name = [...Buffer.from(vendor, "latin1"), 0]; + return Buffer.from([ + 0x41, + ...u32(4 + name.length + scoped.length, little), + ...name, + ...scoped, + ]); +}; + +describe("MIPS GNU attribute parsing", () => { + it.each([true, false])( + "reads MIPS FP and MSA ABI attributes in ELF byte order (little=%s)", + (little) => { + expect( + parseMipsGnuAttributes(section([4, 1, 8, 0], { little }), little), + ).toEqual({ ok: true, value: { fpAbi: 1, msa: 0 } }); + }, + ); + + it("retains a multi-byte value and a missing tag separately", () => { + expect(parseMipsGnuAttributes(section([4, 0x85, 0x01]), true)).toEqual({ + ok: true, + value: { fpAbi: 133, msa: null }, + }); + expect(parseMipsGnuAttributes(section([]), true)).toEqual({ + ok: true, + value: { fpAbi: null, msa: null }, + }); + }); + + it.each([ + ["unknown version", Buffer.from([0x42]), /format version/u], + ["truncated vendor length", Buffer.from([0x41, 1, 0]), /truncated/u], + [ + "overrunning vendor subsection", + Buffer.from([0x41, ...u32(64, true), 0x67, 0x6e, 0x75, 0]), + /does not fit the section/u, + ], + [ + "unterminated vendor", + Buffer.from([0x41, ...u32(7, true), 0x67, 0x6e, 0x75]), + /unterminated/u, + ], + ["non-GNU vendor", section([4, 1], { vendor: "arm" }), /non-GNU vendor/u], + ["section scope", section([4, 1], { scope: 2 }), /sections or symbols/u], + ["symbol scope", section([4, 1], { scope: 3 }), /sections or symbols/u], + ["unknown tag", section([6, 1]), /tag 6/u], + [ + "duplicate FP tag", + section([4, 1, 4, 1]), + /duplicate MIPS GNU floating-point/u, + ], + ["duplicate MSA tag", section([8, 0, 8, 0]), /duplicate MIPS GNU MSA/u], + [ + "truncated value", + section([4, 0x81]), + /malformed MIPS GNU attribute value/u, + ], + [ + "value wider than 32 bits", + section([4, 0xff, 0xff, 0xff, 0xff, 0x1f]), + /malformed MIPS GNU attribute value/u, + ], + ] as const)("refuses %s", (_label, bytes, reason) => { + const result = parseMipsGnuAttributes(bytes, true); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(reason); + }); + + it("requires subsection lengths in the ELF byte order", () => { + expect( + parseMipsGnuAttributes(section([4, 1], { little: true }), false).ok, + ).toBe(false); + }); + + it("refuses a scope length beyond its vendor subsection", () => { + const bytes = section([4, 1]); + bytes.writeUInt32LE(64, 1 + 4 + 4 + 1); + const result = parseMipsGnuAttributes(bytes, true); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/scope length/u); + }); + + it("refuses a repeated GNU vendor subsection", () => { + const one = section([4, 1]); + const result = parseMipsGnuAttributes( + Buffer.concat([one, one.subarray(1)]), + true, + ); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error).toMatch(/duplicate MIPS GNU attribute vendor/u); + }); +}); diff --git a/src/application/MipsGnuAttributes.ts b/src/application/MipsGnuAttributes.ts new file mode 100644 index 0000000000..1d39f9bbbb --- /dev/null +++ b/src/application/MipsGnuAttributes.ts @@ -0,0 +1,115 @@ +import type { MipsGnuAttributes } from "../domain/binaryTargetTypes.js"; +import { err, ok, type Result } from "../domain/result.js"; + +/** Bound the section read before its declared size can drive allocation. */ +export const MIPS_GNU_ATTRIBUTES_MAX_BYTES = 4096; + +const FORMAT_VERSION = 0x41; +const TAG_FILE = 1; +const TAG_GNU_MIPS_ABI_FP = 4; +const TAG_GNU_MIPS_ABI_MSA = 8; + +/** + * Read only GNU file-scope MIPS ABI attributes REA understands. Unknown + * vendors, scopes and tags fail closed; their declarations are not skipped. + */ +export const parseMipsGnuAttributes = ( + bytes: Buffer, + littleEndian: boolean, +): Result => { + if (bytes.length === 0 || bytes[0] !== FORMAT_VERSION) + return err("unsupported MIPS GNU attribute section format version"); + + const readU32 = (offset: number): number => + littleEndian ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset); + const readUleb = ( + offset: number, + end: number, + ): { readonly value: number; readonly next: number } | undefined => { + let value = 0; + for (let index = 0; index < 5 && offset + index < end; index += 1) { + const byte = bytes[offset + index]!; + value += (byte & 0x7f) * 2 ** (7 * index); + if ((byte & 0x80) === 0) + return value <= 0xffffffff + ? { value, next: offset + index + 1 } + : undefined; + } + return undefined; + }; + + let fpAbi: number | null = null; + let msa: number | null = null; + let vendorSeen = false; + let position = 1; + while (position < bytes.length) { + if (bytes.length - position < 4) + return err("truncated MIPS GNU attribute subsection length"); + const length = readU32(position); + if (length < 4 || length > bytes.length - position) + return err( + "MIPS GNU attribute subsection length does not fit the section", + ); + const end = position + length; + const vendorEnd = bytes.indexOf(0, position + 4); + if (vendorEnd < 0 || vendorEnd >= end) + return err("unterminated MIPS GNU attribute vendor name"); + if (bytes.toString("latin1", position + 4, vendorEnd) !== "gnu") + return err( + "MIPS ELF declares non-GNU vendor attributes that REA does not inspect", + ); + if (vendorSeen) + return err("duplicate MIPS GNU attribute vendor subsections"); + vendorSeen = true; + + let cursor = vendorEnd + 1; + while (cursor < end) { + const scopeStart = cursor; + const scope = readUleb(cursor, end); + if (scope === undefined) + return err("malformed MIPS GNU attribute scope tag"); + if (scope.value !== TAG_FILE) + return err( + "MIPS GNU attributes scoped to sections or symbols are not inspected by REA", + ); + if (end - scope.next < 4) + return err("truncated MIPS GNU attribute scope length"); + const scopeLength = readU32(scope.next); + if ( + scopeLength < scope.next + 4 - scopeStart || + scopeLength > end - scopeStart + ) + return err( + "MIPS GNU attribute scope length does not fit its subsection", + ); + const scopeEnd = scopeStart + scopeLength; + cursor = scope.next + 4; + while (cursor < scopeEnd) { + const tag = readUleb(cursor, scopeEnd); + if (tag === undefined) return err("malformed MIPS GNU attribute tag"); + if ( + tag.value !== TAG_GNU_MIPS_ABI_FP && + tag.value !== TAG_GNU_MIPS_ABI_MSA + ) + return err( + `MIPS ELF declares GNU attribute tag ${tag.value}, which REA does not inspect`, + ); + const value = readUleb(tag.next, scopeEnd); + if (value === undefined) + return err("malformed MIPS GNU attribute value"); + if (tag.value === TAG_GNU_MIPS_ABI_FP) { + if (fpAbi !== null) + return err("duplicate MIPS GNU floating-point ABI attributes"); + fpAbi = value.value; + } else { + if (msa !== null) return err("duplicate MIPS GNU MSA ABI attributes"); + msa = value.value; + } + cursor = value.next; + } + cursor = scopeEnd; + } + position = end; + } + return ok({ fpAbi, msa }); +}; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 2cd6ae2078..049158cac5 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -58,6 +58,8 @@ export type HeaderArchitecture = Exclude< export interface MipsElfMetadata extends ElfFamilyMetadata { /** Undefined until inspected; null means no ABI record was observed. */ readonly abiFlags?: MipsAbiFlags | null; + /** Undefined until inspected; null means no GNU attribute section was observed. */ + readonly gnuAttributes?: MipsGnuAttributes | null; /** Unavailable declaration sources; missing records there remain unknown. */ readonly limitations?: readonly string[]; /** Complete dynamic REL declarations, or the reason they could not be inspected. */ @@ -86,6 +88,14 @@ export interface MipsAbiFlags { readonly flags2: number; } +/** File-scope GNU object attributes parsed from `SHT_GNU_ATTRIBUTES`. */ +export interface MipsGnuAttributes { + /** Tag_GNU_MIPS_ABI_FP; null means this file did not declare the tag. */ + readonly fpAbi: number | null; + /** Tag_GNU_MIPS_ABI_MSA vector-argument ABI; null means undeclared. */ + readonly msa: number | null; +} + /** * ELF class and flags retain the width and ABI declarations used by this lane. * ISA extensions in `.riscv.attributes` are not inspected by this header parser. diff --git a/src/ghidra/GhidraMipsProfile.test.ts b/src/ghidra/GhidraMipsProfile.test.ts index 57c04daefd..1e64df9eb5 100644 --- a/src/ghidra/GhidraMipsProfile.test.ts +++ b/src/ghidra/GhidraMipsProfile.test.ts @@ -37,6 +37,7 @@ const target = (change: Partial = {}): BinaryTarget => ({ byteOrder: "little", type: 2, flags: 0x70001001, + gnuAttributes: null, abiFlags: { version: 0, isaLevel: 32, @@ -95,6 +96,7 @@ describe("bounded Ghidra MIPS admission", () => { flags1: 0, flags2: 0, }, + gnu_attributes: null, }); expect(left.value.profile?.digest).not.toBe(right.value.profile?.digest); const windows = new GhidraProvider( @@ -470,7 +472,7 @@ describe("MIPS16 shared-image profile", () => { ); }, ); - it.each(["sharedLoad", "abiFlags"] as const)( + it.each(["sharedLoad", "abiFlags", "gnuAttributes"] as const)( "refuses an uninspected %s declaration", (declaration) => { const value = shared(); @@ -484,9 +486,11 @@ describe("MIPS16 shared-image profile", () => { ).toMatchObject({ status: "unsupported", reason: expect.stringContaining( - declaration === "abiFlags" - ? "inspected ABI flags" - : "have not been inspected", + { + abiFlags: "inspected ABI flags", + gnuAttributes: "GNU MIPS ABI attribute inspection is unavailable", + sharedLoad: "have not been inspected", + }[declaration], ), }); }, diff --git a/src/ghidra/GhidraMipsProfile.ts b/src/ghidra/GhidraMipsProfile.ts index 51ac546f27..52debfd750 100644 --- a/src/ghidra/GhidraMipsProfile.ts +++ b/src/ghidra/GhidraMipsProfile.ts @@ -1,5 +1,8 @@ import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import type { + BinaryTarget, + MipsElfMetadata, +} from "../domain/binaryTargetTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; const MIPS_FP_ABI_SOFT = 3; @@ -65,6 +68,27 @@ const hasLegacyMipsHeaderShape = (target: BinaryTarget): boolean => { ); }; +/** Return a refusal for GNU ABI attributes this existing MIPS lane cannot model. */ +export const ghidraMipsGnuAttributesUnsupportedReason = ( + metadata: MipsElfMetadata, +): string | null => { + const attributes = metadata.gnuAttributes; + if (attributes === undefined) + return "GNU MIPS ABI attribute inspection is unavailable; provider selection cannot treat it as absent."; + if (attributes === null) return null; + if (attributes.msa !== null && attributes.msa !== 0) + return "The GNU MIPS MSA ABI attribute declares an unsupported or unknown vector argument ABI."; + if ( + metadata.abiFlags === null && + attributes.fpAbi !== null && + attributes.fpAbi !== 0 && + attributes.fpAbi !== 1 && + attributes.fpAbi !== 5 + ) + return "Without a MIPS ABI flags record, the GNU floating-point ABI attribute must be absent, any, double-precision, or FPXX for the verified recordless lane."; + return null; +}; + /** Keep family recognition separate from the deliberately bounded Ghidra lane. */ export const ghidraMipsUnsupportedReason = ( target: BinaryTarget, @@ -77,6 +101,9 @@ export const ghidraMipsUnsupportedReason = ( const shared = isGhidraMips16SharedTarget(target); if (metadata.elfClass !== 32) return "This Ghidra MIPS lane supports ELF32 only; MIPS64 and n64 remain unsupported."; + const gnuAttributesReason = + ghidraMipsGnuAttributesUnsupportedReason(metadata); + if (gnuAttributesReason !== null) return gnuAttributesReason; if ( metadata.type !== MIPS_ET_EXEC && metadata.type !== MIPS_ET_REL && @@ -297,6 +324,17 @@ export const ghidraMipsProfileParameters = ( flags1: abi.flags1, flags2: abi.flags2, }, + ...(target.mips.gnuAttributes === undefined + ? {} + : { + gnu_attributes: + target.mips.gnuAttributes === null + ? null + : { + fp_abi: target.mips.gnuAttributes.fpAbi, + msa: target.mips.gnuAttributes.msa, + }, + }), ...(target.mips.sharedLoad === undefined ? {} : { diff --git a/src/ghidra/GhidraPspProfile.ts b/src/ghidra/GhidraPspProfile.ts index 3550d62ab2..8e87073f83 100644 --- a/src/ghidra/GhidraPspProfile.ts +++ b/src/ghidra/GhidraPspProfile.ts @@ -1,5 +1,8 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; -import { ghidraMipsUnsupportedReason } from "./GhidraMipsProfile.js"; +import { + ghidraMipsGnuAttributesUnsupportedReason, + ghidraMipsUnsupportedReason, +} from "./GhidraMipsProfile.js"; import { ghidraPowerPcUnsupportedReason } from "./GhidraPowerPcProfile.js"; import { ghidraRiscvUnsupportedReason } from "./GhidraRiscvProfile.js"; @@ -27,6 +30,9 @@ export const ghidraProcessorUnsupportedReason = ( return ghidraMipsUnsupportedReason(target); const metadata = target.mips; if (metadata === undefined) return "PSP analysis requires ELF metadata."; + const gnuAttributesReason = + ghidraMipsGnuAttributesUnsupportedReason(metadata); + if (gnuAttributesReason !== null) return gnuAttributesReason; if (metadata.elfClass !== 32 || metadata.byteOrder !== "little") return "The PSP Allegrex profile requires little-endian ELF32."; if (metadata.type !== 2) diff --git a/tests/boundary/filesystem/ghidraPspExtension.test.ts b/tests/boundary/filesystem/ghidraPspExtension.test.ts index 7be601d4b8..8872e4a7ed 100644 --- a/tests/boundary/filesystem/ghidraPspExtension.test.ts +++ b/tests/boundary/filesystem/ghidraPspExtension.test.ts @@ -46,6 +46,7 @@ const target = { byteOrder: "little", type: 2, flags: 0x10a23001, + gnuAttributes: null, abiFlags: pspAbiFlags, }, } satisfies BinaryTarget; @@ -312,6 +313,16 @@ describe.skipIf(process.platform === "win32")( expect(result.error._tag).toBe("AnalysisUnsupportedTargetError"); }, ); + + it("refuses a separately declared unsupported MSA argument ABI", async () => { + const { provider } = await installation(); + const altered = { + ...target, + mips: { ...target.mips, gnuAttributes: { fpAbi: 2, msa: 1 } }, + } as BinaryTarget; + expect(ghidraProcessorUnsupportedReason(altered)).toMatch(/MSA ABI/u); + expect(provider.inspectTargetSupport(altered).status).toBe("unsupported"); + }); }, ); @@ -388,6 +399,7 @@ describe.skipIf(process.platform === "win32")( byteOrder: "little", type: 2, flags: 0x10a23001, + gnuAttributes: null, abiFlags: { ...pspAbiFlags, [field]: value }, }, }; @@ -416,6 +428,7 @@ describe.skipIf(process.platform === "win32")( byteOrder: "little", type: 2, flags: 0x10a23001, + gnuAttributes: null, ...(abiFlags === undefined ? {} : { abiFlags }), }, }; diff --git a/tests/boundary/filesystem/mipsAbiFlags.test.ts b/tests/boundary/filesystem/mipsAbiFlags.test.ts index e89f5cdbff..6d26ac4ec6 100644 --- a/tests/boundary/filesystem/mipsAbiFlags.test.ts +++ b/tests/boundary/filesystem/mipsAbiFlags.test.ts @@ -2,9 +2,10 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { open, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { it } from "vitest"; +import { expect, it } from "vitest"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { jsonObjectSchema } from "../../../src/domain/jsonValue.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import type { MipsElfMetadata } from "../../../src/domain/binaryTargetTypes.js"; import { readMipsElfMetadata } from "../../../src/application/MipsElfMetadata.js"; @@ -53,6 +54,62 @@ const fixture = (little = true): Buffer => { bytes.set([0, 0, 32, 2, 1, 1, 0, 5], abiOffset); return bytes; }; +const attributeSection = ( + attributes: readonly number[], + little = true, +): Buffer => { + const subsectionLength = 1 + 4 + attributes.length; + const vendor = Buffer.from("gnu\0", "latin1"); + const sectionLength = 4 + vendor.length + subsectionLength; + const bytes = Buffer.alloc(1 + sectionLength); + bytes[0] = 0x41; + if (little) { + bytes.writeUInt32LE(sectionLength, 1); + bytes.writeUInt32LE(subsectionLength, 1 + 4 + vendor.length + 1); + } else { + bytes.writeUInt32BE(sectionLength, 1); + bytes.writeUInt32BE(subsectionLength, 1 + 4 + vendor.length + 1); + } + vendor.copy(bytes, 5); + bytes[9] = 1; // Tag_File scope. + bytes.set(attributes, 14); + return bytes; +}; +const withGnuAttributes = ( + source: Buffer, + payloads: readonly Buffer[], + little = true, +): Buffer => { + const payloadStart = abiOffset + 24; + const payloadBytes = payloads.reduce( + (total, payload) => total + payload.length, + 0, + ); + const bytes = Buffer.alloc( + Math.max(source.length, payloadStart + payloadBytes), + ); + source.copy(bytes); + let payloadOffset = payloadStart; + const write16 = (value: number, offset: number) => + little + ? bytes.writeUInt16LE(value, offset) + : bytes.writeUInt16BE(value, offset); + const write32 = (value: number, offset: number) => + little + ? bytes.writeUInt32LE(value, offset) + : bytes.writeUInt32BE(value, offset); + write16(2 + payloads.length, 48); + write16(0, 50); // Section names are not needed for numeric-type detection. + payloads.forEach((payload, index) => { + const header = 128 + (2 + index) * 40; + write32(0x6ffffff5, header + 4); + write32(payloadOffset, header + 16); + write32(payload.length, header + 20); + payload.copy(bytes, payloadOffset); + payloadOffset += payload.length; + }); + return bytes; +}; const writeFixture = async (bytes: Buffer): Promise => { const directory = await createTestTempDirectory("rea-mips-abi-test-"); const path = join(directory, "fixture.elf"); @@ -77,7 +134,7 @@ const resolveFixture = async (bytes: Buffer) => { assert.deepEqual(await readFile(path), bytes); return result.value; }; -const inspect = async ( +const inspectDeclarations = async ( bytes: Buffer, little = true, checkCancelled: () => void = () => {}, @@ -90,11 +147,19 @@ const inspect = async ( metadata(little), checkCancelled, ); - return result.ok ? ok(result.value.abiFlags) : result; + return result; } finally { await handle.close(); } }; +const inspect = async ( + bytes: Buffer, + little = true, + checkCancelled: () => void = () => {}, +) => { + const result = await inspectDeclarations(bytes, little, checkCancelled); + return result.ok ? ok(result.value.abiFlags) : result; +}; const target = (mips: MipsElfMetadata) => ({ path: "/fixture.elf", sha256: "a".repeat(64), @@ -102,7 +167,7 @@ const target = (mips: MipsElfMetadata) => ({ format: "elf" as const, architecture: "mips" as const, availableArchitectures: ["mips"] as const, - mips, + mips: { gnuAttributes: null, ...mips }, }); for (const little of [true, false]) { @@ -374,25 +439,117 @@ it("projects a malformed out-of-probe ABI record as a target error", async () => }); for (const little of [true, false]) { - it(`refuses GNU ABI attributes by section type without reading attacker-controlled names or sizes (${little ? "LE" : "BE"})`, async () => { - const bytes = fixture(little); - const write16 = little - ? (value: number, offset: number) => bytes.writeUInt16LE(value, offset) - : (value: number, offset: number) => bytes.writeUInt16BE(value, offset); - const write32 = little - ? (value: number, offset: number) => bytes.writeUInt32LE(value, offset) - : (value: number, offset: number) => bytes.writeUInt32BE(value, offset); - write32(0xffffffff, 168); // Unknown name; the section type is authoritative. - write32(0x6ffffff5, 172); // SHT_GNU_ATTRIBUTES. - write32(0xfffffff0, 184); // Must not be read as a payload offset. - write32(0xffffffff, 188); // Must not control allocation. - write16(0, 50); // No section-name string table is required for detection. - const result = await inspect(bytes, little); - assert.ok(!result.ok); - assert.match(result.error, /GNU ABI attributes that REA does not inspect/u); + it(`reads bounded GNU FP/MSA attributes through the full target resolver (${little ? "LE" : "BE"})`, async () => { + const bytes = withGnuAttributes( + fixture(little), + [attributeSection([4, 5, 8, 0], little)], + little, + ); + const result = await inspectDeclarations(bytes, little); + assert.ok(result.ok); + assert.deepEqual(result.value.gnuAttributes, { fpAbi: 5, msa: 0 }); + const resolved = await resolveFixture(bytes); + assert.deepEqual(resolved.mips.gnuAttributes, { fpAbi: 5, msa: 0 }); + assert.equal(ghidraMipsUnsupportedReason(resolved), null); + const profileParameters = jsonObjectSchema.parse( + ghidraMipsProfileParameters(resolved), + ); + expect(profileParameters.mips_elf).toMatchObject({ + gnu_attributes: { fp_abi: 5, msa: 0 }, + }); }); } +it("reconciles FP tags with ABI flags while treating FP ANY as no requirement", async () => { + const any = withGnuAttributes(fixture(), [attributeSection([4, 0])]); + const accepted = await inspectDeclarations(any); + assert.ok(accepted.ok); + assert.equal(accepted.value.gnuAttributes?.fpAbi, 0); + + const conflict = withGnuAttributes(fixture(), [attributeSection([4, 1])]); + const rejected = await inspectDeclarations(conflict); + assert.ok(!rejected.ok); + assert.match( + rejected.error, + /conflicting MIPS ABI flags and GNU floating-point ABI/u, + ); +}); + +it("retains unsupported MSA declarations but refuses provider selection", async () => { + const bytes = withGnuAttributes(fixture(), [attributeSection([8, 1])]); + const resolved = await resolveFixture(bytes); + assert.deepEqual(resolved.mips.gnuAttributes, { fpAbi: null, msa: 1 }); + assert.match( + ghidraMipsUnsupportedReason(resolved) ?? "", + /MSA ABI attribute declares an unsupported/u, + ); +}); + +it.each([ + [0, null], + [1, null], + [5, null], + [2, /GNU floating-point ABI attribute/u], + [3, /GNU floating-point ABI attribute/u], +] as const)( + "applies recordless admission to GNU FP value %i", + async (fpAbi, reason) => { + const bytes = fixture(); + bytes.writeUInt32LE(0x50000001, 36); // Recordless MIPS32 legacy header. + bytes.writeUInt32LE(0, 52); // No PT_MIPS_ABIFLAGS. + bytes.writeUInt32LE(0, 172); // No SHT_MIPS_ABIFLAGS. + const withAttributes = withGnuAttributes(bytes, [ + attributeSection([4, fpAbi]), + ]); + const resolved = await resolveFixture(withAttributes); + const unsupported = ghidraMipsUnsupportedReason(resolved); + if (reason === null) assert.equal(unsupported, null); + else assert.match(unsupported ?? "", reason); + }, +); + +it.each([ + ["an unknown attribute tag", [6, 1], /tag 6/u], + [ + "a duplicate attribute section", + [4, 5], + /duplicate MIPS GNU attribute sections/u, + ], +] as const)( + "refuses %s while inspecting section contents", + async (label, attr, reason) => { + const payload = attributeSection(attr); + const bytes = withGnuAttributes( + fixture(), + label === "a duplicate attribute section" + ? [payload, payload] + : [payload], + ); + const result = await inspectDeclarations(bytes); + assert.ok(!result.ok); + assert.match(result.error, reason); + }, +); + +it("checks the GNU attribute size bound before attempting its file read", async () => { + const bytes = withGnuAttributes(fixture(), [attributeSection([4, 5])]); + bytes.writeUInt32LE(0xfffffff0, 224); + bytes.writeUInt32LE(4097, 228); + const result = await inspectDeclarations(bytes); + assert.ok(!result.ok); + assert.match(result.error, /outside REA's inspected bound of 4096 bytes/u); +}); + +it("uses section type and bounds the GNU attribute payload extent", async () => { + const bytes = withGnuAttributes(fixture(), [attributeSection([4, 5])]); + bytes.writeUInt32LE(0xffffffff, 208); // Unknown name; type remains authoritative. + bytes.writeUInt16LE(0, 50); // No section-name string table is required. + bytes.writeUInt32LE(bytes.length + 1, 224); + const result = await inspectDeclarations(bytes); + assert.ok(!result.ok); + assert.match(result.error, /GNU attribute section lies outside the file/u); +}); + it("preserves cancellation instead of turning it into malformed target data", async () => { const directory = await createTestTempDirectory("rea-mips-resolution-"); const path = join(directory, "fixture.elf"); diff --git a/tests/boundary/filesystem/mipsSharedLoad.test.ts b/tests/boundary/filesystem/mipsSharedLoad.test.ts index 6ad6a38c27..ba88cdb780 100644 --- a/tests/boundary/filesystem/mipsSharedLoad.test.ts +++ b/tests/boundary/filesystem/mipsSharedLoad.test.ts @@ -90,6 +90,7 @@ for (const little of [true, false]) { type: 3, flags: 0x74001005, abi_flags: null, + gnu_attributes: null, shared_load: { status: "complete", relocation_count: 2,