From ad2111fac64aa8acf9b9d6fba656b64be46ff677 Mon Sep 17 00:00:00 2001 From: gensokyo-dev Date: Fri, 13 Nov 2026 18:26:14 +0000 Subject: [PATCH] fix(ghidra): say when an inferred no-return call cuts a function Ghidra's Non-Returning Functions - Discovered analyzer can mark a returning callee, such as an imported operator new thunk, as non-returning. Ghidra then clears the fallthrough at each call site and refits the callers, so a constructor decompiles to a few plausible lines. REA reported the terminal call with the FunctionManager flag but did not say the result omits the code after it or how to compare. That analyzer bookmarks each entry it marks with the Analysis comment "Non-Returning Function Found" (Ghidra 11.0 through 12.1.4 source); the name-list Known analyzer writes "Non-Returning Function Identified". When a terminal call's callee, or the function a thunk forwards to, carries the Discovered bookmark, the call's limitation now says the flag was inferred, and the function adds one address-free limitation that its body and pseudocode end at that call and omit the following code if the callee returns, with the REA_GHIDRA_DISABLED_ANALYZERS comparison. A flag without the bookmark keeps an unknown origin. The function-boundary observation contract moves to v5 so snapshots from earlier bridges are not replayed without the qualification. Refs #10365 Co-Authored-By: gensokyo-dev Co-Authored-By: Claude Opus 5.5 --- bridge/ghidra/ReaGhidraBridge.java | 52 +++++++++++++++++--- docs/providers/ghidra-provider.md | 13 ++++- src/ghidra/GhidraAnalysisProfile.ts | 2 +- src/ghidra/GhidraRawProfileDigest.test.ts | 6 +-- src/ghidra/GhidraSnapshotCache.test.ts | 4 +- tests/boundary/mcp/ghidraEvidenceMcp.test.ts | 2 +- 6 files changed, 65 insertions(+), 14 deletions(-) diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 82d75c5cc2..47aed46bec 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -77,6 +77,8 @@ import ghidra.program.model.data.StringDataInstance; import ghidra.program.model.data.DataType; import ghidra.program.model.lang.Register; import ghidra.program.model.scalar.Scalar; +import ghidra.program.model.listing.Bookmark; +import ghidra.program.model.listing.BookmarkType; import ghidra.program.model.listing.CommentType; import ghidra.program.model.listing.Data; import ghidra.program.model.listing.DataIterator; @@ -3987,6 +3989,7 @@ public final class ReaGhidraBridge extends HeadlessScript { private final class FunctionBoundaryLimitations { private final Function function; private final List limitations = new ArrayList<>(); + private boolean inferredNoReturnCall; private Instruction beforePrevious; private Instruction previous; @@ -3996,8 +3999,11 @@ public final class ReaGhidraBridge extends HeadlessScript { private void observe(Instruction instruction) throws Exception { monitor.checkCancelled(); - String limitation = terminalCallLimitation(instruction); - if (limitation != null) limitations.add(limitation); + TerminalCall terminal = terminalCall(instruction); + if (terminal != null) { + limitations.add(terminal.limitation()); + if (terminal.inferredNoReturn()) inferredNoReturnCall = true; + } if (beforePrevious != null && previous != null) { String seh = borlandSehHandlerLimitation( function, beforePrevious, previous, instruction); @@ -4008,10 +4014,39 @@ public final class ReaGhidraBridge extends HeadlessScript { } private List values() { - return List.copyOf(limitations); + if (!inferredNoReturnCall) return List.copyOf(limitations); + List result = new ArrayList<>(limitations); + // One consequence and recovery per function; each call keeps its own fact. The text + // names no address, so bounded summaries deduplicate it across functions. + result.add("A terminal call in this function targets a callee that " + + "Ghidra's Non-Returning Functions - Discovered analyzer inferred cannot return, so Ghidra " + + "ended the analyzed body, and the pseudocode and facts derived from it, at that call. If the " + + "callee returns, this result omits the code after the call. To compare, analyze a fresh import " + + "with REA_GHIDRA_DISABLED_ANALYZERS='Non-Returning Functions - Discovered'."); + return result; } } + /** A terminal call's limitation and whether its no-return flag was analyzer-inferred. */ + private record TerminalCall(String limitation, boolean inferredNoReturn) {} + + /** + * Ghidra's Non-Returning Functions - Discovered analyzer bookmarks each entry it marks with + * this Analysis comment; the name-list Known analyzer uses "Non-Returning Function Identified". + * A missing bookmark leaves the flag's origin unknown, never declared. + */ + private boolean discoveredNoReturn(Function target) { + Function thunked = target.isThunk() ? target.getThunkedFunction(true) : null; + return hasDiscoveredNoReturnBookmark(target) || + (thunked != null && hasDiscoveredNoReturnBookmark(thunked)); + } + + private boolean hasDiscoveredNoReturnBookmark(Function function) { + Bookmark bookmark = currentProgram.getBookmarkManager().getBookmark( + function.getEntryPoint(), BookmarkType.ANALYSIS, "Non-Returning Function"); + return bookmark != null && "Non-Returning Function Found".equals(bookmark.getComment()); + } + private String borlandSehHandlerLimitation( Function function, Instruction handlerPush, Instruction framePush, Instruction frameLink) { @@ -4104,9 +4139,10 @@ public final class ReaGhidraBridge extends HeadlessScript { } } - private String terminalCallLimitation(Instruction instruction) { + private TerminalCall terminalCall(Instruction instruction) { if (!instruction.getFlowType().isCall() || !instruction.getFlowType().isTerminal()) return null; + boolean inferred = false; Address[] destinations = instruction.getFlows(); StringBuilder limitation = new StringBuilder(); limitation.append("Ghidra Listing reports a terminal call at ") @@ -4135,14 +4171,18 @@ public final class ReaGhidraBridge extends HeadlessScript { Function target = destinations.length == 1 ? currentProgram.getFunctionManager().getFunctionAt(destinations[0]) : null; - if (target != null && target.hasNoReturn()) { + if (target != null && target.hasNoReturn() && discoveredNoReturn(target)) { + inferred = true; + limitation.append(" Ghidra's Non-Returning Functions - Discovered analyzer bookmarked this callee as a non-returning function it found; the flag is an analysis inference, not an independent verification that the callee cannot return."); + } + else if (target != null && target.hasNoReturn()) { limitation.append(" This records Ghidra's FunctionManager flag, not an independent verification that the callee cannot return."); } else { limitation.append(" The reason for the terminal flow is not established by this observation."); } } - return limitation.toString(); + return new TerminalCall(limitation.toString(), inferred); } private static String renderAssembly(List instructions) { diff --git a/docs/providers/ghidra-provider.md b/docs/providers/ghidra-provider.md index 069afe2bd9..4466d259ac 100644 --- a/docs/providers/ghidra-provider.md +++ b/docs/providers/ghidra-provider.md @@ -338,7 +338,18 @@ handler execution or change the body. Instruction windows and dossiers also retain these warnings in their result limitations. An unresolved callee or unestablished terminal-flow basis remains explicit. These observations qualify the analysis; they do not prove that a no-return flag is incorrect or recover -excluded code. Analysis profiles version this observation contract so older +excluded code. When the callee's entry, or the function a thunk forwards to, +carries the `Non-Returning Function Found` analysis bookmark that Ghidra's +`Non-Returning Functions - Discovered` analyzer writes, the call's limitation +says the flag was inferred, and the function's limitations add one statement +that the body and pseudocode end at that call and omit the following code if +the callee returns. That analyzer can mark returning callees, such as an +imported `operator new` thunk, so a constructor may decompile to a few lines. To +compare, open a fresh import with +`REA_GHIDRA_DISABLED_ANALYZERS='Non-Returning Functions - Discovered'`; an +existing project (`REA_GHIDRA_PROJECT_PATH`) keeps the flags its analysis +already stored. A flag without that bookmark keeps an unknown origin; REA does +not treat it as declared. Analysis profiles version this observation contract so older snapshots cannot replay unqualified results. On non-Windows hosts, the ephemeral import repair clears no-return flags on the diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 83fa0320f2..f56aa7adbb 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -74,7 +74,7 @@ const sharedAnalysisSemantics = { instruction_status: "unlisted-and-containing-code-data-v3", instruction_references: "exact-address-incoming-without-synthetic-entry-v3", byte_search_coverage: "separate-loaded-overlays-v1", - function_boundary_observations: "ghidra-function-boundary-limitations-v4", + function_boundary_observations: "ghidra-function-boundary-limitations-v5", } as const; /** diff --git a/src/ghidra/GhidraRawProfileDigest.test.ts b/src/ghidra/GhidraRawProfileDigest.test.ts index 682576a8ab..6621f5e142 100644 --- a/src/ghidra/GhidraRawProfileDigest.test.ts +++ b/src/ghidra/GhidraRawProfileDigest.test.ts @@ -8,12 +8,12 @@ import { GhidraProvider } from "./GhidraProvider.js"; // Digests commit to the current shared policy, including the instruction // status, load-image relocation-symbol, procedure search, byte-search and -// bit-field base-type, runtime value-flow budgets and inventory-search revisions that prevent reuse of snapshots with earlier +// bit-field base-type, runtime value-flow budgets, function-boundary and inventory-search revisions that prevent reuse of snapshots with earlier // results. const RAW_DIGESTS = { - "6502": "51160ff6e94cb32eaeb87ac248915afb3bc17e33b11a1a101222344dd3907c6f", + "6502": "6c4e88435526e8f0907b072600e3b121577962a36554e023dde4cf85113dd9f6", "x86-disabled-analyzer": - "94e93961a699cf9250844d53fe95802c72063c08656fc308b3fed523dd86ea1c", + "b2dc33d1932cbe71d72819428634927f3a2d8a2bbd54b6893f823ccb6b795d0b", } as const; const configFor = (environment: NodeJS.ProcessEnv) => { diff --git a/src/ghidra/GhidraSnapshotCache.test.ts b/src/ghidra/GhidraSnapshotCache.test.ts index d396e4e504..31e4733d73 100644 --- a/src/ghidra/GhidraSnapshotCache.test.ts +++ b/src/ghidra/GhidraSnapshotCache.test.ts @@ -351,8 +351,8 @@ describe("Ghidra snapshot profile admission", () => { }, { parameter: "function_boundary_observations", - current: "ghidra-function-boundary-limitations-v4", - legacy: "ghidra-function-boundary-limitations-v3", + current: "ghidra-function-boundary-limitations-v5", + legacy: "ghidra-function-boundary-limitations-v4", }, { parameter: "instruction_references", diff --git a/tests/boundary/mcp/ghidraEvidenceMcp.test.ts b/tests/boundary/mcp/ghidraEvidenceMcp.test.ts index 47c018209f..086c142cca 100644 --- a/tests/boundary/mcp/ghidraEvidenceMcp.test.ts +++ b/tests/boundary/mcp/ghidraEvidenceMcp.test.ts @@ -190,7 +190,7 @@ it("retains terminal-call qualifications across live dossiers and snapshot-backe expect(evidence.limitations, name).toContain(limitation); expect(evidence.analysis_profile?.parameters).toMatchObject({ function_boundary_observations: - "ghidra-function-boundary-limitations-v4", + "ghidra-function-boundary-limitations-v5", }); const replay = await harness.session.execute(name, evidence.parameters); if (!replay.ok) throw replay.error;