diff --git a/.github/actions/fetch-runtime/action.yml b/.github/actions/fetch-runtime/action.yml index 7a5f826..6252794 100644 --- a/.github/actions/fetch-runtime/action.yml +++ b/.github/actions/fetch-runtime/action.yml @@ -6,7 +6,7 @@ inputs: description: Dependency bundle version. required: true platform: - description: Platform slug (linux-x64, linux-arm64, windows-x64, macos-arm64, macos-x64). + description: Platform slug (linux-x64, linux-arm64, windows-x64, windows-arm64, macos-arm64, macos-x64). required: true sdk-tag: description: If set, also sparse-checkout the SDK headers at this tag and export IDA_SDK_DIR. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56b8d9d..644d7f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,12 @@ jobs: - { runner: macos-15-intel, platform: macos-x64, version: 9.3.260421sp2, sdk-tag: v9.3.1-release } - { runner: macos-14, platform: macos-arm64, version: 9.3.260421sp2, sdk-tag: v9.3.1-release } - { runner: windows-2022, platform: windows-x64, version: 9.3.260421sp2, sdk-tag: v9.3.1-release } + - { runner: ubuntu-latest, platform: linux-x64, version: 9.4.260714, sdk-tag: v9.4.0-release } + - { runner: ubuntu-24.04-arm, platform: linux-arm64, version: 9.4.260714, sdk-tag: v9.4.0-release } + - { runner: macos-15-intel, platform: macos-x64, version: 9.4.260714, sdk-tag: v9.4.0-release } + - { runner: macos-14, platform: macos-arm64, version: 9.4.260714, sdk-tag: v9.4.0-release } + - { runner: windows-2022, platform: windows-x64, version: 9.4.260714, sdk-tag: v9.4.0-release } + - { runner: windows-11-arm, platform: windows-arm64, version: 9.4.260714, sdk-tag: v9.4.0-release } # - { runner: ubuntu-latest, platform: linux-x64, version: 9.2.250908, sdk-tag: v9.2.0-release } runs-on: ${{ matrix.runner }} timeout-minutes: 30 @@ -71,6 +77,10 @@ jobs: AWS_REGION: auto S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} S3_BUCKET: ${{ secrets.S3_BUCKET }} + # clang-tidy publishes no win_arm64 wheel and its sdist won't build; the tidy and fmt + # steps skip Windows anyway. Job-level, not step-level: mise resolves tools lazily, so + # a later step re-attempts the install. `runner` is unavailable here, hence matrix. + MISE_DISABLE_TOOLS: ${{ startsWith(matrix.platform, 'windows') && 'pipx:clang-tidy,pipx:clang-format' || '' }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -113,8 +123,24 @@ jobs: CTCACHE_DIR: ${{ runner.temp }}/ctcache CTCACHE_SAVE_OUTPUT: "1" run: just tidy + # IDALOG is the runtime's own message log, and it survives a fatal exit -- the only channel + # that says *why* bring-up died, since the facade's exit trap does not cover init and stdout + # capture is a no-op on Windows. Explicit bash (pwsh mangles `$?`) and no `-q`, so cargo's + # own diagnostics and the real exit code both survive. - name: Accept IDA EULA - run: cargo run -q -p idakit --example accept_eula + shell: bash + env: + IDALOG: ida-init.log + IDALOG_TIMESTAMPS: "1" + run: | + set +e + cargo run -p idakit --example accept_eula + rc=$? + set -e + echo "accept_eula exit=$rc" + echo "--- IDALOG ---" + cat ida-init.log || echo "(no IDALOG produced)" + exit $rc # Fixtures are host-independent, so every platform fetches the same corpus tier; the # exported manifest drives both the corpus matrix and the dedicated tests below. - name: Fetch test corpus diff --git a/crates/idakit-sys/facade/cfg.cpp b/crates/idakit-sys/facade/cfg.cpp index 0545363..13893c5 100644 --- a/crates/idakit-sys/facade/cfg.cpp +++ b/crates/idakit-sys/facade/cfg.cpp @@ -18,6 +18,8 @@ // construct it below); gen_cfg.h only forward-declares it. #include "gen_bridge.h" +#include + namespace gen { // Builds the control-flow graph of the function at addr, owned by the returned handle; throws if diff --git a/crates/idakit-sys/facade/cfunc_shims.cpp b/crates/idakit-sys/facade/cfunc_shims.cpp index f305820..dc11fa4 100644 --- a/crates/idakit-sys/facade/cfunc_shims.cpp +++ b/crates/idakit-sys/facade/cfunc_shims.cpp @@ -20,6 +20,8 @@ #include "cfunc_shims.h" +#include + namespace { // The decompiler is a plugin; init it once (idempotent) before any decompile_func call. diff --git a/crates/idakit-sys/facade/function.cpp b/crates/idakit-sys/facade/function.cpp index 5912432..c1b8af6 100644 --- a/crates/idakit-sys/facade/function.cpp +++ b/crates/idakit-sys/facade/function.cpp @@ -13,6 +13,8 @@ #include "gen_function.h" +#include + namespace gen { // Start address of the nth function in the functions list, or BADADDR when n is out of range. diff --git a/crates/idakit-sys/facade/hexrays.cpp b/crates/idakit-sys/facade/hexrays.cpp index 4238990..543942c 100644 --- a/crates/idakit-sys/facade/hexrays.cpp +++ b/crates/idakit-sys/facade/hexrays.cpp @@ -23,6 +23,8 @@ // below); gen_hexrays.h only forward-declares them. #include "gen_bridge.h" +#include + using namespace facade; namespace gen { diff --git a/crates/idakit-sys/facade/qvec_bridge.cpp b/crates/idakit-sys/facade/qvec_bridge.cpp index 1bfb5d0..d77d77a 100644 --- a/crates/idakit-sys/facade/qvec_bridge.cpp +++ b/crates/idakit-sys/facade/qvec_bridge.cpp @@ -17,6 +17,8 @@ #include "qvec_bridge.h" +#include + // The recipe's "explicit instantiation to force the symbols" step (template class // qvector;) does NOT compile here: it instantiates *every* member, and // qvector::resize_noinit carries CASSERT(is_trivially_constructible), which range_t diff --git a/crates/idakit-sys/facade/range.cpp b/crates/idakit-sys/facade/range.cpp index d9e1046..84d4059 100644 --- a/crates/idakit-sys/facade/range.cpp +++ b/crates/idakit-sys/facade/range.cpp @@ -17,6 +17,8 @@ // it) and instantiates rust::Vec; gen_range.h only forward-declares ChunkInfo. #include "gen_bridge.h" +#include + namespace gen { // The entry (main) chunk of the function at addr; throws if there's no function there or it has diff --git a/crates/idakit-sys/facade/reference.cpp b/crates/idakit-sys/facade/reference.cpp index 8a55280..3ac5640 100644 --- a/crates/idakit-sys/facade/reference.cpp +++ b/crates/idakit-sys/facade/reference.cpp @@ -16,6 +16,8 @@ // instantiates rust::Vec; gen_reference.h only forward-declares XrefRec. #include "gen_bridge.h" +#include + namespace gen { // Every cross-reference edge to (is_to) or from addr, collected into an owned Vec; empty if there diff --git a/crates/idakit-sys/facade/segment.cpp b/crates/idakit-sys/facade/segment.cpp index d53b99f..dbd37a4 100644 --- a/crates/idakit-sys/facade/segment.cpp +++ b/crates/idakit-sys/facade/segment.cpp @@ -13,6 +13,8 @@ #include "gen_seg.h" +#include + namespace gen { // The segment's comment (repeatable or regular) at index n; throws when n is out of range or that diff --git a/crates/idakit-sys/facade/typewalk_bridge.cpp b/crates/idakit-sys/facade/typewalk_bridge.cpp index e68e090..7914213 100644 --- a/crates/idakit-sys/facade/typewalk_bridge.cpp +++ b/crates/idakit-sys/facade/typewalk_bridge.cpp @@ -28,6 +28,8 @@ #include "gen_facade_consts.h" // gen::NONE #include "gen_visitors.h" +#include + namespace bridge { namespace { diff --git a/crates/idakit/src/claim.rs b/crates/idakit/src/claim.rs index 5497510..484a87e 100644 --- a/crates/idakit/src/claim.rs +++ b/crates/idakit/src/claim.rs @@ -8,7 +8,7 @@ //! //! `g_main` is private, so we recover its address by decoding the PC-relative load //! in `is_main_thread`'s prologue instead of hardcoding an offset: `mov register, -//! [rip+disp32]` on x86-64, an `adrp`+`ldr` pair on aarch64. +//! [rip+disp32]` on x86-64, an `adrp` plus a load on aarch64. use std::ffi::c_void; use std::ptr; @@ -100,40 +100,99 @@ unsafe fn follow_jmp_thunk(entry: *const u8) -> *const u8 { entry } -/// aarch64: decodes `is_main_thread`'s `&g_main` materialization, an `adrp Xd, page` + -/// `ldr Xt, [Xd, off]` pair. +/// aarch64: decodes `is_main_thread`'s `&g_main` materialization, an `adrp Xd, page` followed by +/// a load through it. /// -/// A stack-save prologue precedes it, so this scans a short window for the first such pair: -/// `g_main = (adrp_pc & !0xfff) + (page << 12) + off`. +/// Clang loads straight through the `adrp` destination; MSVC splits the address across +/// `adrp`+`add` and loads through the sum, so a forwarding `add` is followed before the load. A +/// stack-save prologue precedes either, hence the scan. On Windows the function's address is an +/// import thunk; [`follow_import_thunk`] resolves it to the body. #[cfg(target_arch = "aarch64")] fn decode_g_main(entry: *const u8) -> Result<*const u8, String> { const WINDOW: usize = 8; + + // SAFETY: `entry` is a mapped code pointer; a thunk and its slot are mapped (see fn doc). + let entry = unsafe { follow_import_thunk(entry) }; + // SAFETY: `entry` is a mapped executable function; its first WINDOW 4-byte // instructions lie within the body. let insns: [u32; WINDOW] = unsafe { ptr::read(entry.cast()) }; for (i, &adrp) in insns.iter().enumerate() { - // ADRP: bit31=1, bits28..24=10000 (mask 0x9f00_0000 -> 0x9000_0000). - if adrp & 0x9f00_0000 != 0x9000_0000 { + let Some((mut reg, mut base)) = adrp_page(entry.wrapping_add(i * 4) as u64, adrp) else { continue; - } - let rd = adrp & 0x1f; - let imm = i64::from((((adrp >> 5) & 0x7_ffff) << 2) | ((adrp >> 29) & 0x3)); - let page = (imm ^ 0x10_0000) - 0x10_0000; // sign-extend the 21-bit page count - let adrp_pc = entry.wrapping_add(i * 4) as u64; - let base = ((adrp_pc & !0xfff) as i64 + (page << 12)) as u64; - - // First following `ldr Xt, [Xd, off]` (64-bit unsigned offset, mask - // 0xffc0_0000 -> 0xf940_0000) that dereferences Xd carries the offset. - for &ldr in &insns[i + 1..] { - if ldr & 0xffc0_0000 != 0xf940_0000 || (ldr >> 5) & 0x1f != rd { - continue; + }; + for &insn in &insns[i + 1..] { + if let Some(off) = ldr_offset(insn, reg) { + return Ok(base.wrapping_add(off) as *const u8); + } + if let Some((dst, imm)) = add_imm(insn, reg) { + reg = dst; + base = base.wrapping_add(imm); } - let off = u64::from((ldr >> 10) & 0xfff) * 8; // imm12, scaled by access size - return Ok(base.wrapping_add(off) as *const u8); } } - Err("is_main_thread has no adrp+ldr g_main load in its prologue".to_owned()) + Err(format!( + "no g_main load in is_main_thread prologue {insns:08x?}" + )) +} + +/// The destination register and page address of an `adrp Xd, page` sited at `pc`. +#[cfg(target_arch = "aarch64")] +fn adrp_page(pc: u64, insn: u32) -> Option<(u32, u64)> { + if insn & 0x9f00_0000 != 0x9000_0000 { + return None; + } + let imm = i64::from((((insn >> 5) & 0x7_ffff) << 2) | ((insn >> 29) & 0x3)); + let page = (imm ^ 0x10_0000) - 0x10_0000; // sign-extend the 21-bit page count + Some((insn & 0x1f, ((pc & !0xfff) as i64 + (page << 12)) as u64)) +} + +/// The destination register and immediate of a 64-bit `add Xd, Xn, #imm` based on `rn`. +#[cfg(target_arch = "aarch64")] +fn add_imm(insn: u32, rn: u32) -> Option<(u32, u64)> { + if insn & 0xff80_0000 != 0x9100_0000 || (insn >> 5) & 0x1f != rn { + return None; + } + let shift = if insn & 0x0040_0000 == 0 { 0 } else { 12 }; // `sh` scales the immediate by a page + Some((insn & 0x1f, u64::from((insn >> 10) & 0xfff) << shift)) +} + +/// The byte offset of a 64-bit `ldr Xt, [Xn, #off]` based on `rn`. +#[cfg(target_arch = "aarch64")] +fn ldr_offset(insn: u32, rn: u32) -> Option { + if insn & 0xffc0_0000 != 0xf940_0000 || (insn >> 5) & 0x1f != rn { + return None; + } + Some(u64::from((insn >> 10) & 0xfff) * 8) // imm12, scaled by access size +} + +/// Follows an ARM64 import thunk (`adrp Xd, page`; `ldr Xd, [Xd, off]`; `br Xd`) to its target. +/// +/// On Windows the address of an imported function is such a thunk rather than the body; the real +/// address lives in the slot the `ldr` reads. Elsewhere the address is already the body, whose +/// prologue does not tail-jump, so `entry` is returned unchanged. Requiring the `br` through the +/// adrp's own register keeps an ordinary prologue, or an ELF PLT entry (which stages through x16 +/// then x17), from matching. +/// +/// # Safety +/// `entry` must be a mapped code pointer. When it is a thunk, its three instructions and the slot +/// it references (both in the same image) must be mapped, which holds for a real thunk. +#[cfg(target_arch = "aarch64")] +unsafe fn follow_import_thunk(entry: *const u8) -> *const u8 { + // SAFETY: caller guarantees `entry` is a mapped code pointer, so its first 12 bytes read. + let [adrp, ldr, br]: [u32; 3] = unsafe { ptr::read(entry.cast()) }; + let Some((rd, base)) = adrp_page(entry as u64, adrp) else { + return entry; + }; + if ldr & 0x1f != rd || br != (0xd61f_0000 | (rd << 5)) { + return entry; + } + match ldr_offset(ldr, rd) { + // SAFETY: a real thunk's slot is a mapped, loader-bound pointer in the same image. + Some(off) => unsafe { *(base.wrapping_add(off) as *const *const u8) }, + None => entry, + } } #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] @@ -277,4 +336,18 @@ mod aarch64_tests { let w = [0u32; 8]; assert!(decode_g_main(w.as_ptr().cast::()).is_err()); } + + /// The real Windows ARM64 prologue: `adrp x8` / `add x20, x8, #0x620` / `ldr x19, [x20, #8]`. + /// The load's base is the `add`'s destination, not the `adrp`'s, so the pair alone misses it. + #[test] + fn decodes_the_forwarding_add_form() { + const ADRP_X8: u32 = 0xB001_46E8; + const ADD_X20_X8: u32 = 0x9118_8114; + const LDR_X19_X20_8: u32 = 0xF940_0693; + + let (reg, base) = adrp_page(0x1_006b_5c58, ADRP_X8).expect("adrp x8"); + let (reg, imm) = add_imm(ADD_X20_X8, reg).expect("add x20, x8"); + let off = ldr_offset(LDR_X19_X20_8, reg).expect("ldr x19, [x20]"); + assert!(base + imm + off == 0x1_02f9_2628); + } } diff --git a/crates/idakit/src/kernel.rs b/crates/idakit/src/kernel.rs index bf9e9bb..34ea0f2 100644 --- a/crates/idakit/src/kernel.rs +++ b/crates/idakit/src/kernel.rs @@ -204,6 +204,8 @@ impl IdaConfig { // pump too so no stray panic can unwind and kill the kernel. let _ = catch_unwind(AssertUnwindSafe(|| job(&mut idb))); } + // A database left open at exit deadlocks 9.4's atexit flush. + idb.close(false); }) .expect("spawn kernel thread"); diff --git a/crates/idakit/src/lib.rs b/crates/idakit/src/lib.rs index 1100e45..4317542 100644 --- a/crates/idakit/src/lib.rs +++ b/crates/idakit/src/lib.rs @@ -297,6 +297,8 @@ use crate::kernel::KernelClaim; pub struct Database { /// Interior mutability lets `decompile(&self)` init Hex-Rays lazily. hexrays_ready: Cell, + /// Whether a database is currently open. + is_open: Cell, /// `Some` for an in-place `Database`; `None` for the actor's, whose claim `run` holds. _claim: Option, /// Suppresses the auto `Send`/`Sync` impls. `Send` is re-granted below by a guarded @@ -369,6 +371,8 @@ impl Database { reason: reason.unwrap_or_else(|| format!("open failed (status {rc})")), }); } + // Set before auto_wait: a trap there still leaves the database open. + self.is_open.set(true); // run_auto only enables the analysis queue; block until it drains so callers // observe a fully analyzed database. Analysis runs kernel code, so it can trap too. if run_auto && self.auto_wait() == sys::EXIT_TRAPPED { @@ -383,6 +387,7 @@ impl Database { pub(crate) fn new() -> Self { Self { hexrays_ready: Cell::new(false), + is_open: Cell::new(false), _claim: None, _not_sync: PhantomData, } @@ -392,6 +397,7 @@ impl Database { pub(crate) fn owned(claim: KernelClaim) -> Self { Self { hexrays_ready: Cell::new(false), + is_open: Cell::new(false), _claim: Some(claim), _not_sync: PhantomData, } @@ -400,12 +406,17 @@ impl Database { /// Close the current database, optionally saving analysis back to the `.i64`. #[doc(alias("close_database"))] pub fn close(&mut self, save: bool) { + // Closing with no database open aborts on 9.4. + if !self.is_open.get() { + return; + } // Both are per-database, so the next open must not inherit them: a stale `hexrays_ready` // skips `hexrays_init` for a database that never had it, and the cfunc cache is keyed by // address, which means nothing across two different programs. self.clear_decompilation_cache(); self.close_database(save); self.hexrays_ready.set(false); + self.is_open.set(false); } /// Opens `path`, runs `f` against the open database, and closes it (without saving) on