Sign in Sign up
kretrod/lodestone Public
Branches
master
408 lines (372 loc) · 14.7 KB Raw
//! Linux loader.
//!
//! Two ways in, because they suit different moments:
//!
//!   * `--preload [--] <command...>` runs the game with `LD_PRELOAD` pointing at
//!     the client, so its constructor fires as the process starts. Nothing
//!     exotic is involved — no ptrace, no root — which makes it the reliable
//!     route. With no command it just prints the wrapper line to paste into a
//!     launcher such as Prism.
//!   * With no `--preload` it behaves like the Windows loader: wait for
//!     Minecraft to appear, then attach with ptrace and make the running process
//!     dlopen the client. That needs ptrace permission, which most distributions
//!     restrict by default.
//!
//! Finding `dlopen` in the target leans on one fact: the same libc file is
//! mapped into both processes, so the symbol's offset within it is identical and
//! only the load address differs.

use std::ffi::c_void;
use std::io::Write;
use std::path::PathBuf;

/// The client library, built into this binary by build.rs. Empty when the
/// library had not been built at the time the loader was compiled.
const EMBEDDED_CLIENT: &[u8] = include_bytes!(env!("LODESTONE_CLIENT_DLL"));

pub fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();

    if args.iter().any(|a| a == "--eject") {
        report(eject());
        return;
    }

    // An explicit path wins, so a freshly built library can be loaded without
    // rebuilding the loader around it.
    let lib = match args.iter().position(|a| a == "--lib") {
        Some(i) => args
            .get(i + 1)
            .cloned()
            .ok_or_else(|| "--lib needs a path".to_string()),
        None => extract_client(),
    };
    let lib = match lib {
        Ok(p) => p,
        Err(e) => {
            eprintln!("error: {e}");
            std::process::exit(1);
        }
    };
    let lib = match std::fs::canonicalize(&lib) {
        Ok(p) => p.to_string_lossy().into_owned(),
        Err(e) => {
            eprintln!("error: {lib}: {e}");
            std::process::exit(1);
        }
    };

    if let Some(i) = args.iter().position(|a| a == "--preload") {
        let cmd: Vec<String> = args[i + 1..]
            .iter()
            .filter(|a| a.as_str() != "--")
            .cloned()
            .collect();
        report(preload(&lib, &cmd));
        return;
    }

    let wait = !args.iter().any(|a| a == "--no-wait");
    report(attach(&lib, wait));
}

fn report(r: Result<String, String>) {
    match r {
        Ok(m) => println!("{m}"),
        Err(e) => {
            eprintln!("error: {e}");
            std::process::exit(1);
        }
    }
}

// ---------------------------------------------------------------------------
// preload
// ---------------------------------------------------------------------------

fn preload(lib: &str, cmd: &[String]) -> Result<String, String> {
    if cmd.is_empty() {
        return Ok(format!(
            "add this as a wrapper command in your launcher\n\
             (Prism: Edit Instance → Settings → Custom commands → Wrapper command):\n\
             \n    env LD_PRELOAD={lib}\n"
        ));
    }
    // Append rather than replace: something else may already be preloaded.
    let existing = std::env::var("LD_PRELOAD").unwrap_or_default();
    let value = if existing.is_empty() {
        lib.to_string()
    } else {
        format!("{lib}:{existing}")
    };
    let status = std::process::Command::new(&cmd[0])
        .args(&cmd[1..])
        .env("LD_PRELOAD", value)
        .status()
        .map_err(|e| format!("could not run {}: {e}", cmd[0]))?;
    Ok(format!("game exited with {status}"))
}

// ---------------------------------------------------------------------------
// finding the game
// ---------------------------------------------------------------------------

/// The JVM with GLFW mapped is the game — the same test the Windows side uses.
/// `Ok(None)` means it is simply not running yet.
fn find_game_now() -> Result<Option<i32>, String> {
    let mut games = Vec::new();
    for entry in std::fs::read_dir("/proc").map_err(|e| format!("/proc: {e}"))? {
        let Ok(entry) = entry else { continue };
        let Some(pid) = entry.file_name().to_str().and_then(|s| s.parse::<i32>().ok()) else {
            continue;
        };
        let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default();
        let comm = comm.trim();
        if comm != "java" && comm != "javaw" {
            continue;
        }
        let maps = std::fs::read_to_string(format!("/proc/{pid}/maps")).unwrap_or_default();
        if maps.contains("libglfw") {
            games.push(pid);
        }
    }
    match games.len() {
        1 => Ok(Some(games[0])),
        0 => Ok(None),
        n => Err(format!("{n} Minecraft processes are running")),
    }
}

/// Wait until the game shows up, rewriting one line while it does.
fn wait_for_game() -> Result<i32, String> {
    if let Some(pid) = find_game_now()? {
        return Ok(pid);
    }
    const SPIN: [char; 4] = ['|', '/', '-', '\\'];
    let mut i = 0usize;
    loop {
        print!("\rwaiting for Minecraft... {} ", SPIN[i % SPIN.len()]);
        let _ = std::io::stdout().flush();
        i += 1;
        std::thread::sleep(std::time::Duration::from_millis(400));
        if let Some(pid) = find_game_now()? {
            print!("\r                              \r");
            let _ = std::io::stdout().flush();
            // Let the game settle: libglfw is mapped a moment before the window
            // and GL context are actually up.
            std::thread::sleep(std::time::Duration::from_millis(1500));
            return Ok(pid);
        }
    }
}

// ---------------------------------------------------------------------------
// ptrace injection
// ---------------------------------------------------------------------------

fn attach(lib: &str, wait: bool) -> Result<String, String> {
    let pid = if wait {
        wait_for_game()?
    } else {
        find_game_now()?.ok_or_else(|| "Minecraft is not running".to_string())?
    };
    // SAFETY: every ptrace call is checked, the target is stopped for the whole
    // sequence, and both its registers and the stack bytes we borrow are put
    // back before we detach.
    unsafe { ptrace_inject(pid, lib) }
}

unsafe fn ptrace_inject(pid: i32, lib: &str) -> Result<String, String> {
    let dlopen_addr = target_dlopen(pid)?;

    if libc::ptrace(libc::PTRACE_ATTACH, pid, null(), null()) < 0 {
        return Err(format!(
            "ptrace attach to {pid}: {}\n\
             hint: ptrace of an unrelated process is usually restricted — either run this \
             as root, or allow it with: sudo sysctl -w kernel.yama.ptrace_scope=0\n\
             (or use --preload, which needs no ptrace at all)",
            std::io::Error::last_os_error()
        ));
    }
    let mut status = 0i32;
    libc::waitpid(pid, &mut status, 0);

    let result = call_dlopen(pid, dlopen_addr, lib);
    libc::ptrace(libc::PTRACE_DETACH, pid, null(), null());

    let handle = result?;
    if handle == 0 {
        return Err(format!(
            "dlopen in pid {pid} returned NULL — wrong architecture, or a dependency is missing"
        ));
    }
    Ok(format!("injected into pid {pid} (handle {handle:#x})"))
}

/// Drive the stopped target through one call to `dlopen`, then put it back
/// exactly as it was.
unsafe fn call_dlopen(pid: i32, dlopen_addr: u64, lib: &str) -> Result<u64, String> {
    let mut saved: libc::user_regs_struct = std::mem::zeroed();
    if libc::ptrace(libc::PTRACE_GETREGS, pid, null(), &mut saved as *mut _ as *mut c_void) < 0 {
        return Err(format!("PTRACE_GETREGS: {}", std::io::Error::last_os_error()));
    }

    // Borrow a patch of stack well below the interrupted frame, clear of the
    // 128-byte red zone, for the path string and a fake return address.
    let mut path = lib.as_bytes().to_vec();
    path.push(0);
    let scratch = (saved.rsp - 8192) & !0xF;
    let sp = (scratch - 256) & !0xF;
    let sp = sp - 8;

    let old_path = peek_bytes(pid, scratch, path.len())?;
    let old_ret = peek_bytes(pid, sp, 8)?;
    poke_bytes(pid, scratch, &path)?;
    // Return to 0 on purpose: the fault hands control straight back to us.
    poke_bytes(pid, sp, &0u64.to_ne_bytes())?;

    let mut regs = saved;
    regs.rdi = scratch;
    regs.rsi = (libc::RTLD_NOW | libc::RTLD_GLOBAL) as u64;
    regs.rsp = sp;
    regs.rip = dlopen_addr;
    regs.rax = 0;
    if libc::ptrace(libc::PTRACE_SETREGS, pid, null(), &regs as *const _ as *mut c_void) < 0 {
        return Err(format!("PTRACE_SETREGS: {}", std::io::Error::last_os_error()));
    }
    if libc::ptrace(libc::PTRACE_CONT, pid, null(), null()) < 0 {
        return Err(format!("PTRACE_CONT: {}", std::io::Error::last_os_error()));
    }

    let mut status = 0i32;
    libc::waitpid(pid, &mut status, 0);

    let mut after: libc::user_regs_struct = std::mem::zeroed();
    libc::ptrace(libc::PTRACE_GETREGS, pid, null(), &mut after as *mut _ as *mut c_void);
    let handle = after.rax;

    // Put the borrowed stack and the registers back.
    let _ = poke_bytes(pid, scratch, &old_path);
    let _ = poke_bytes(pid, sp, &old_ret);
    libc::ptrace(libc::PTRACE_SETREGS, pid, null(), &saved as *const _ as *mut c_void);
    Ok(handle)
}

/// Where `dlopen` lives in the target.
///
/// The same libc file is mapped into both processes, so the offset of the symbol
/// within that file is identical and only the load address differs. Since glibc
/// 2.34 `dlopen` lives in libc itself rather than a separate libdl.
unsafe fn target_dlopen(pid: i32) -> Result<u64, String> {
    let sym = libc::dlsym(libc::RTLD_DEFAULT, c"dlopen".as_ptr());
    if sym.is_null() {
        return Err("could not find dlopen in this process".into());
    }
    let mut info: libc::Dl_info = std::mem::zeroed();
    if libc::dladdr(sym, &mut info) == 0 || info.dli_fname.is_null() {
        return Err("dladdr could not name the library dlopen came from".into());
    }
    let file = std::ffi::CStr::from_ptr(info.dli_fname)
        .to_string_lossy()
        .into_owned();

    let local_base = module_base("/proc/self/maps", &file)
        .ok_or_else(|| format!("{file} is not mapped in this process"))?;
    let target_base = module_base(&format!("/proc/{pid}/maps"), &file).ok_or_else(|| {
        format!("{file} is not mapped in pid {pid} — the game may be using a different libc")
    })?;
    Ok(target_base + (sym as u64 - local_base))
}

/// The lowest address at which `needle` is mapped, from a /proc maps file.
fn module_base(maps: &str, needle: &str) -> Option<u64> {
    let text = std::fs::read_to_string(maps).ok()?;
    let mut best: Option<u64> = None;
    for line in text.lines() {
        if !line.contains(needle) {
            continue;
        }
        let start = line.split('-').next()?;
        let Ok(addr) = u64::from_str_radix(start, 16) else {
            continue;
        };
        best = Some(best.map_or(addr, |b: u64| b.min(addr)));
    }
    best
}

fn null() -> *mut c_void {
    std::ptr::null_mut()
}

/// Read `len` bytes out of the stopped target, a word at a time.
unsafe fn peek_bytes(pid: i32, addr: u64, len: usize) -> Result<Vec<u8>, String> {
    let mut out = Vec::with_capacity(len);
    let mut i = 0usize;
    while i < len {
        *libc::__errno_location() = 0;
        let word = libc::ptrace(
            libc::PTRACE_PEEKDATA,
            pid,
            (addr + i as u64) as *mut c_void,
            null(),
        );
        if word == -1 && *libc::__errno_location() != 0 {
            return Err(format!(
                "PTRACE_PEEKDATA at {:#x}: {}",
                addr + i as u64,
                std::io::Error::last_os_error()
            ));
        }
        let bytes = (word as u64).to_ne_bytes();
        let n = (len - i).min(8);
        out.extend_from_slice(&bytes[..n]);
        i += 8;
    }
    out.truncate(len);
    Ok(out)
}

/// Write bytes into the stopped target, read-modify-writing the final partial
/// word so its neighbours survive.
unsafe fn poke_bytes(pid: i32, addr: u64, data: &[u8]) -> Result<(), String> {
    let mut i = 0usize;
    while i < data.len() {
        let n = (data.len() - i).min(8);
        let mut word = [0u8; 8];
        if n < 8 {
            let existing = peek_bytes(pid, addr + i as u64, 8)?;
            word.copy_from_slice(&existing);
        }
        word[..n].copy_from_slice(&data[i..i + n]);
        if libc::ptrace(
            libc::PTRACE_POKEDATA,
            pid,
            (addr + i as u64) as *mut c_void,
            u64::from_ne_bytes(word) as *mut c_void,
        ) < 0
        {
            return Err(format!(
                "PTRACE_POKEDATA at {:#x}: {}",
                addr + i as u64,
                std::io::Error::last_os_error()
            ));
        }
        i += n;
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// housekeeping
// ---------------------------------------------------------------------------

/// Beside the loader, so the pair works from wherever they are put.
fn marker() -> PathBuf {
    std::env::current_exe()
        .ok()
        .and_then(|p| p.parent().map(|d| d.join("unload")))
        .unwrap_or_else(|| PathBuf::from("unload"))
}

/// Ask the client to unload. The client polls for this file and pulls its hooks
/// out from its own thread, which is the only safe order — so this is the same
/// handshake the Windows loader uses.
fn eject() -> Result<String, String> {
    std::fs::write(marker(), "1").map_err(|e| format!("could not write the unload marker: {e}"))?;
    std::thread::sleep(std::time::Duration::from_millis(1200));
    let _ = std::fs::remove_file(marker());
    Ok("asked the client to stop".into())
}

/// Write the embedded client out so it can be loaded.
///
/// Under a fresh name each run: the client never unmaps itself, so reusing a
/// name would only ever load the copy already in the process.
fn extract_client() -> Result<String, String> {
    if EMBEDDED_CLIENT.is_empty() {
        return Err("this build has no client embedded; pass --lib <path>".into());
    }
    let dir = std::env::current_exe()
        .ok()
        .and_then(|p| p.parent().map(|d| d.to_path_buf()))
        .unwrap_or_else(std::env::temp_dir);
    let stamp = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let name = format!("liblodestone-client-{stamp}.so");
    let path = dir.join(&name);
    if let Err(e) = std::fs::write(&path, EMBEDDED_CLIENT) {
        // A read-only folder is a normal thing to be dropped into.
        let fallback = std::env::temp_dir().join(&name);
        std::fs::write(&fallback, EMBEDDED_CLIENT)
            .map_err(|e2| format!("could not write the client: {e} / {e2}"))?;
        return Ok(fallback.to_string_lossy().into_owned());
    }
    Ok(path.to_string_lossy().into_owned())
}