diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 70132eeae..46b672eb7 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -3436,13 +3436,17 @@ public final class ReaGhidraBridge extends HeadlessScript { * The canonical empty string is a small literal with a zero count. */ private SwiftStringLiteralDecode swiftStringLiterals(Function function) throws Exception { + return swiftStringLiterals(function, new SwiftLiteralBudget()); + } + + // The budget is owned by one decode run; an explicit budget also permits deterministic producer regressions. + private SwiftStringLiteralDecode swiftStringLiterals(Function function, SwiftLiteralBudget budget) throws Exception { Register[] registers = new Register[31]; for (int index = 0; index < registers.length; index++) { registers[index] = currentProgram.getRegister("x" + index); } TreeMap observed = new TreeMap<>(); TreeMap omitted = new TreeMap<>(); - SwiftLiteralBudget budget = new SwiftLiteralBudget(); Map selectedValues = new HashMap<>(); SymbolicPropogator propagator = new SymbolicPropogator(currentProgram, false); propagator.flowConstants(function.getEntryPoint(), function.getBody(), new ContextEvaluatorAdapter() { @@ -3687,6 +3691,11 @@ public final class ReaGhidraBridge extends HeadlessScript { remaining = Math.max(0, (runtime.maxMemory() - runtime.totalMemory() + runtime.freeMemory()) / 2); } + SwiftLiteralBudget(long remaining) { + if (remaining < 0) throw new IllegalArgumentException("Swift literal budget must be nonnegative"); + this.remaining = remaining; + } + boolean takeState() { if (remaining < BYTES_PER_RECORD) return false; remaining -= BYTES_PER_RECORD; diff --git a/scripts/verify-real-ghidra-swift.mjs b/scripts/verify-real-ghidra-swift.mjs index 0aa58e2f0..4691b1dd1 100644 --- a/scripts/verify-real-ghidra-swift.mjs +++ b/scripts/verify-real-ghidra-swift.mjs @@ -1,7 +1,14 @@ #!/usr/bin/env node import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { copyFile, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + readdir, + rm, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -50,6 +57,10 @@ const literalSource = fileURLToPath( ); const binary = join(root, "swift-inventory"); const literalBinary = join(root, "swift-literals"); +const literalAssembly = fileURLToPath( + new URL("../tests/conformance/swift/decoder-safeguards.S", import.meta.url), +); +const literalObject = join(root, "decoder-safeguards.o"); const reports = []; let literalReport; try { @@ -59,6 +70,19 @@ try { await mkdir(join(root, "copied")); const copied = join(root, "copied", "swift-inventory"); await copyFile(binary, copied); + await execute( + "xcrun", + [ + "clang", + "-target", + "arm64-apple-macosx13.0", + "-c", + literalAssembly, + "-o", + literalObject, + ], + { timeout: 60_000 }, + ); // Decoding covers AArch64 register pairs, so build that slice on any Mac host. await execute( "swiftc", @@ -67,11 +91,13 @@ try { "-target", "arm64-apple-macosx13.0", literalSource, + literalObject, "-o", literalBinary, ], { timeout: 60_000 }, ); + await verifyDecoderSafeguards(); await client.connect(transport); for (const [variant, path] of [ ["debug", binary], @@ -227,3 +253,54 @@ async function verifyStringLiterals() { await call("close_binary", {}); } } + +// Exercise the actual Java producer inside a disposable Ghidra database. The +// explicit private budget boundary makes exhaustion deterministic, without a +// production CLI/MCP option or dependence on the verification host's free heap. +async function verifyDecoderSafeguards() { + const scripts = join(root, "decoder-scripts"); + await mkdir(scripts); + const bridgeSources = fileURLToPath( + new URL("../bridge/ghidra/", import.meta.url), + ); + for (const name of await readdir(bridgeSources)) { + if (name.endsWith(".java")) + await copyFile(join(bridgeSources, name), join(scripts, name)); + } + await copyFile( + fileURLToPath( + new URL( + "../tests/conformance/swift/ReaSwiftDecoderRegressions.java", + import.meta.url, + ), + ), + join(scripts, "ReaSwiftDecoderRegressions.java"), + ); + const report = join(root, "decoder-safeguards.txt"); + await execute( + installation.analyzeHeadlessPath, + [ + root, + "decoder-regressions", + "-import", + literalBinary, + "-scriptPath", + scripts, + "-postScript", + "ReaSwiftDecoderRegressions.java", + report, + "-deleteProject", + "-max-cpu", + "2", + ], + { + env: { ...process.env, JAVA_HOME: installation.javaHome }, + timeout: 120_000, + maxBuffer: 8 * 1024 * 1024, + }, + ); + assert.equal( + await readFile(report, "utf8"), + "REA_SWIFT_DECODER_SAFEGUARDS_OK\n", + ); +} diff --git a/tests/conformance/swift/ReaSwiftDecoderRegressions.java b/tests/conformance/swift/ReaSwiftDecoderRegressions.java new file mode 100644 index 000000000..f8f31d96b --- /dev/null +++ b/tests/conformance/swift/ReaSwiftDecoderRegressions.java @@ -0,0 +1,145 @@ +// Producer regressions run inside a real, ephemeral Ghidra analysis of the +// source-owned AArch64 fixture. No target code is executed and no decoded JSON +// fixture substitutes for the Java decoder under test. +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import ghidra.app.util.headless.HeadlessScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.FunctionIterator; +import ghidra.program.model.listing.Instruction; +import ghidra.program.model.listing.InstructionIterator; +import ghidra.program.model.lang.Register; +import ghidra.program.model.symbol.Symbol; +import ghidra.program.model.symbol.SymbolIterator; + +public class ReaSwiftDecoderRegressions extends HeadlessScript { + private ReaGhidraBridge decoder; + private Class budgetClass; + + @Override + public void run() throws Exception { + decoder = new ReaGhidraBridge(); + setInherited(decoder, "currentProgram", currentProgram); + setInherited(decoder, "monitor", monitor); + setInherited(decoder, "sessionDefaultAddressSpace", currentProgram.getAddressFactory().getDefaultAddressSpace()); + budgetClass = Class.forName("ReaGhidraBridge$SwiftLiteralBudget", true, decoder.getClass().getClassLoader()); + Function copied = function("rea_swift_decoder_copied"); + JsonArray copiedValues = literals(decode(copied, 65536)); + require(copiedValues.size() > 0, "copied constant pair was not decoded"); + for (var element : copiedValues) { + require(element.getAsJsonObject().get("value").getAsString().equals("ok"), "copied words invented text"); + } + for (String name : new String[] {"select", "selected_copy", "padding", "utf8"}) { + Object result = decode(function("rea_swift_decoder_" + name), 65536); + require(literals(result).isEmpty(), name + " produced an invented Swift literal"); + require(omissions(result).isEmpty(), name + " was confused with capacity exhaustion"); + } + for (String name : new String[] {"select", "selected_copy"}) { + Function selected = function("rea_swift_decoder_" + name); + require((Boolean) invoke("selectedValue", new Class[] {Function.class, Instruction.class, Register.class}, selected, returnInstruction(selected), currentProgram.getRegister("x0")), + name + " did not retain conditional-select provenance"); + } + require(invoke("swiftLiteralLayout", new Class[] {long.class, long.class}, 0x6261L, 0xe100000000000000L) == null, + "nonzero small-string padding was accepted"); + require(invoke("swiftLiteralLayout", new Class[] {long.class, long.class}, 0xc0L, 0xa100000000000000L) == null, + "malformed small-string UTF-8 was accepted"); + rejectLarge("rea_swift_decoder_bad_terminator", 0xd000000000000003L); + rejectLarge("rea_swift_decoder_bad_large_utf8", 0x1000000000000001L); + Object exhausted = decode(copied, 0); + require(literals(exhausted).isEmpty(), "capacity failure published decoded small text"); + JsonArray omitted = omissions(exhausted); + require(omitted.size() == 1, "capacity failure did not stop subsequent use sites"); + JsonObject omission = omitted.get(0).getAsJsonObject(); + require(omission.get("reason").getAsString().equals("capacity"), "capacity omission lost its reason"); + require(!omission.has("value"), "omission claimed a retained literal value"); + require(getScriptArgs().length == 1, "Expected owned result path"); + Files.writeString(Path.of(getScriptArgs()[0]), "REA_SWIFT_DECODER_SAFEGUARDS_OK\n"); + } + + private Object decode(Function function, long remaining) throws Exception { + Constructor constructor = budgetClass.getDeclaredConstructor(long.class); + constructor.setAccessible(true); + return invoke("swiftStringLiterals", new Class[] {Function.class, budgetClass}, function, constructor.newInstance(remaining)); + } + + private void rejectLarge(String name, long word) throws Exception { + Address address = symbol(name).getAddress(); + long object = 0x8000000000000000L | ((address.getOffset() - 0x20) & 0x00ffffffffffffffL); + Object layout = invoke("swiftLiteralLayout", new Class[] {long.class, long.class}, word, object); + require(layout != null, "large negative fixture did not reach the memory-text decoder"); + require(invoke("swiftLargeLiteralText", new Class[] {layout.getClass()}, layout) == null, + name + " was accepted as literal text"); + } + + private Instruction returnInstruction(Function function) { + InstructionIterator instructions = currentProgram.getListing().getInstructions(function.getBody(), true); + while (instructions.hasNext()) { + Instruction instruction = instructions.next(); + if (instruction.getMnemonicString().equalsIgnoreCase("ret")) return instruction; + } + throw new AssertionError("Fixture has no return instruction"); + } + + private Function function(String name) { + FunctionIterator functions = currentProgram.getFunctionManager().getFunctions(true); + Function found = null; + while (functions.hasNext()) { + Function candidate = functions.next(); + if (!candidate.getName().equals(name) && !candidate.getName().equals("_" + name)) continue; + require(found == null, "ambiguous fixture function " + name); + found = candidate; + } + require(found != null, "missing fixture function " + name); + return found; + } + + private Symbol symbol(String name) { + SymbolIterator symbols = currentProgram.getSymbolTable().getAllSymbols(true); + Symbol found = null; + while (symbols.hasNext()) { + Symbol candidate = symbols.next(); + if (!candidate.getName().equals(name) && !candidate.getName().equals("_" + name)) continue; + require(found == null, "ambiguous fixture symbol " + name); + found = candidate; + } + require(found != null, "missing fixture symbol " + name); + return found; + } + + private Object invoke(String name, Class[] types, Object... arguments) throws Exception { + Method method = ReaGhidraBridge.class.getDeclaredMethod(name, types); + method.setAccessible(true); + return method.invoke(decoder, arguments); + } + + private JsonArray literals(Object result) throws Exception { return array(result, "literals"); } + private JsonArray omissions(Object result) throws Exception { return array(result, "omissions"); } + private JsonArray array(Object result, String name) throws Exception { + Method method = result.getClass().getDeclaredMethod(name); + method.setAccessible(true); + return (JsonArray) method.invoke(result); + } + + private void setInherited(Object target, String name, Object value) throws Exception { + for (Class type = target.getClass(); type != null; type = type.getSuperclass()) { + try { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + return; + } catch (NoSuchFieldException nextOwner) { } + } + throw new AssertionError("Missing Ghidra state field " + name); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} diff --git a/tests/conformance/swift/decoder-safeguards.S b/tests/conformance/swift/decoder-safeguards.S new file mode 100644 index 000000000..c426cfac7 --- /dev/null +++ b/tests/conformance/swift/decoder-safeguards.S @@ -0,0 +1,58 @@ +/* Static-analysis fixture only. Never executed. Deliberate malformed ABI words + * cannot be produced by a well-formed Swift source literal. */ +.text +.p2align 2 +.globl _rea_swift_decoder_copied +_rea_swift_decoder_copied: + mov x5, #0x6b6f + movz x6, #0xe200, lsl #48 + mov x0, x5 + mov x1, x6 + stp x0, x1, [sp, #-16]! + ldp x0, x1, [sp], #16 + ret + +.p2align 2 +.globl _rea_swift_decoder_select +_rea_swift_decoder_select: + mov x2, #0x61 + mov x3, #0x6262 + movz x6, #0xe100, lsl #48 + movz x7, #0xe200, lsl #48 + cmp w4, #0 + csel x0, x2, x3, eq + csel x1, x6, x7, eq + ret + +.p2align 2 +.globl _rea_swift_decoder_selected_copy +_rea_swift_decoder_selected_copy: + mov x2, #0x61 + mov x3, #0x62 + cmp w4, #0 + csel x5, x2, x3, eq + mov x0, x5 + movz x1, #0xe100, lsl #48 + ret + +.p2align 2 +.globl _rea_swift_decoder_padding +_rea_swift_decoder_padding: + mov x0, #0x6261 + movz x1, #0xe100, lsl #48 + ret + +.p2align 2 +.globl _rea_swift_decoder_utf8 +_rea_swift_decoder_utf8: + mov x0, #0xc0 + movz x1, #0xa100, lsl #48 + ret + +.section __DATA,__const +.globl _rea_swift_decoder_bad_terminator +_rea_swift_decoder_bad_terminator: + .byte 0x61, 0x62, 0x63, 0x78 +.globl _rea_swift_decoder_bad_large_utf8 +_rea_swift_decoder_bad_large_utf8: + .byte 0xc0, 0