Repository navigation
Description
TL;DR: On a stripped Rust library, Ghidra treats two libc calls as calls that never return. That cuts one function from 78 instructions to 8, and REA shows it with no warning. REA already has a script that undoes this for some functions, but it skips these two.
Before submitting
- I searched the open and closed issues for a related report.
- I removed credentials, tokens, private paths, and sensitive target data.
Area
Native analysis or decompilation
Observed behavior
I pointed REA at loot.cpython-313-x86_64-linux-gnu.so with the Ghidra provider. The file is the Python module of libloot 0.29.6 (GPL-3.0-or-later). It ships inside Amethyst Mod Manager 2.5.3 (GPL-3.0-or-later). It is Rust with PyO3 0.25.1, and it is stripped.
The one exported function is PyInit_loot. REA returns this for it (the three try/catch comment lines are removed):
void PyInit_loot(void)
{
/* WARNING: Subroutine does not return */
__tls_get_addr(&PTR_0068ee28);
}
The assembly view stops at the same call, after 8 instructions. The procedure info says the body is 43 bytes. The function dossier returns the same body. It reports confidence: observed and only the generic limitations.
The real function is longer. In objdump -d it runs from file offset 0x3e1020 to a ud2 at 0x3e1172. That range holds 78 instructions. The code reads a thread-local counter, calls the module init, and builds the Python error on failure.
Ghidra alone gives the same result. I ran analyzeHeadless on the same file with default analysis and no REA script. The function has the same 8 instructions. The external __tls_get_addr carries the no-return flag. So Ghidra sets the flag. REA does not clear it, and it does not say the body may be cut.
The flag is wrong for more than one call. These are Ghidra's counts after default analysis, before any REA script ran:
file call really no-return? sites functions
loot malloc no 1,611 840
loot __tls_get_addr no 197 156
loot _Unwind_Resume yes 636 189
loot abort yes 1 1
filegraph memcpy no 1,206 482
filegraph memset no 187 117
filegraph strlen no 217 102
filegraph __errno_location no 87 45
"Sites" are the calls where Ghidra ends the flow. "Functions" is how many functions hold them. I did not measure how many of those functions are really cut. A call can end the flow while the code after it is reached some other way. PyInit_loot is cut. PyInit_amethyst_filegraph is not cut, and it decompiles in full.
Reproduction
- Get the file from Amethyst Mod Manager 2.5.3. The v2.5.3 release has the Flatpak as
AmethystModManager.flatpak. The file sits at/app/lib/python3.13/site-packages/loot.cpython-313-x86_64-linux-gnu.soinside it. - I took mine from an installed copy. I did not compare the release asset byte for byte (unverified). The SHA-256 of my file is
7777474017b65531258cc1678f2c1ee45e61f4b76de4890c02c7436cbbbe60a8. - Copy the file to a plain folder.
- Run
rea decompile /abs/path/loot.cpython-313-x86_64-linux-gnu.so PyInit_loot --provider ghidra --format json. Over MCP, callopen_binarywithprovider_idset toghidra. Then callprocedure_pseudo_codeforPyInit_loot. - Compare with
objdump -d --start-address=0x3e1020 --stop-address=0x3e1180on the same file.
The CLI command and the MCP route gave the same result on main.
The first import took 4 to 5 minutes here, with three or four Ghidra processes running. After open_binary, later calls on the same MCP session answered in under a second.
A small Rust library I built myself did not reproduce this. In it, __tls_get_addr was not flagged. The function decompiled in full, with 48 instructions. So the Amethyst file is needed. I do not know what makes Ghidra set the flag on this file.
Expected behavior
PyInit_loot should come back whole. If Ghidra's flag cannot be trusted, the result should say so. Today nothing tells the reader that the body may be cut at a no-return call.
Impact and scope
- Affected: the pseudo-code, the assembly, the info and the dossier. Any function that calls such an external can be hit.
- Not affected: the bytes.
read_bytesat 0x4e1046 returns the call and the code after it.inspect_binary_layoutis also untouched. - Evidence integrity: the result is typed
observedand carries no limitation. A caller readsPyInit_lootas a function that never returns. - Seen on two Rust files. I have not tried C or C++ files that make the same libc calls.
REA version
Main branch, at commit 4999e4b (#1651). Its package.json says 6.3.0. The released 6.3.0 package gave the same output in an earlier run. I did not re-run it today.
Main has moved to 639b730 since then. The no-return script has not changed there. I checked with git diff.
Runtime and platform
Node 24.18.0; Nobara 44 (Fedora 44 base), Linux 7.2.9, x86-64; Ghidra 12.1.4 (Apache-2.0); JDK 21.0.12.1
Target and artifact context
ELF64 x86-64 shared object, stripped, Rust with PyO3 0.25.1, 5,836,952 bytes. A second file, amethyst_filegraph.abi3.so, was used for the filegraph rows.
Interface
MCP
Earliest failure stage
Native analysis or reconstruction
Relevant output or evidence
The decompile and the table are above. These are the first lines of the real function, from objdump -d:
3e1020: push %r14
3e1022: push %rbx
3e1023: sub $0xb8,%rsp
3e102a: lea -0x30aea9(%rip),%rax
3e1031: mov %rax,0x8(%rsp)
3e1036: movq $0x1e,0x10(%rsp)
3e103f: lea 0x1adde2(%rip),%rdi
3e1046: call 529080 <__tls_get_addr@plt>
3e104b: mov 0x70(%rax),%rdi <- Ghidra stops before this
...
3e1102: ret
In this file, Ghidra adds 0x100000 to file offsets. The call is at 0x4e1046 in REA.
Typed outcome or recovery expected
When a function body ends at a call whose callee has the no-return flag, the result could carry a limitation. It would name the callee.
Analysis or suspected cause
This part is read from source, and I tested the fix idea once.
The script bridge/ghidra/ReaGhidraNoReturnFix.java at 4999e4b already clears wrong no-return flags. Its rule is: flagged no-return, and the body holds a real return. Its loop skips thunks, which covers the PLT stubs. An external function has no body with a return in it. So neither one is ever cleared.
After the script ran on loot, it reported cleared=4 sites=75 refit=23. The external __tls_get_addr stayed flagged on all 197 call sites. The external malloc stayed flagged on all 1,611.
I tried a copy of that script that also clears the flag on six externals and their stubs. The six are malloc, __tls_get_addr, memcpy, memset, strlen and __errno_location. The copy then re-disassembles the call sites the same way. After that, the function came back with 59 instructions. The decompile has 74 lines. It now shows the real calls and PyErr_SetRaisedException.
That copy re-disassembled 1,812 call sites and refit 994 functions in loot. I did not check whether those refits are right (unverified). I tried it on one file.
Commands
export GHIDRA_INSTALL_DIR=/path/to/ghidra_12.1.4_PUBLIC JAVA_HOME=/path/to/jdk-21
export REA_GHIDRA_STARTUP_TIMEOUT_MS=600000
# REA
node scripts/rea.mjs decompile /abs/loot.cpython-313-x86_64-linux-gnu.so PyInit_loot --provider ghidra --format json
# Ghidra alone, with a script that prints the facts
$GHIDRA_INSTALL_DIR/support/analyzeHeadless /tmp/proj rea-project \
-import /abs/loot.cpython-313-x86_64-linux-gnu.so -readOnly -deleteProject \
-scriptPath /abs/scripts -postScript P4Dump2.java ghidra_default_analysis_only PyInit_ masterlistThe script P4Dump2.java takes a label, a function name prefix and a list of search terms. It lists the instructions of the matching function. It also prints the no-return flag of each call target and the decompiler text. The script P4Nr.java counts call sites per no-return external. I can share both scripts if that helps. The fix idea from the last section is this diff against the REA script:
--- bridge/ghidra/ReaGhidraNoReturnFix.java (4999e4be2)
+++ P4NoReturnExt.java (class renamed; run after the original script)
@@ -16,7 +16,7 @@
* call as a terminator and the decompiler truncated them. Clear the flag, then re-disassemble only
* the call sites of the functions that were cleared and refit their owning functions in place.
*/
-public final class ReaGhidraNoReturnFix extends GhidraScript {
+public final class P4NoReturnExt extends GhidraScript {
@Override
public void run() throws Exception {
// Windows P0 does not admit analysis-database mutation.
@@ -24,6 +24,21 @@
FunctionManager fm = currentProgram.getFunctionManager();
Listing li = currentProgram.getListing();
Set<Address> cleared = new HashSet<>();
+ Set<String> names = Set.of("__tls_get_addr", "malloc", "memcpy", "memset", "strlen", "__errno_location");
+ for (Function e : fm.getExternalFunctions()) {
+ if (names.contains(e.getName()) && e.hasNoReturn()) {
+ e.setNoReturn(false);
+ println("P4X cleared external " + e.getName());
+ }
+ }
+ for (Function t : fm.getFunctions(true)) {
+ if (!t.isThunk()) continue;
+ Function target = t.getThunkedFunction(true);
+ if (target != null && names.contains(target.getName())) {
+ if (t.hasNoReturn()) t.setNoReturn(false);
+ cleared.add(t.getEntryPoint());
+ }
+ }
for (Function f : fm.getFunctions(true)) {
monitor.checkCancelled();
if (!f.hasNoReturn() || f.isThunk()) continue;Test setup
- Box: Nobara 44 (Fedora 44 base), Linux 7.2.9
- CPU: AMD Ryzen 9 5900X, 12 cores / 24 threads
- RAM: 32 GB DDR4-3600
- Node: 24.18.0
- Main: 4999e4b (fix(javascript): isolate source analysis and retain bounded partial evidence #1651)
- Ghidra: 12.1.4 (Apache-2.0), with JDK 21.0.12.1
- Target: Amethyst Mod Manager 2.5.3 (GPL-3.0-or-later)
- Target: libloot 0.29.6 (GPL-3.0-or-later)
Related, but not the same, is #1244. It asks for a warning when a guessed signature clashes with the code.
🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊
Activity
fix(ghidra): expose terminal-call boundary assumptions
CassieFox23 commentedon Oct 11, 2026
TL;DR: I ran main 1e51c73 on the Linux file that the close note called unverified. That is from before #1666 landed. The function still stopped after 8 instructions. But every view named the call and said the flag is only Ghidra's. That matches what 69a8b3a set out to do. The #1666 fix then brought back the full body on this file.
What I ran
I built main at 1e51c73. I opened
loot.cpython-313-x86_64-linux-gnu.sowithopen_binaryandprovider_idset toghidra, in one MCP session. The file is the Python module of libloot 0.29.6 (GPL-3.0-or-later). It ships in Amethyst Mod Manager (GPL-3.0-or-later). I then called four tools forPyInit_loot.__tls_get_addrand 0x4e1046procedure_pseudo_code__tls_get_addr(&PTR_0068ee28);procedure_assembly0x4e1046: CALL 0x00629080procedure_infoanalyze_functionThe line is the same in all four results:
The body did not change, as expected: 69a8b3a does not clear flags or recover code. In
objdump -dthe function runs from file offset 0x3e1020 to theud2at 0x3e1172. That range holds 75 instructions. The issue body says 78, which was my mistake. I counted the linesobjdumpprints, and three long instructions wrap onto a second line.The other file,
amethyst_filegraph.abi3.so, was already whole.PyInit_amethyst_filegraphon 1e51c73 has 31 instructions and 157 bytes. Its decompile is 53 lines. It has no terminal-call line.#1666 landed on main as 49e6f00 while I tested. Its head takes
PyInit_lootfrom 8 instructions to 59 on this file. The details are in my comment on #1666.Not run
malloc,memcpy,memset,strlen,__errno_location). I checked only__tls_get_addrat 0x4e1046.Thanks, morluto, for the fix in 69a8b3a.
Test setup
🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊