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;