From 715b558be251d9a13014af736ed3a9f04e455b48 Mon Sep 17 00:00:00 2001 From: Raymond Shiner Date: Fri, 7 Aug 2026 18:56:36 -0700 Subject: [PATCH 1/2] fix(linux): resolve Mono image base from /proc//maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux read_mono_root_domain() swept the address space from 0 in 4KiB steps looking for the MZ magic. Under Wine/Proton — the only way MTGA runs on Linux, and what the README documents this path as targeting — the game's mono-2.0-bdwgc.dll is mapped around 0x6ffff_xxxx_xxxx, roughly 3e10 pages up. The loop is unbounded, so init() never returns. Wine maps the DLL from disk, so /proc//maps names it outright. Take the base from there and hand it to PEReader, mirroring what the Windows path already does with Process::module(). Verified against MTGA under Proton Experimental (Arch, kernel 6.17): init() 2.3s, then readAccount/readRanks/readInventory/readDecks(95)/ readCollection all return correct data matching the Arena UI. --- src/mono_reader.rs | 78 ++++++++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 34 deletions(-) diff --git a/src/mono_reader.rs b/src/mono_reader.rs index b078d53..828bef7 100644 --- a/src/mono_reader.rs +++ b/src/mono_reader.rs @@ -120,45 +120,55 @@ impl MonoReader { self.mono_root_domain } + /// Locate the Mono runtime's PE image base in a Wine/Proton-hosted MTGA. + /// + /// Wine maps the game's DLLs from disk, so `/proc//maps` names them + /// outright — the same information `Process::module()` hands the Windows + /// path. Sweeping memory for the `MZ` magic is not a workable substitute + /// here: Wine loads the image up around 0x6ffff_xxxx_xxxx, which is ~3e10 + /// pages above zero, so a 4KiB-stride walk never arrives. #[cfg(target_os = "linux")] - pub fn read_mono_root_domain(&mut self) -> usize { - // walk trough the memory of the process to find the mono root domain - // we use the PE header magic number (MZ) to find the mono library - - let mut addr = 0 as usize; - let mut found = false; - let mut managed = DataMember::::new(self.handle); - - println!("Searching for mono library..."); - - while !found { - let val = unsafe { - managed.set_offset(vec![addr]); - match managed.read() { - Ok(val) => val, - Err(_e) => 0, - } - }; + fn mono_module_base(pid: u32) -> Option { + let maps = std::fs::read_to_string(format!("/proc/{}/maps", pid)).ok()?; + + maps.lines() + .filter(|line| line.ends_with(constants::MONO_LIBRARY)) + .filter_map(|line| { + // start-end perms file_offset dev inode path + let mut fields = line.split_whitespace(); + let start = fields.next()?.split('-').next()?; + let file_offset = fields.nth(1)?; + + Some(( + usize::from_str_radix(file_offset, 16).ok()?, + usize::from_str_radix(start, 16).ok()?, + )) + }) + // The mapping at file offset 0 is the one carrying the PE header. + .min() + .map(|(_, start)| start) + } - // MZ - if val == 0x5a4d { - let pe = PEReader::new(&self, addr); + #[cfg(target_os = "linux")] + pub fn read_mono_root_domain(&mut self) -> usize { + let base = match Self::mono_module_base(self.pid) { + Some(base) => base, + None => { + eprintln!("Mono module not found in process maps"); + return 0; + } + }; - let mono_root_offset = pe.get_function_offset("mono_get_root_domain"); + let pe = PEReader::new(&self, base); - match mono_root_offset { - Ok(offset) => { - println!("mono_get_root_domain offset: {:?}", offset); - self.mono_root_domain = addr + offset as usize; - found = true - } - _ => { - // This is not the library we are looking for - // eprintln!("Error: mono_get_root_domain not found"); - } - } + match pe.get_function_offset("mono_get_root_domain") { + Ok(offset) => { + println!("mono_get_root_domain offset: {:?}", offset); + self.mono_root_domain = base + offset as usize; + } + _ => { + eprintln!("Error: mono_get_root_domain not found"); } - addr += 4096; } println!("mono_root_domain addr: {:x?}", self.mono_root_domain); From be692854e7c3dc5e7323976593737ec63b7cb3ef Mon Sep 17 00:00:00 2001 From: Raymond Shiner Date: Fri, 7 Aug 2026 18:58:16 -0700 Subject: [PATCH 2/2] fix(linux): stop reporting non-root users as unprivileged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_admin() equated privilege with uid 0, which is a Windows-shaped assumption. On Linux, cross-process memory reads are permitted to root, to any process holding CAP_SYS_PTRACE, and to same-uid processes whenever the Yama LSM is absent or set to ptrace_scope=0. Users in those last two groups can read MTGA's memory perfectly well, but the desktop app still greeted them with "App is not running with admin/elevated privileges" — a warning with no action behind it, since elevating was never what they were missing. Check the actual conditions instead. Verified on Arch (ptrace_scope=0): isAdmin() now returns true as an ordinary user, and the typed readers succeed in the same process. --- src/mono_reader.rs | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/mono_reader.rs b/src/mono_reader.rs index 828bef7..01cb123 100644 --- a/src/mono_reader.rs +++ b/src/mono_reader.rs @@ -61,7 +61,13 @@ impl MonoReader { } #[cfg(target_os = "linux")] { - return sudo::check() == RunningAs::Root; + // Root is only one of three ways to qualify for reading another + // process' memory. A plain user with CAP_SYS_PTRACE, or any user at + // all under a permissive Yama policy, can call process_vm_readv + // just fine — reporting "not elevated" to them is a false alarm. + return sudo::check() == RunningAs::Root + || Self::has_cap_sys_ptrace() + || Self::yama_allows_attach(); } #[cfg(target_os = "macos")] { @@ -120,6 +126,37 @@ impl MonoReader { self.mono_root_domain } + /// Whether this process holds CAP_SYS_PTRACE, which grants cross-process + /// memory reads without being root. + #[cfg(target_os = "linux")] + fn has_cap_sys_ptrace() -> bool { + // CapEff is a hex bitmask in /proc/self/status; CAP_SYS_PTRACE is bit 19. + std::fs::read_to_string("/proc/self/status") + .ok() + .and_then(|status| { + status + .lines() + .find_map(|line| line.strip_prefix("CapEff:")) + .and_then(|caps| u64::from_str_radix(caps.trim(), 16).ok()) + }) + .map(|caps| caps & (1 << 19) != 0) + .unwrap_or(false) + } + + /// Whether the Yama LSM permits attaching to a non-descendant process. + /// + /// Scope 0 is unrestricted same-uid access. Scope 1 (the common default) + /// limits attachment to descendants, and MTGA is never our child, so + /// anything above 0 means we cannot read it as an ordinary user. + #[cfg(target_os = "linux")] + fn yama_allows_attach() -> bool { + match std::fs::read_to_string("/proc/sys/kernel/yama/ptrace_scope") { + // No Yama at all: classic ptrace rules, same-uid is sufficient. + Err(_) => true, + Ok(scope) => scope.trim() == "0", + } + } + /// Locate the Mono runtime's PE image base in a Wine/Proton-hosted MTGA. /// /// Wine maps the game's DLLs from disk, so `/proc//maps` names them