From 3cfa00a29c1eb531faca9542935446e6d751ff4c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:17:03 +0800 Subject: [PATCH] fix(ghidra): preserve valid Unicode language metadata Validate XML bytes before shared syntax parsing so literal U+FFFD remains valid while malformed bytes and declaration mismatches fail at their producing boundary. Validation: 92 focused checks, 3 selected fake Apktool boundary checks, 6 final encoding controls, check:fast and docs:check. Ghidra coverage uses tiny real files and descriptors, without a real engine or Windows qualification. Refs #4712 --- docs/installation.md | 2 + src/ghidra/GhidraLanguageCatalog.ts | 18 +++-- .../ghidraLanguageCatalogEncoding.test.ts | 68 +++++++++++++++++++ 3 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 tests/boundary/filesystem/ghidraLanguageCatalogEncoding.test.ts diff --git a/docs/installation.md b/docs/installation.md index 7dca3450b0..0bfd00063c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1024,6 +1024,8 @@ export REA_GHIDRA_COMPILER_SPEC_ID="borlandcpp" # optional; requires a languag REA passes `-processor`/`-cspec`, checks both against the installation's `.ldefs`, and records them in the analysis profile. DOS imports reject an override. +Language definitions must be well-formed XML encoded as UTF-8 or BOM-marked UTF-16, with any encoding declaration matching the bytes. Literal Unicode replacement characters (U+FFFD) are accepted; malformed source bytes are rejected. + To seed code that auto-analysis cannot reach (computed jumps, pointers in data): ```bash diff --git a/src/ghidra/GhidraLanguageCatalog.ts b/src/ghidra/GhidraLanguageCatalog.ts index 1cde8769f4..1cd939af20 100644 --- a/src/ghidra/GhidraLanguageCatalog.ts +++ b/src/ghidra/GhidraLanguageCatalog.ts @@ -1,7 +1,8 @@ import { open, readdir } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import { join } from "node:path"; -import { DOMParser, onWarningStopParsing } from "@xmldom/xmldom"; +import { parseXmlDocument } from "../domain/xmlDocument.js"; +import { decodeXmlPlistText } from "../domain/propertyListXmlText.js"; import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; @@ -80,9 +81,12 @@ export const parseGhidraLanguageDefinitions = ( text: string, ): readonly GhidraLanguageDefinition[] => { const definitions: GhidraLanguageDefinition[] = []; - const document = new DOMParser({ - onError: onWarningStopParsing, - }).parseFromString(text, "application/xml"); + const parsed = parseXmlDocument(text, { + doctype: "allow", + lineEndings: "normalize", + }); + if (!parsed.ok) throw parsed.error; + const document = parsed.value; const root = document.documentElement; if (root?.tagName !== "language_definitions") throw new SyntaxError("Expected Ghidra language_definitions XML root"); @@ -164,10 +168,10 @@ export const readGhidraLanguageCatalog = async ( if (!entry.endsWith(".ldefs")) continue; const bytes = await readLanguageDefinitionFile(join(directory, entry)); if (bytes === undefined) continue; - // XML 1.0 section 4.3.3: a leading UTF-8 BOM is an encoding signature, - // not document content; TextDecoder consumes it, Buffer#toString does not. + // The XML byte owner validates encoding and declaration before syntax + // parsing; a literal U+FFFD cannot diagnose invalid source bytes. definitions.push( - ...parseGhidraLanguageDefinitions(new TextDecoder().decode(bytes)), + ...parseGhidraLanguageDefinitions(decodeXmlPlistText(bytes)), ); } } diff --git a/tests/boundary/filesystem/ghidraLanguageCatalogEncoding.test.ts b/tests/boundary/filesystem/ghidraLanguageCatalogEncoding.test.ts new file mode 100644 index 0000000000..647e5c2266 --- /dev/null +++ b/tests/boundary/filesystem/ghidraLanguageCatalogEncoding.test.ts @@ -0,0 +1,68 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { readGhidraLanguageCatalog } from "../../../src/ghidra/GhidraLanguageCatalog.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const xml = + ''; +it.each([ + { name: "literal U+FFFD", bytes: Buffer.from(xml), expectedError: null }, + { + name: "UTF-8 BOM", + bytes: Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(xml)]), + expectedError: null, + }, + { + name: "UTF-16 BOM/declaration", + bytes: Buffer.concat([ + Buffer.from([0xff, 0xfe]), + Buffer.from('' + xml, "utf16le"), + ]), + expectedError: null, + }, + { + name: "malformed UTF-8", + bytes: Buffer.concat([ + Buffer.from('', + ), + ]), + expectedError: "invalid-bytes", + }, + { + name: "encoding mismatch", + bytes: Buffer.from('' + xml), + expectedError: "encoding-mismatch", + }, + { + name: "malformed XML alongside U+FFFD", + bytes: Buffer.from(xml.replace('id="toy:LE:32:default"', "id=unquoted")), + expectedError: "malformed", + }, +])( + "reads $name at the language-file byte boundary", + async ({ bytes, expectedError }) => { + const root = await createTestTempDirectory("rea-ghidra-xml-encoding-"); + const directory = join( + root, + "Ghidra", + "Processors", + "toy", + "data", + "languages", + ); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, "toy.ldefs"), bytes); + if (expectedError === null) + expect(await readGhidraLanguageCatalog(root)).toEqual([ + { id: "toy:LE:32:default", compilerSpecIds: ["default"] }, + ]); + else + await expect(readGhidraLanguageCatalog(root)).rejects.toMatchObject({ + reason: expectedError, + }); + }, +);