From 5d7b36e7501077689d1b2dcf44f9f8a6819be242 Mon Sep 17 00:00:00 2001 From: blasty Date: Sat, 25 Jul 2026 22:33:16 +0200 Subject: projects phase 3: follow an import into the binary that implements it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following a call to strcmp reached the PLT/extern entry and stopped there — Hex-Rays has nothing to decompile, because the code lives in a library this binary only references. With the other binary open in the same project we already had everything needed to cross that gap; we just weren't indexing it. Index each binary's imports and exports (KIND_IMPORT / KIND_EXPORT) alongside its functions and strings. On a follow, _import_stub asks whether the target address is one of this binary's import stubs; if so _cross_binary_impl asks the index who exports that name, and we switch there instead of landing on the thunk. Verified end to end on a real echo + libc project: Enter on `strrchr(a1, 47)` in echo's pseudocode switches to libc.so.6 and lands on strrchr at 0xaf960. Three things it turns on: * ELF symbol versioning. The importer sees strrchr@@GLIBC_2.2.5 while the provider may export any of three spellings, so raw names resolve almost nothing. domain.link_name() cuts at the first '@'; Linkage.raw keeps what IDA reported, which is what the listing shows. * Exact match, not substring — ProjectIndex.exact(), so `read` doesn't bind to pread/read_line/thread_start. It also answers below the 3-char trigram floor, and plenty of real exports are that short. * Resolution reads the on-disk index, so a provider resolves while its worker is evicted. That's what the index was for. When nothing in the project provides the symbol _follow_import declines and the normal navigation runs: landing on the stub is still the honest answer, and a single-binary session is unchanged. The PLT-stub PRESENTATION item stays open — an unprovided import should say "imported, provider not in project" rather than show a decompiler error. server/patch_server.py gains list_linkage (idautils.Entries + enum_import_names); a worker without it degrades to no linkage rather than failing. tests: index join +8 (exact vs substring, short names, exclude-self, reverse join, kind isolation, forget unresolves) and link_name +4. 36/0 index, 195/0 scenarios, 23/0 project UI, 33/0 project, 22/0 pool. --- idatui/domain.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) (limited to 'idatui/domain.py') diff --git a/idatui/domain.py b/idatui/domain.py index ef37ab9..681542e 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -176,6 +176,35 @@ class StrLit: type: str = "" +def link_name(raw: str) -> str: + """A linkage name reduced to what actually joins across binaries. + + ELF symbol versioning means the importer sees ``strrchr@@GLIBC_2.2.5`` while + the provider may export ``strrchr``, ``strrchr@GLIBC_2.2.5`` or the versioned + spelling — comparing raw names silently resolves almost nothing. Cut at the + first '@' so both sides meet on the bare symbol. + """ + n = (raw or "").strip() + at = n.find("@") + return n[:at] if at > 0 else n + + +@dataclass(frozen=True) +class Linkage: + """One import or export: a name this binary takes from, or offers to, other + modules. ``module`` is set for imports (the library IDA attributes it to), + ``ordinal`` for exports. + + ``name`` is the joinable name; ``raw`` keeps the spelling IDA reported, which + is what the user sees in the listing. + """ + addr: int + name: str + module: str = "" + ordinal: int = 0 + raw: str = "" + + @dataclass class Decompilation: ea: int @@ -818,6 +847,7 @@ class Program: self._decomp: dict[int, tuple[Decompilation, int]] = {} self._decomp_maps: dict[int, tuple[list[list[int]], int]] = {} # line->ea sets self._strings: list["StrLit"] | None = None # whole-binary string literals + self._linkage: tuple[list["Linkage"], list["Linkage"]] | None = None self._name_gen = 0 # bumped on rename; invalidates stale name caches self._segments_cache: list[tuple[int, int, int, str]] | None = None self._sections: list[tuple[int, int, str]] | None = None @@ -1312,6 +1342,34 @@ class Program: self._strings = out return out + def linkage(self) -> tuple[list[Linkage], list[Linkage]]: + """``(imports, exports)`` for this binary, cached. ``([], [])`` if the + tool is unavailable — an old worker must not break the caller.""" + with self._lock: + hit = self._linkage + if hit is not None: + return hit + try: + payload = self.client.call("list_linkage", kind="both") + except IDAToolError: + return ([], []) + if not isinstance(payload, dict): + return ([], []) + imps = [Linkage(addr=_as_int(r.get("addr", 0)), + name=link_name(r.get("name", "")), + module=r.get("module", "") or "", + raw=r.get("name", "") or "") + for r in payload.get("imports", []) if isinstance(r, dict)] + exps = [Linkage(addr=_as_int(r.get("addr", 0)), + name=link_name(r.get("name", "")), + ordinal=int(r.get("ordinal", 0) or 0), + raw=r.get("name", "") or "") + for r in payload.get("exports", []) if isinstance(r, dict)] + out = ([i for i in imps if i.name], [e for e in exps if e.name]) + with self._lock: + self._linkage = out + return out + def decomp_map(self, ea: int) -> list[list[int]]: """Per-pseudocode-line instruction coverage for the split-view region highlight: a list aligned to the decompiled lines, each the EAs the -- cgit v1.3.1-sl0p