Add Linux and macOS support, batch class transformations with a disk cache, and add cross-platform release packaging. Also improve runtime namespace handling, keybind editing, and startup behavior.
This commit is contained in:
bbldrizzy committed 2026-10-27 20:30:09 +00:00
1 parent 910a126f04
commit 38ff6d387c
29 files changed
+2234 -491

No files matched your search

+75 -11
View File
@@ -1,15 +1,34 @@
# Prestige injector (Rust)
`PrestigeInjector.exe` loads `prestige_native.dll` into a running Minecraft **1.21.11 Fabric** JVM (64-bit, Fabric Loader 0.19.x,
Fabric API installed). It uses the same overall method as the openvape bundle, written from scratch in Rust with no external crates.
`PrestigeInjector` loads the native loader library (`prestige_native.dll` / `libprestige_native.so` / `libprestige_native.dylib`)
into a running Minecraft **1.21.11 Fabric** JVM (64-bit, Fabric Loader 0.19.x, Fabric API installed). It uses the same overall
method as the openvape bundle, written from scratch in Rust with no external crates.
## Supported platforms
| OS | Architectures | Injector binary | Loader library |
|---------|--------------------|-----------------------|-----------------------------|
| Windows | x86_64 | `PrestigeInjector.exe`| `prestige_native.dll` |
| Linux | x86_64 | `PrestigeInjector` | `libprestige_native.so` |
| macOS | x86_64, arm64 | `PrestigeInjector` | `libprestige_native.dylib` |
The injector and library must be built for the same architecture as the target JVM.
## How it works
1. **Injector** lists visible `java.exe`/`javaw.exe` windows (refreshed every 750 ms), then runs `LoadLibraryW` in the chosen
process through `CreateRemoteThread` and confirms the module is mapped.
2. **`prestige_native.dll`** (`DllMain` only spawns a worker thread):
- waits for `jvm.dll`, `JNI_GetCreatedJavaVMs`, attaches, acquires the JVMTI redefine/retransform capabilities;
- writes the embedded payload jar (the remapped `prestige-*.jar`) to `%TEMP%\PrestigeLoader`;
1. **Injector** lists the running Fabric client JVMs, then loads the library into the chosen process.
- **Windows:** lists visible `java.exe`/`javaw.exe` windows (refreshed every 750 ms), then runs `LoadLibraryW` in the chosen
process through `CreateRemoteThread` and confirms the module is mapped.
- **Linux / macOS:** uses the HotSpot attach protocol (the same mechanism as `jcmd <pid> JVMTI.agent_load`). The injector
creates `.attach_pid<pid>` and sends `SIGQUIT` to the JVM, connects to its attach socket (`/tmp/.java_pid<pid>`, under
`$TMPDIR` on macOS) and asks it to load the library. The JVM `dlopen`s `libprestige_native` and calls its
`Agent_OnAttach` entry point. No ptrace, no root, no per-architecture shellcode.
Processes are found via `/proc` (Linux) or `ps` (macOS); there are no window titles, so the picker shows the pid and
instance name.
2. **The native library** only spawns a worker thread from its entry point (`DllMain` on Windows, `Agent_OnAttach` on
unix, which also hands over the `JavaVM*`). The worker:
- waits for the JVM (Windows: `jvm.dll`, `JNI_GetCreatedJavaVMs`), attaches, acquires the JVMTI redefine/retransform capabilities;
- writes the embedded payload jar (the remapped `prestige-*.jar`) to the temp directory under `PrestigeLoader`;
- `FabricLauncherBase.getLauncher().addToClassPath(jar)` so payload and game classes share the Knot class loader;
- registers `NativeBridge.classBytes` / `NativeBridge.redefine` and calls `Bootstrap.start()`.
3. **`Bootstrap`** (Java) waits for the client window, then for every ClassTransform hook target gets the current class bytes
@@ -19,14 +38,59 @@ Fabric API installed). It uses the same overall method as the openvape bundle, w
helper classes, call sites become `invokestatic`, and private member access goes through `Access` MethodHandles.
Finally the Prestige and ClickGUI entrypoints run on the client thread.
Log: `prestige-native.log` beside the DLL, plus `[Prestige-Inject]` lines in the game log.
## Build
The payload jar is embedded with `include_bytes!`, so each target needs its own build. Both scripts below build for the host OS only and run
`gradlew remapJar`, `cargo build --release`, and copy the results to `dist/`.
```powershell
.\build-injector.ps1 # gradlew remapJar, cargo build --release, copies to dist\
.\build-injector.ps1 # Windows: dist\PrestigeInjector.exe + dist\prestige_native.dll
```
```sh
./build-injector.sh # Linux/macOS: dist/PrestigeInjector + dist/libprestige_native.{so,dylib}
```
To build release zips for every platform from Windows, run `build-release.bat`. It cross-compiles Linux (x86_64,
glibc 2.17+, so one build covers every mainstream distro) and macOS (universal x86_64 + arm64) with `cargo-zigbuild`, installing
zig and cargo-zigbuild on first run, and writes `dist\release\prestige-<ver>-{windows-x86_64,linux-x86_64,macos-universal}.zip`,
the bare mod jar and `SHA256SUMS.txt`.
Set `JAVA_HOME` beforehand to choose the JDK for Gradle (`build-injector.sh` honours it). The GitHub Actions workflow
`.github/workflows/injector.yml` builds and tests all four targets (Windows x86_64, Linux x86_64, macOS x86_64, macOS arm64)
and uploads them as artifacts.
## Use
Start a Fabric 1.21.11 instance, then run `dist\PrestigeInjector.exe` (or `PrestigeInjector.exe <pid> [dll]`).
Start a Fabric 1.21.11 instance, then run:
```sh
PrestigeInjector # interactive: pick the game from the list
PrestigeInjector <pid> [lib path] # inject into a specific pid, optionally with an explicit library path
```
On Windows this is `PrestigeInjector.exe`. By default the library is looked up beside the injector.
## Install as a mod instead
The payload jar is also a normal Fabric mod. Put `prestige-*.jar` (from `dist/` or the CI `payload-jar` artifact) in the
`mods/` folder of a Fabric Loader 0.19+ / Fabric API / Minecraft 1.21.11 instance; no injector is needed, on any OS.
Do not use both: if the mod is already loaded, the injector's bootstrap logs "already loaded as a Fabric mod" and skips.
## Requirements (Linux / macOS)
- Run the injector as the **same user** as the game.
- The attach mechanism must be enabled: the game must not be started with `-XX:+DisableAttachMechanism`.
- The JVM architecture must match the injector build. On Apple Silicon use an arm64 JVM, or use the x86_64 build for JVMs
running under Rosetta.
- Sandboxed launchers (Flatpak, Snap) hide the attach socket and are not supported.
## Known limits
- On Linux/macOS the library stays loaded after self-destruct: hooks are removed and natives unregistered, but the library is
not `dlclose`d (unmapping code the thread is still returning into would crash the JVM). On Windows the library is freed.
- Linux and macOS support is compile-checked in CI; test on a real host before relying on it.
## Log
`prestige-native.log` is written beside the loader library, plus `[Prestige-Inject]` lines in the game log.
+58 -72
View File
@@ -1,10 +1,11 @@
//! PrestigeInjector: lists the running Minecraft (javaw/java) processes and loads prestige_native.dll into the selected one
//! with the classic remote LoadLibraryW thread. The DLL does everything else once it is inside the JVM; the injector tails
//! the DLL's log so every stage (transforming, injecting, initialising) is visible.
//! PrestigeInjector: lists the running Minecraft (javaw/java) processes and loads the native library
//! (prestige_native.dll / libprestige_native.so / libprestige_native.dylib) into the selected one: a remote LoadLibraryW
//! thread on Windows, the HotSpot attach protocol on Linux and macOS. The library does everything else once it is inside
//! the JVM; the injector tails its log so every stage (transforming, injecting, initialising) is visible.
//!
//! PrestigeInjector.exe interactive process picker (Up/Down, Enter, Esc)
//! PrestigeInjector.exe <pid> [dll path] non-interactive
mod win;
//! PrestigeInjector interactive process picker (Up/Down, Enter, Esc)
//! PrestigeInjector <pid> [lib path] non-interactive
mod platform;
use std::fs;
use std::io::{self, Read, Seek, SeekFrom, Write};
@@ -12,9 +13,9 @@ use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::{Duration, Instant};
const DLL_NAME: &str = "prestige_native.dll";
const WATERMARK: &str = "Cracked by Skid Solutions";
const INVITE_URL: &str = "https://discord.gg/M56rd2wwC5";
/// The one credit line, shown once at the top of the screen.
const CREDIT: &str = "Cracked by Skid Solutions - https://discord.gg/x2FJyGrDNg";
const INVITE_URL: &str = "https://discord.gg/x2FJyGrDNg";
/// How long to follow the DLL's progress after it has been loaded.
const FOLLOW_TIMEOUT: Duration = Duration::from_secs(180);
@@ -28,16 +29,18 @@ const RED: &str = "\x1b[91m";
fn default_dll() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join(DLL_NAME)))
.unwrap_or_else(|| PathBuf::from(DLL_NAME))
.and_then(|exe| exe.parent().map(|dir| dir.join(platform::LIB_NAME)))
.unwrap_or_else(|| PathBuf::from(platform::LIB_NAME))
}
fn banner() {
println!("{CYAN} ___ ___ ___ ___ _____ ___ ___ ___ ");
println!(" | _ \\ _ \\ __/ __|_ _|_ _/ __| __|");
println!(" | _/ / _|\\__ \\ | | | | (_ | _| ");
println!(" |_| |_|_\\___|___/ |_| |___\\___|___|{RESET} injector");
println!("{YELLOW} {WATERMARK}{RESET}\n");
fn banner() -> String {
let mut out = String::new();
out.push_str(&format!("{CYAN} ___ ___ ___ ___ _____ ___ ___ ___ \n"));
out.push_str(" | _ \\ _ \\ __/ __|_ _|_ _/ __| __|\n");
out.push_str(" | _/ / _|\\__ \\ | | | | (_ | _| \n");
out.push_str(&format!(" |_| |_|_\\___|___/ |_| |___\\___|___|{RESET} injector\n"));
out.push_str(&format!("{YELLOW} {CREDIT}{RESET}\n\n"));
out
}
fn step(label: &str, message: &str) {
@@ -58,21 +61,26 @@ fn clock() -> String {
}
fn main() -> ExitCode {
win::enable_ansi();
let code = real_main();
platform::restore_terminal();
code
}
fn real_main() -> ExitCode {
platform::enable_ansi();
let _ = clock();
let args: Vec<String> = std::env::args().skip(1).collect();
let interactive = args.is_empty();
let (pid, dll): (Option<u32>, PathBuf) = match args.as_slice() {
[] => {
banner();
invite_prompt();
open_invite_once();
(pick_process(), default_dll())
}
[pid] => (parse_pid(pid), default_dll()),
[pid, dll] => (parse_pid(pid), PathBuf::from(dll)),
_ => {
eprintln!("usage: PrestigeInjector.exe [<pid> [dll path]]");
eprintln!("usage: PrestigeInjector [<pid> [lib path]]");
return ExitCode::from(2);
}
};
@@ -81,7 +89,7 @@ fn main() -> ExitCode {
return if interactive { ExitCode::SUCCESS } else { ExitCode::from(2) };
};
if !interactive {
banner();
print!("{}", banner());
}
let code = run(pid, &dll);
@@ -109,16 +117,18 @@ fn run(pid: u32, dll: &Path) -> ExitCode {
}
};
let dll_display = dll.display().to_string();
step("loader", &dll_display.trim_start_matches(r"\\?\").to_string());
#[cfg(windows)]
let dll_display = dll_display.trim_start_matches(r"\\?\").to_string();
step("loader", &dll_display);
// The DLL appends to prestige-native.log beside itself; remember where it ends so only this run's lines are shown.
// The library appends to prestige-native.log beside itself; remember where it ends so only this run's lines are shown.
let log_path = dll.with_file_name("prestige-native.log");
let log_start = fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
step("checking", "64-bit JVM, process access, DLL not already loaded");
step("injecting", "remote thread LoadLibraryW");
match win::inject(pid, &dll) {
Ok(()) => ok("injected", "prestige_native.dll is loaded in the game process"),
step("checking", "64-bit JVM, process access, library not already loaded");
step("injecting", "loading the native library into the JVM");
match platform::inject(pid, &dll) {
Ok(()) => ok("injected", &format!("{} is loaded in the game process", platform::LIB_NAME)),
Err(e) => {
fail("failed", &e);
return ExitCode::from(1);
@@ -130,7 +140,6 @@ fn run(pid: u32, dll: &Path) -> ExitCode {
Outcome::Done => {
println!();
ok("success", "successfully injected - Prestige is running in the game");
println!("{YELLOW} {WATERMARK}{RESET} {DIM}{INVITE_URL}{RESET}");
ExitCode::SUCCESS
}
Outcome::Failed(line) => {
@@ -173,7 +182,9 @@ fn follow_log(path: &Path, start: u64) -> Outcome {
// lines look like "[unix seconds] message"
let message = line.split_once("] ").map(|(_, m)| m).unwrap_or(line);
let lower = message.to_ascii_lowercase();
if lower.contains("failed") || lower.contains("not found") {
// Per-hook failures ("[java] hook failed for ...") are not fatal: Bootstrap keeps the hooks that applied.
let fatal = message.starts_with("FAILED:") || lower.contains("start failed") || lower.contains("client init failed") || lower.contains("could not hook");
if fatal {
fail("loader", message);
return Outcome::Failed(message.to_string());
}
@@ -189,51 +200,26 @@ fn follow_log(path: &Path, start: u64) -> Outcome {
}
fn config_path() -> Option<PathBuf> {
std::env::var_os("APPDATA").map(|dir| PathBuf::from(dir).join("PrestigeInjector").join("settings.txt"))
platform::config_dir().map(|dir| dir.join("PrestigeInjector").join("settings.txt"))
}
/// First launch: open the Discord invite, then ask whether to open it again on later launches.
fn invite_prompt() {
/// Opens the Discord invite on the very first launch only (no prompt); afterwards only the credit line shows it.
fn open_invite_once() {
let Some(path) = config_path() else { return };
let saved = fs::read_to_string(&path).ok();
let show = match saved.as_deref() {
None => true, // first launch
Some(text) => !text.lines().any(|l| l.trim() == "show_invite=false"),
};
if !show {
if path.exists() {
return;
}
let first = saved.is_none();
step("discord", &format!("opening {INVITE_URL}"));
if !win::open_url(INVITE_URL) {
fail("discord", &format!("could not open a browser, visit {INVITE_URL} manually"));
}
let again = ask_yes_no(if first {
"Show the Discord invite again next time you start the injector? [Y/n] "
} else {
"Keep showing the Discord invite on launch? [Y/n] "
});
let _ = platform::open_url(INVITE_URL);
if let Some(dir) = path.parent() {
let _ = fs::create_dir_all(dir);
}
let _ = fs::write(&path, format!("show_invite={again}\n"));
println!();
}
fn ask_yes_no(question: &str) -> bool {
print!("{question}");
let _ = io::stdout().flush();
let mut line = String::new();
if io::stdin().read_line(&mut line).is_err() {
return true;
}
!matches!(line.trim().to_ascii_lowercase().as_str(), "n" | "no")
let _ = fs::write(&path, "invite_opened=true\n");
}
/// Interactive list of Minecraft JVM windows, refreshed every 750 ms. Returns the chosen pid.
fn pick_process() -> Option<u32> {
let mut selected = 0usize;
let mut processes = win::java_windows();
let mut processes = platform::java_processes();
let mut last_refresh = Instant::now();
loop {
if selected >= processes.len() {
@@ -242,34 +228,34 @@ fn pick_process() -> Option<u32> {
draw(&processes, selected);
let deadline = Instant::now() + Duration::from_millis(50);
while Instant::now() < deadline {
if let Some(key) = win::poll_key() {
if let Some(key) = platform::poll_key() {
match key {
win::Key::Up => selected = selected.saturating_sub(1),
win::Key::Down => selected = (selected + 1).min(processes.len().saturating_sub(1)),
win::Key::Enter => {
platform::Key::Up => selected = selected.saturating_sub(1),
platform::Key::Down => selected = (selected + 1).min(processes.len().saturating_sub(1)),
platform::Key::Enter => {
if let Some(p) = processes.get(selected) {
return Some(p.pid);
}
}
win::Key::Escape => return None,
platform::Key::Escape => return None,
}
draw(&processes, selected);
}
std::thread::sleep(Duration::from_millis(10));
}
if last_refresh.elapsed() >= Duration::from_millis(750) {
processes = win::java_windows();
processes = platform::java_processes();
last_refresh = Instant::now();
}
}
}
fn draw(processes: &[win::JavaWindow], selected: usize) {
fn draw(processes: &[platform::JavaProcess], selected: usize) {
let mut out = String::from("\x1b[2J\x1b[H");
out.push_str(&format!("{CYAN}Prestige injector{RESET} - Minecraft 1.21.11 (Fabric, 64-bit) {YELLOW}{WATERMARK}{RESET}\n"));
out.push_str("Up/Down select, Enter inject, Esc quit\n\n");
out.push_str(&banner());
out.push_str("Minecraft 1.21.11 (Fabric, 64-bit) - Up/Down select, Enter inject, Esc quit\n\n");
if processes.is_empty() {
out.push_str(" waiting for a javaw.exe / java.exe window...\n");
out.push_str(" waiting for a Minecraft JVM (java/javaw)...\n");
}
for (i, p) in processes.iter().enumerate() {
let marker = if i == selected { ">" } else { " " };
@@ -0,0 +1,61 @@
//! Platform backends of the injector. Each backend exposes the same functions:
//! enable_ansi, poll_key, java_processes, inject, open_url, restore_terminal.
use std::path::PathBuf;
/// A running Minecraft JVM shown in the picker.
pub struct JavaProcess {
pub pid: u32,
pub title: String,
pub exe: String,
}
pub enum Key {
Up,
Down,
Enter,
Escape,
}
#[cfg(windows)]
mod win;
#[cfg(windows)]
pub use win::*;
#[cfg(unix)]
mod unix_attach;
#[cfg(unix)]
mod unix_procs;
#[cfg(unix)]
mod unix_term;
#[cfg(unix)]
pub use unix_attach::inject;
#[cfg(unix)]
pub use unix_procs::java_processes;
#[cfg(unix)]
pub use unix_term::{enable_ansi, open_url, poll_key, restore_terminal};
#[cfg(windows)]
pub const LIB_NAME: &str = "prestige_native.dll";
#[cfg(target_os = "macos")]
pub const LIB_NAME: &str = "libprestige_native.dylib";
#[cfg(all(unix, not(target_os = "macos")))]
pub const LIB_NAME: &str = "libprestige_native.so";
/// Per-user configuration directory: APPDATA, $XDG_CONFIG_HOME or ~/.config, ~/Library/Application Support.
pub fn config_dir() -> Option<PathBuf> {
#[cfg(windows)]
{
std::env::var_os("APPDATA").map(PathBuf::from)
}
#[cfg(target_os = "macos")]
{
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library").join("Application Support"))
}
#[cfg(all(unix, not(target_os = "macos")))]
{
match std::env::var_os("XDG_CONFIG_HOME") {
Some(dir) if !dir.is_empty() => Some(PathBuf::from(dir)),
_ => std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")),
}
}
}
@@ -0,0 +1,405 @@
//! HotSpot attach-protocol injection for Linux and macOS (what `jcmd <pid> JVMTI.agent_load` does).
//!
//! 1. Create `.attach_pid<pid>` where the target JVM looks for it and send SIGQUIT.
//! 2. Wait for the UNIX socket `.java_pid<pid>`.
//! 3. Send `1\0load\0<abs lib>\0true\0\0` and read the status reply.
//!
//! The JVM then dlopens the library and calls `Agent_OnAttach`.
#![allow(dead_code)]
use std::fs;
use std::io::{Read, Write};
use std::os::unix::fs::MetadataExt;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
fn geteuid() -> u32;
}
const SIGQUIT: i32 = 3;
const SOCKET_WAIT: Duration = Duration::from_secs(10);
const POLL_INTERVAL: Duration = Duration::from_millis(50);
const REPLY_TIMEOUT: Duration = Duration::from_secs(30);
const IS_MACOS: bool = cfg!(target_os = "macos");
pub fn inject(pid: u32, lib: &Path) -> Result<(), String> {
let lib = fs::canonicalize(lib)
.map_err(|e| format!("library '{}' not found: {e}", lib.display()))?;
let lib_str = lib
.to_str()
.ok_or_else(|| "library path is not valid UTF-8".to_string())?;
preflight_owner(pid)?;
if let Some(msg) = arch_mismatch(pid) {
return Err(msg);
}
let sockets = socket_candidates(pid);
let existing = sockets.iter().find(|p| is_socket(p)).cloned();
let sock_path = match existing {
Some(p) => p,
None => trigger_attach(pid, &sockets)?,
};
send_load(&sock_path, lib_str)
}
// ---------------------------------------------------------------- preflight
fn current_uid() -> u32 {
unsafe { geteuid() }
}
fn preflight_owner(pid: u32) -> Result<(), String> {
let uid = if IS_MACOS {
let out = Command::new("ps")
.args(["-o", "uid=", "-p", &pid.to_string()])
.output()
.map_err(|e| format!("failed to run ps: {e}"))?;
let text = String::from_utf8_lossy(&out.stdout);
let text = text.trim();
if text.is_empty() {
return Err(format!("process {pid} does not exist"));
}
text.parse::<u32>()
.map_err(|_| format!("could not parse owner of process {pid}: '{text}'"))?
} else {
match fs::metadata(format!("/proc/{pid}")) {
Ok(m) => m.uid(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(format!("process {pid} does not exist"))
}
Err(e) => return Err(format!("cannot inspect process {pid}: {e}")),
}
};
let me = current_uid();
if uid != me {
return Err(format!(
"process {pid} belongs to uid {uid} but the injector runs as uid {me}; \
the attach protocol only works for the same user (run the injector as the user that started the game)"
));
}
Ok(())
}
fn host_arch() -> &'static str {
match std::env::consts::ARCH {
"aarch64" | "arm64" => "arm64",
other => other,
}
}
/// Some(message) only on a definite mismatch; None when matching or undeterminable.
fn arch_mismatch(pid: u32) -> Option<String> {
let target = target_arch(pid)?;
if target == host_arch() {
return None;
}
let hint = if IS_MACOS && target == "x86_64" && host_arch() == "arm64" {
" (Rosetta?)"
} else {
""
};
Some(format!(
"target JVM is {target}{hint} but this injector is {}; use a matching JVM or a matching injector build",
host_arch()
))
}
fn target_arch(pid: u32) -> Option<String> {
if IS_MACOS {
let out = Command::new("ps")
.args(["-o", "comm=", "-p", &pid.to_string()])
.output()
.ok()?;
let comm = String::from_utf8_lossy(&out.stdout).trim().to_string();
if comm.is_empty() {
return None;
}
let head = read_head(Path::new(&comm), 64)?;
macho_arch(&head).map(str::to_string)
} else {
// Prefer the libjvm mapped into the process, fall back to the executable.
let mut candidates: Vec<PathBuf> = Vec::new();
if let Ok(maps) = fs::read_to_string(format!("/proc/{pid}/maps")) {
for line in maps.lines() {
if line.contains("libjvm.so") {
if let Some(path) = line.split_whitespace().nth(5) {
candidates.push(PathBuf::from(path));
break;
}
}
}
}
candidates.push(PathBuf::from(format!("/proc/{pid}/exe")));
for c in candidates {
if let Some(head) = read_head(&c, 64) {
if let Some(a) = elf_arch(&head) {
return Some(a.to_string());
}
}
}
None
}
}
fn read_head(path: &Path, n: usize) -> Option<Vec<u8>> {
let mut f = fs::File::open(path).ok()?;
let mut buf = vec![0u8; n];
let mut got = 0;
while got < n {
match f.read(&mut buf[got..]) {
Ok(0) => break,
Ok(k) => got += k,
Err(_) => return None,
}
}
buf.truncate(got);
Some(buf)
}
/// Architecture name ("x86_64", "arm64", ...) from an ELF header, None if not ELF/unknown.
fn elf_arch(h: &[u8]) -> Option<&'static str> {
if h.len() < 20 || &h[0..4] != b"\x7fELF" {
return None;
}
let machine = match h[5] {
1 => u16::from_le_bytes([h[18], h[19]]),
2 => u16::from_be_bytes([h[18], h[19]]),
_ => return None,
};
Some(match machine {
3 => "x86",
40 => "arm",
62 => "x86_64",
183 => "arm64",
243 => "riscv",
21 => "ppc64",
_ => return None,
})
}
/// Architecture from a thin 64-bit Mach-O header. Fat/universal binaries return None
/// (the running slice cannot be determined from the file alone).
fn macho_arch(h: &[u8]) -> Option<&'static str> {
if h.len() < 8 {
return None;
}
let magic = u32::from_le_bytes([h[0], h[1], h[2], h[3]]);
let cputype = match magic {
0xfeed_facf => u32::from_le_bytes([h[4], h[5], h[6], h[7]]),
0xcffa_edfe => u32::from_be_bytes([h[4], h[5], h[6], h[7]]),
_ => return None,
};
match cputype {
0x0100_0007 => Some("x86_64"),
0x0100_000c => Some("arm64"),
_ => None,
}
}
// ------------------------------------------------------------ attach trigger
fn socket_candidates(pid: u32) -> Vec<PathBuf> {
let name = format!(".java_pid{pid}");
let mut v = Vec::new();
if IS_MACOS {
if let Some(t) = std::env::var_os("TMPDIR") {
v.push(Path::new(&t).join(&name));
}
}
let tmp = Path::new("/tmp").join(&name);
if !v.contains(&tmp) {
v.push(tmp);
}
v
}
fn is_socket(p: &Path) -> bool {
use std::os::unix::fs::FileTypeExt;
fs::symlink_metadata(p)
.map(|m| m.file_type().is_socket())
.unwrap_or(false)
}
/// Removes the trigger files when dropped, on every path.
struct TriggerFiles(Vec<PathBuf>);
impl Drop for TriggerFiles {
fn drop(&mut self) {
for p in &self.0 {
let _ = fs::remove_file(p);
}
}
}
fn trigger_candidates(pid: u32) -> Vec<PathBuf> {
let name = format!(".attach_pid{pid}");
let mut v = Vec::new();
if !IS_MACOS {
if let Ok(cwd) = fs::read_link(format!("/proc/{pid}/cwd")) {
v.push(cwd.join(&name));
}
}
v.push(Path::new("/tmp").join(&name));
if IS_MACOS {
if let Some(t) = std::env::var_os("TMPDIR") {
let p = Path::new(&t).join(&name);
if !v.contains(&p) {
v.push(p);
}
}
}
v
}
fn trigger_attach(pid: u32, sockets: &[PathBuf]) -> Result<PathBuf, String> {
let mut created = Vec::new();
for p in trigger_candidates(pid) {
if fs::File::create(&p).is_ok() {
created.push(p);
}
}
if created.is_empty() {
return Err(format!(
"could not create .attach_pid{pid} in the target's working directory or /tmp"
));
}
let _guard = TriggerFiles(created);
if unsafe { kill(pid as i32, SIGQUIT) } != 0 {
return Err(format!(
"failed to send SIGQUIT to {pid}: {}",
std::io::Error::last_os_error()
));
}
let start = Instant::now();
while start.elapsed() < SOCKET_WAIT {
if let Some(p) = sockets.iter().find(|p| is_socket(p)) {
return Ok(p.clone());
}
std::thread::sleep(POLL_INTERVAL);
}
if let Some(msg) = arch_mismatch(pid) {
return Err(format!("attach socket never appeared; {msg}"));
}
Err(format!(
"the JVM did not create its attach socket within {}s. Possible causes: the JVM was started with \
-XX:+DisableAttachMechanism or -Xrs, the game runs in a sandbox (Flatpak/Snap) with a private /tmp, \
or it belongs to a different user",
SOCKET_WAIT.as_secs()
))
}
// ------------------------------------------------------------------ protocol
/// `1\0load\0<lib>\0true\0\0`: version, command, then three NUL-terminated args
/// (library path, "true" = absolute path, empty options).
fn build_request(lib: &str) -> Vec<u8> {
let mut v = Vec::with_capacity(lib.len() + 16);
v.extend_from_slice(b"1\0load\0");
v.extend_from_slice(lib.as_bytes());
v.extend_from_slice(b"\0true\0\0");
v
}
/// First line is the integer status (0 = success); the rest is message text.
fn parse_response(data: &[u8]) -> Result<(), String> {
let text = String::from_utf8_lossy(data);
let (first, rest) = match text.split_once('\n') {
Some((a, b)) => (a.trim(), b.trim()),
None => (text.trim(), ""),
};
if first.is_empty() {
return Err("JVM closed the attach connection without a reply".into());
}
match first.parse::<i32>() {
Ok(0) => Ok(()),
Ok(code) => {
if rest.is_empty() {
Err(format!("JVM rejected the load request (status {code})"))
} else {
Err(format!("JVM rejected the load request (status {code}): {rest}"))
}
}
Err(_) => Err(format!("unexpected reply from JVM: {}", text.trim())),
}
}
fn send_load(sock: &Path, lib: &str) -> Result<(), String> {
let mut s = UnixStream::connect(sock).map_err(|e| {
format!(
"could not connect to attach socket {}: {e} (stale socket, or the JVM runs as another user)",
sock.display()
)
})?;
let _ = s.set_read_timeout(Some(REPLY_TIMEOUT));
let _ = s.set_write_timeout(Some(REPLY_TIMEOUT));
s.write_all(&build_request(lib))
.map_err(|e| format!("failed to send attach request: {e}"))?;
let mut buf = Vec::new();
match s.read_to_end(&mut buf) {
Ok(_) => {}
// A partial reply is still parseable; a bare timeout with no data is not.
Err(e) if buf.is_empty() => return Err(format!("no reply from JVM: {e}")),
Err(_) => {}
}
parse_response(&buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_format() {
assert_eq!(
build_request("/opt/libprestige_native.so"),
b"1\0load\0/opt/libprestige_native.so\0true\0\0".to_vec()
);
}
#[test]
fn response_ok_and_errors() {
assert!(parse_response(b"0\n").is_ok());
assert!(parse_response(b"0").is_ok());
let e = parse_response(b"-1\nreturn code: 1\n").unwrap_err();
assert!(e.contains("-1") && e.contains("return code: 1"));
assert!(parse_response(b"").is_err());
assert!(parse_response(b"garbage").is_err());
}
#[test]
fn elf_headers() {
let mut h = vec![0u8; 64];
h[..4].copy_from_slice(b"\x7fELF");
h[5] = 1;
h[18..20].copy_from_slice(&62u16.to_le_bytes());
assert_eq!(elf_arch(&h), Some("x86_64"));
h[18..20].copy_from_slice(&183u16.to_le_bytes());
assert_eq!(elf_arch(&h), Some("arm64"));
assert_eq!(elf_arch(b"nope"), None);
}
#[test]
fn macho_headers() {
let mut h = vec![0xcf, 0xfa, 0xed, 0xfe];
h.extend_from_slice(&0x0100_000cu32.to_le_bytes());
assert_eq!(macho_arch(&h), Some("arm64"));
let mut h = vec![0xcf, 0xfa, 0xed, 0xfe];
h.extend_from_slice(&0x0100_0007u32.to_le_bytes());
assert_eq!(macho_arch(&h), Some("x86_64"));
// fat binary -> undeterminable
assert_eq!(macho_arch(&[0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 2]), None);
}
}
@@ -0,0 +1,158 @@
//! Lists running Minecraft (Fabric) JVMs on Linux and macOS. std only.
use super::JavaProcess;
/// Last path component, accepting both separators.
fn file_name(path: &str) -> &str {
path.trim_end_matches(['/', '\\']).rsplit(['/', '\\']).next().unwrap_or(path)
}
/// True when `argv0` names a java launcher and the arguments look like a Minecraft/Fabric JVM.
pub(crate) fn is_minecraft_jvm(args: &[String]) -> bool {
let Some(argv0) = args.first() else { return false };
if !file_name(argv0).to_ascii_lowercase().contains("java") {
return false;
}
args.iter().any(|a| {
let a = a.to_ascii_lowercase();
a.contains("net.fabricmc.loader") || a.contains("knotclient") || a.contains("minecraft")
})
}
/// Friendly instance label: value of --version, else the last component of --gameDir, else "Minecraft".
pub(crate) fn instance_label(args: &[String]) -> String {
fn value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
let prefix = format!("{flag}=");
let mut it = args.iter();
while let Some(a) = it.next() {
if a == flag {
return it.next().map(String::as_str).filter(|v| !v.is_empty());
}
if let Some(v) = a.strip_prefix(&prefix) {
if !v.is_empty() {
return Some(v);
}
}
}
None
}
if let Some(v) = value(args, "--version") {
return v.to_string();
}
if let Some(dir) = value(args, "--gameDir") {
let name = file_name(dir);
if !name.is_empty() {
return name.to_string();
}
}
"Minecraft".to_string()
}
fn build(pid: u32, args: &[String]) -> Option<JavaProcess> {
if !is_minecraft_jvm(args) {
return None;
}
Some(JavaProcess { pid, title: instance_label(args), exe: file_name(&args[0]).to_string() })
}
/// Parses one `ps -axo pid=,uid=,command=` line into (pid, uid, argv).
/// ps joins argv with spaces, so argv[0] is taken as everything before the first " -" option.
#[cfg_attr(target_os = "linux", allow(dead_code))]
pub(crate) fn parse_ps_line(line: &str) -> Option<(u32, u32, Vec<String>)> {
let line = line.trim_start();
let (pid, rest) = line.split_once(char::is_whitespace)?;
let rest = rest.trim_start();
let (uid, cmd) = rest.split_once(char::is_whitespace)?;
let cmd = cmd.trim();
let (argv0, tail) = match cmd.find(" -") {
Some(i) => (&cmd[..i], &cmd[i..]),
None => (cmd, ""),
};
let mut args = vec![argv0.to_string()];
args.extend(tail.split_whitespace().map(str::to_string));
Some((pid.parse().ok()?, uid.parse().ok()?, args))
}
#[cfg(target_os = "linux")]
pub fn java_processes() -> Vec<JavaProcess> {
use std::os::unix::fs::MetadataExt;
let own_pid = std::process::id();
let own_uid = std::fs::metadata("/proc/self").map(|m| m.uid()).ok();
let mut out = Vec::new();
let Ok(dir) = std::fs::read_dir("/proc") else { return out };
for entry in dir.flatten() {
let Some(pid) = entry.file_name().to_str().and_then(|n| n.parse::<u32>().ok()) else { continue };
if pid == own_pid {
continue;
}
if let (Some(me), Ok(meta)) = (own_uid, entry.metadata()) {
if meta.uid() != me {
continue;
}
}
let Ok(raw) = std::fs::read(entry.path().join("cmdline")) else { continue };
let args: Vec<String> = raw
.split(|&b| b == 0)
.filter(|s| !s.is_empty())
.map(|s| String::from_utf8_lossy(s).into_owned())
.collect();
if let Some(p) = build(pid, &args) {
out.push(p);
}
}
out.sort_by_key(|p| p.pid);
out
}
#[cfg(not(target_os = "linux"))]
pub fn java_processes() -> Vec<JavaProcess> {
let own_pid = std::process::id();
let Ok(output) = std::process::Command::new("ps").args(["-axo", "pid=,uid=,command="]).output() else {
return Vec::new();
};
let text = String::from_utf8_lossy(&output.stdout);
let rows: Vec<_> = text.lines().filter_map(parse_ps_line).collect();
let own_uid = rows.iter().find(|r| r.0 == own_pid).map(|r| r.1);
let mut out: Vec<JavaProcess> = rows
.iter()
.filter(|(pid, uid, _)| *pid != own_pid && own_uid.map_or(true, |u| u == *uid))
.filter_map(|(pid, _, args)| build(*pid, args))
.collect();
out.sort_by_key(|p| p.pid);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn v(s: &[&str]) -> Vec<String> {
s.iter().map(|x| x.to_string()).collect()
}
#[test]
fn filters_minecraft_jvms() {
assert!(is_minecraft_jvm(&v(&["/usr/lib/jvm/java-21/bin/java", "-cp", "x", "net.fabricmc.loader.impl.launch.knot.KnotClient"])));
assert!(is_minecraft_jvm(&v(&["java", "-Dminecraft.launcher.brand=x", "Main"])));
assert!(!is_minecraft_jvm(&v(&["/usr/bin/java", "-jar", "gradle.jar"])));
assert!(!is_minecraft_jvm(&v(&["/usr/bin/bash", "-c", "minecraft"])));
assert!(!is_minecraft_jvm(&[]));
}
#[test]
fn labels() {
assert_eq!(instance_label(&v(&["java", "--version", "fabric-1.21.11"])), "fabric-1.21.11");
assert_eq!(instance_label(&v(&["java", "--version=1.21"])), "1.21");
assert_eq!(instance_label(&v(&["java", "--gameDir", "/home/u/.minecraft/instances/Pack/"])), "Pack");
assert_eq!(instance_label(&v(&["java", "KnotClient"])), "Minecraft");
}
#[test]
fn ps_lines() {
let (pid, uid, args) = parse_ps_line(" 123 501 /Users/a/Library/Application Support/x/bin/java -Xmx2G --version 1.21 net.fabricmc.loader").unwrap();
assert_eq!((pid, uid), (123, 501));
assert_eq!(args[0], "/Users/a/Library/Application Support/x/bin/java");
assert!(is_minecraft_jvm(&args));
assert_eq!(instance_label(&args), "1.21");
assert!(parse_ps_line("garbage").is_none());
}
}
@@ -0,0 +1,152 @@
//! Terminal input and URL opening on Linux and macOS. std only (no termios structs).
//!
//! Raw mode is entered lazily on the first `poll_key` call via `stty -icanon -echo min 1 time 0`
//! on /dev/tty (ISIG stays on, so Ctrl-C still raises SIGINT). A reader thread blocks on the tty
//! and forwards bytes over a channel; `poll_key` parses them. The original settings (`stty -g`)
//! are restored by `restore_terminal()` and, for Ctrl-C, by a SIGINT handler that only writes to
//! a self-pipe (async-signal-safe); a helper thread then restores the terminal and exits 130.
use super::Key;
use std::fs::File;
use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::mpsc::{channel, Receiver, RecvTimeoutError};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
extern "C" {
fn signal(signum: i32, handler: usize) -> usize;
fn pipe(fds: *mut i32) -> i32;
fn write(fd: i32, buf: *const u8, n: usize) -> isize;
fn read(fd: i32, buf: *mut u8, n: usize) -> isize;
}
const SIGINT: i32 = 2;
static SAVED: Mutex<Option<String>> = Mutex::new(None);
static INPUT: OnceLock<Mutex<Option<Receiver<u8>>>> = OnceLock::new();
static SIG_WRITE_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
fn input() -> &'static Mutex<Option<Receiver<u8>>> {
INPUT.get_or_init(|| Mutex::new(None))
}
fn tty_stdin() -> Stdio {
match File::open("/dev/tty") {
Ok(f) => Stdio::from(f),
Err(_) => Stdio::inherit(),
}
}
fn stty(args: &[&str]) -> Option<String> {
let out = Command::new("stty").args(args).stdin(tty_stdin()).stderr(Stdio::null()).output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
}
/// Nothing to do on unix (terminals understand ANSI); raw mode is entered lazily by `poll_key`.
pub fn enable_ansi() {}
extern "C" fn on_sigint(_sig: i32) {
let fd = SIG_WRITE_FD.load(std::sync::atomic::Ordering::Relaxed);
if fd >= 0 {
let b = 1u8;
unsafe { write(fd, &b, 1) };
}
}
fn install_sigint() {
let mut fds = [0i32; 2];
if unsafe { pipe(fds.as_mut_ptr()) } != 0 {
return;
}
SIG_WRITE_FD.store(fds[1], std::sync::atomic::Ordering::Relaxed);
let rfd = fds[0];
std::thread::spawn(move || {
let mut b = 0u8;
loop {
let n = unsafe { read(rfd, &mut b, 1) };
if n == 1 {
restore_terminal();
println!();
std::process::exit(130);
}
if n == 0 {
return;
}
}
});
unsafe { signal(SIGINT, on_sigint as extern "C" fn(i32) as usize) };
}
fn enter_raw() -> Option<Receiver<u8>> {
let saved = stty(&["-g"])?;
*SAVED.lock().ok()? = Some(saved);
stty(&["-icanon", "-echo", "min", "1", "time", "0"])?;
let mut tty: Box<dyn Read + Send> = match File::open("/dev/tty") {
Ok(f) => Box::new(f),
Err(_) => Box::new(std::io::stdin()),
};
let (tx, rx) = channel();
std::thread::spawn(move || {
let mut b = [0u8; 1];
while let Ok(1) = tty.read(&mut b) {
if tx.send(b[0]).is_err() {
break;
}
}
});
install_sigint();
Some(rx)
}
/// Returns a pending key press, if any, without blocking (except ~30ms to disambiguate a lone Esc).
pub fn poll_key() -> Option<Key> {
let mut guard = input().lock().ok()?;
if guard.is_none() {
*guard = Some(enter_raw()?);
}
let rx = guard.as_ref()?;
loop {
let b = rx.try_recv().ok()?;
match b {
b'\n' | b'\r' => return Some(Key::Enter),
b'k' => return Some(Key::Up),
b'j' => return Some(Key::Down),
27 => {
let ms30 = Duration::from_millis(30);
match rx.recv_timeout(ms30) {
Err(RecvTimeoutError::Timeout) | Err(RecvTimeoutError::Disconnected) => return Some(Key::Escape),
Ok(b'[') | Ok(b'O') => match rx.recv_timeout(ms30) {
Ok(b'A') => return Some(Key::Up),
Ok(b'B') => return Some(Key::Down),
_ => {}
},
Ok(_) => {}
}
}
_ => {}
}
}
}
/// Restores the terminal settings saved when raw mode was entered. Safe to call repeatedly.
pub fn restore_terminal() {
let saved = SAVED.lock().ok().and_then(|mut s| s.take());
if let Some(s) = saved {
stty(&[&s]);
}
}
/// Opens a URL in the default browser (xdg-open / open). Returns whether the spawn succeeded.
pub fn open_url(url: &str) -> bool {
#[cfg(target_os = "macos")]
let opener = "open";
#[cfg(not(target_os = "macos"))]
let opener = "xdg-open";
Command::new(opener)
.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| true)
.unwrap_or(false)
}
@@ -1,278 +1,270 @@
//! Win32 plumbing of the injector (raw bindings, no external crates).
use std::ffi::c_void;
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
use std::time::Duration;
type Handle = *mut c_void;
const PROCESS_CREATE_THREAD: u32 = 0x0002;
const PROCESS_VM_OPERATION: u32 = 0x0008;
const PROCESS_VM_WRITE: u32 = 0x0020;
const PROCESS_VM_READ: u32 = 0x0010;
const PROCESS_QUERY_INFORMATION: u32 = 0x0400;
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
const MEM_COMMIT: u32 = 0x1000;
const MEM_RESERVE: u32 = 0x2000;
const MEM_RELEASE: u32 = 0x8000;
const PAGE_READWRITE: u32 = 0x04;
const TH32CS_SNAPMODULE: u32 = 0x8;
const TH32CS_SNAPMODULE32: u32 = 0x10;
const WAIT_OBJECT_0: u32 = 0;
#[repr(C)]
struct ModuleEntry32W {
size: u32,
module_id: u32,
process_id: u32,
glblcnt_usage: u32,
proccnt_usage: u32,
base_addr: *mut u8,
base_size: u32,
module: Handle,
sz_module: [u16; 256],
sz_exe_path: [u16; 260],
}
extern "system" {
fn OpenProcess(access: u32, inherit: i32, pid: u32) -> Handle;
fn CloseHandle(h: Handle) -> i32;
fn VirtualAllocEx(process: Handle, address: *mut c_void, size: usize, alloc_type: u32, protect: u32) -> *mut c_void;
fn VirtualFreeEx(process: Handle, address: *mut c_void, size: usize, free_type: u32) -> i32;
fn WriteProcessMemory(process: Handle, address: *mut c_void, buf: *const c_void, size: usize, written: *mut usize) -> i32;
fn CreateRemoteThread(process: Handle, attrs: *mut c_void, stack: usize, start: *mut c_void, param: *mut c_void, flags: u32, id: *mut u32) -> Handle;
fn WaitForSingleObject(h: Handle, ms: u32) -> u32;
fn GetExitCodeThread(h: Handle, code: *mut u32) -> i32;
fn GetModuleHandleW(name: *const u16) -> Handle;
fn GetProcAddress(module: Handle, name: *const u8) -> *mut c_void;
fn IsWow64Process(process: Handle, wow64: *mut i32) -> i32;
fn QueryFullProcessImageNameW(process: Handle, flags: u32, buf: *mut u16, size: *mut u32) -> i32;
fn GetLastError() -> u32;
fn CreateToolhelp32Snapshot(flags: u32, pid: u32) -> Handle;
fn Module32FirstW(snapshot: Handle, entry: *mut ModuleEntry32W) -> i32;
fn Module32NextW(snapshot: Handle, entry: *mut ModuleEntry32W) -> i32;
fn GetStdHandle(id: u32) -> Handle;
fn GetConsoleMode(h: Handle, mode: *mut u32) -> i32;
fn SetConsoleMode(h: Handle, mode: u32) -> i32;
}
#[link(name = "user32")]
extern "system" {
fn EnumWindows(callback: unsafe extern "system" fn(Handle, isize) -> i32, param: isize) -> i32;
fn IsWindowVisible(hwnd: Handle) -> i32;
fn GetWindowTextW(hwnd: Handle, buf: *mut u16, max: i32) -> i32;
fn GetWindowThreadProcessId(hwnd: Handle, pid: *mut u32) -> u32;
}
#[link(name = "shell32")]
extern "system" {
fn ShellExecuteW(hwnd: Handle, verb: *const u16, file: *const u16, params: *const u16, dir: *const u16, show: i32) -> isize;
}
extern "C" {
fn _kbhit() -> i32;
fn _getch() -> i32;
}
pub struct JavaWindow {
pub pid: u32,
pub title: String,
pub exe: String,
}
pub enum Key {
Up,
Down,
Enter,
Escape,
}
pub fn enable_ansi() {
unsafe {
let out = GetStdHandle(-11i32 as u32);
let mut mode = 0;
if GetConsoleMode(out, &mut mode) != 0 {
SetConsoleMode(out, mode | 0x0004);
}
}
}
/// Non-blocking key read: arrows arrive as 0xE0/0x00 followed by 72 (up) / 80 (down).
pub fn poll_key() -> Option<Key> {
unsafe {
if _kbhit() == 0 {
return None;
}
match _getch() {
13 => Some(Key::Enter),
27 => Some(Key::Escape),
0 | 0xE0 => match _getch() {
72 => Some(Key::Up),
80 => Some(Key::Down),
_ => None,
},
_ => None,
}
}
}
fn wide(path: &Path) -> Vec<u16> {
path.as_os_str().encode_wide().chain(std::iter::once(0)).collect()
}
fn image_name(pid: u32) -> Option<String> {
unsafe {
let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if process.is_null() {
return None;
}
let mut buf = [0u16; 1024];
let mut len = buf.len() as u32;
let ok = QueryFullProcessImageNameW(process, 0, buf.as_mut_ptr(), &mut len);
CloseHandle(process);
if ok == 0 {
return None;
}
Some(String::from_utf16_lossy(&buf[..len as usize]))
}
}
unsafe extern "system" fn collect(hwnd: Handle, param: isize) -> i32 {
let out = &mut *(param as *mut Vec<(u32, String)>);
if IsWindowVisible(hwnd) == 0 {
return 1;
}
let mut buf = [0u16; 256];
let len = GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32);
if len <= 0 {
return 1;
}
let mut pid = 0;
GetWindowThreadProcessId(hwnd, &mut pid);
out.push((pid, String::from_utf16_lossy(&buf[..len as usize])));
1
}
/// Visible top-level windows that belong to java.exe / javaw.exe, one entry per process.
pub fn java_windows() -> Vec<JavaWindow> {
let mut windows: Vec<(u32, String)> = Vec::new();
unsafe {
EnumWindows(collect, &mut windows as *mut _ as isize);
}
let mut out: Vec<JavaWindow> = Vec::new();
for (pid, title) in windows {
if out.iter().any(|w| w.pid == pid) {
continue;
}
let Some(path) = image_name(pid) else { continue };
let exe = path.rsplit(['\\', '/']).next().unwrap_or("").to_string();
if exe.eq_ignore_ascii_case("javaw.exe") || exe.eq_ignore_ascii_case("java.exe") {
out.push(JavaWindow { pid, title, exe });
}
}
out
}
fn last_error(what: &str) -> String {
format!("{what} failed (error {})", unsafe { GetLastError() })
}
/// True when the module file name is loaded in the process.
fn module_loaded(pid: u32, file_name: &str) -> bool {
unsafe {
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, pid);
if snapshot.is_null() || snapshot as isize == -1 {
return false;
}
let mut entry: ModuleEntry32W = std::mem::zeroed();
entry.size = std::mem::size_of::<ModuleEntry32W>() as u32;
let mut found = false;
let mut ok = Module32FirstW(snapshot, &mut entry);
while ok != 0 {
let len = entry.sz_module.iter().position(|&c| c == 0).unwrap_or(entry.sz_module.len());
if String::from_utf16_lossy(&entry.sz_module[..len]).eq_ignore_ascii_case(file_name) {
found = true;
break;
}
ok = Module32NextW(snapshot, &mut entry);
}
CloseHandle(snapshot);
found
}
}
/// LoadLibraryW(dll) in the target process through CreateRemoteThread.
pub fn inject(pid: u32, dll: &Path) -> Result<(), String> {
unsafe {
let access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ;
let process = OpenProcess(access, 0, pid);
if process.is_null() {
return Err(last_error("OpenProcess (run as the same user, or as administrator)"));
}
let result = inject_into(process, pid, dll);
CloseHandle(process);
result
}
}
unsafe fn inject_into(process: Handle, pid: u32, dll: &Path) -> Result<(), String> {
let mut wow64 = 0;
if IsWow64Process(process, &mut wow64) != 0 && wow64 != 0 {
return Err("the target is a 32-bit JVM; only 64-bit JVMs are supported".into());
}
let file_name = dll.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if module_loaded(pid, &file_name) {
return Err(format!("{file_name} is already loaded in process {pid}"));
}
let path = wide(dll);
let bytes = path.len() * 2;
let remote = VirtualAllocEx(process, std::ptr::null_mut(), bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if remote.is_null() {
return Err(last_error("VirtualAllocEx"));
}
let cleanup = |err: String| {
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
Err(err)
};
if WriteProcessMemory(process, remote, path.as_ptr() as *const c_void, bytes, std::ptr::null_mut()) == 0 {
return cleanup(last_error("WriteProcessMemory"));
}
// kernel32 is mapped at the same base in every process of a boot session, so the local address is valid remotely.
let kernel32 = GetModuleHandleW(wide(Path::new("kernel32.dll")).as_ptr());
let load_library = GetProcAddress(kernel32, c"LoadLibraryW".as_ptr() as *const u8);
if load_library.is_null() {
return cleanup(last_error("resolve LoadLibraryW"));
}
let thread = CreateRemoteThread(process, std::ptr::null_mut(), 0, load_library, remote, 0, std::ptr::null_mut());
if thread.is_null() {
return cleanup(last_error("CreateRemoteThread"));
}
let wait = WaitForSingleObject(thread, 30_000);
let mut exit = 0;
GetExitCodeThread(thread, &mut exit);
CloseHandle(thread);
if wait != WAIT_OBJECT_0 {
// The thread may still be running inside the target; leave the buffer alone.
return Err("remote LoadLibraryW did not finish within 30 seconds".into());
}
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
// LoadLibraryW's return value is truncated to 32 bits by GetExitCodeThread, so confirm through the module list.
for _ in 0..20 {
if module_loaded(pid, &file_name) {
return Ok(());
}
std::thread::sleep(Duration::from_millis(100));
}
Err(format!("LoadLibraryW returned {exit:#x} but {file_name} is not mapped (missing dependency or blocked by security software?)"))
}
/// Opens a URL in the default web browser.
pub fn open_url(url: &str) -> bool {
let verb: Vec<u16> = "open".encode_utf16().collect();
let target: Vec<u16> = url.encode_utf16().chain(std::iter::once(0)).collect();
// ShellExecute returns a value greater than 32 on success
unsafe { ShellExecuteW(std::ptr::null_mut(), verb.as_ptr(), target.as_ptr(), std::ptr::null(), std::ptr::null(), 1) > 32 }
}
//! Win32 plumbing of the injector (raw bindings, no external crates).
use std::ffi::c_void;
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
use std::time::Duration;
use super::{JavaProcess, Key};
type Handle = *mut c_void;
const PROCESS_CREATE_THREAD: u32 = 0x0002;
const PROCESS_VM_OPERATION: u32 = 0x0008;
const PROCESS_VM_WRITE: u32 = 0x0020;
const PROCESS_VM_READ: u32 = 0x0010;
const PROCESS_QUERY_INFORMATION: u32 = 0x0400;
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
const MEM_COMMIT: u32 = 0x1000;
const MEM_RESERVE: u32 = 0x2000;
const MEM_RELEASE: u32 = 0x8000;
const PAGE_READWRITE: u32 = 0x04;
const TH32CS_SNAPMODULE: u32 = 0x8;
const TH32CS_SNAPMODULE32: u32 = 0x10;
const WAIT_OBJECT_0: u32 = 0;
#[repr(C)]
struct ModuleEntry32W {
size: u32,
module_id: u32,
process_id: u32,
glblcnt_usage: u32,
proccnt_usage: u32,
base_addr: *mut u8,
base_size: u32,
module: Handle,
sz_module: [u16; 256],
sz_exe_path: [u16; 260],
}
extern "system" {
fn OpenProcess(access: u32, inherit: i32, pid: u32) -> Handle;
fn CloseHandle(h: Handle) -> i32;
fn VirtualAllocEx(process: Handle, address: *mut c_void, size: usize, alloc_type: u32, protect: u32) -> *mut c_void;
fn VirtualFreeEx(process: Handle, address: *mut c_void, size: usize, free_type: u32) -> i32;
fn WriteProcessMemory(process: Handle, address: *mut c_void, buf: *const c_void, size: usize, written: *mut usize) -> i32;
fn CreateRemoteThread(process: Handle, attrs: *mut c_void, stack: usize, start: *mut c_void, param: *mut c_void, flags: u32, id: *mut u32) -> Handle;
fn WaitForSingleObject(h: Handle, ms: u32) -> u32;
fn GetExitCodeThread(h: Handle, code: *mut u32) -> i32;
fn GetModuleHandleW(name: *const u16) -> Handle;
fn GetProcAddress(module: Handle, name: *const u8) -> *mut c_void;
fn IsWow64Process(process: Handle, wow64: *mut i32) -> i32;
fn QueryFullProcessImageNameW(process: Handle, flags: u32, buf: *mut u16, size: *mut u32) -> i32;
fn GetLastError() -> u32;
fn CreateToolhelp32Snapshot(flags: u32, pid: u32) -> Handle;
fn Module32FirstW(snapshot: Handle, entry: *mut ModuleEntry32W) -> i32;
fn Module32NextW(snapshot: Handle, entry: *mut ModuleEntry32W) -> i32;
fn GetStdHandle(id: u32) -> Handle;
fn GetConsoleMode(h: Handle, mode: *mut u32) -> i32;
fn SetConsoleMode(h: Handle, mode: u32) -> i32;
}
#[link(name = "user32")]
extern "system" {
fn EnumWindows(callback: unsafe extern "system" fn(Handle, isize) -> i32, param: isize) -> i32;
fn IsWindowVisible(hwnd: Handle) -> i32;
fn GetWindowTextW(hwnd: Handle, buf: *mut u16, max: i32) -> i32;
fn GetWindowThreadProcessId(hwnd: Handle, pid: *mut u32) -> u32;
}
#[link(name = "shell32")]
extern "system" {
fn ShellExecuteW(hwnd: Handle, verb: *const u16, file: *const u16, params: *const u16, dir: *const u16, show: i32) -> isize;
}
extern "C" {
fn _kbhit() -> i32;
fn _getch() -> i32;
}
/// Nothing to restore on Windows (keys are read with _getch).
pub fn restore_terminal() {}
pub fn enable_ansi() {
unsafe {
let out = GetStdHandle(-11i32 as u32);
let mut mode = 0;
if GetConsoleMode(out, &mut mode) != 0 {
SetConsoleMode(out, mode | 0x0004);
}
}
}
/// Non-blocking key read: arrows arrive as 0xE0/0x00 followed by 72 (up) / 80 (down).
pub fn poll_key() -> Option<Key> {
unsafe {
if _kbhit() == 0 {
return None;
}
match _getch() {
13 => Some(Key::Enter),
27 => Some(Key::Escape),
0 | 0xE0 => match _getch() {
72 => Some(Key::Up),
80 => Some(Key::Down),
_ => None,
},
_ => None,
}
}
}
fn wide(path: &Path) -> Vec<u16> {
path.as_os_str().encode_wide().chain(std::iter::once(0)).collect()
}
fn image_name(pid: u32) -> Option<String> {
unsafe {
let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if process.is_null() {
return None;
}
let mut buf = [0u16; 1024];
let mut len = buf.len() as u32;
let ok = QueryFullProcessImageNameW(process, 0, buf.as_mut_ptr(), &mut len);
CloseHandle(process);
if ok == 0 {
return None;
}
Some(String::from_utf16_lossy(&buf[..len as usize]))
}
}
unsafe extern "system" fn collect(hwnd: Handle, param: isize) -> i32 {
let out = &mut *(param as *mut Vec<(u32, String)>);
if IsWindowVisible(hwnd) == 0 {
return 1;
}
let mut buf = [0u16; 256];
let len = GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32);
if len <= 0 {
return 1;
}
let mut pid = 0;
GetWindowThreadProcessId(hwnd, &mut pid);
out.push((pid, String::from_utf16_lossy(&buf[..len as usize])));
1
}
/// Visible top-level windows that belong to java.exe / javaw.exe, one entry per process.
pub fn java_processes() -> Vec<JavaProcess> {
let mut windows: Vec<(u32, String)> = Vec::new();
unsafe {
EnumWindows(collect, &mut windows as *mut _ as isize);
}
let mut out: Vec<JavaProcess> = Vec::new();
for (pid, title) in windows {
if out.iter().any(|w| w.pid == pid) {
continue;
}
let Some(path) = image_name(pid) else { continue };
let exe = path.rsplit(['\\', '/']).next().unwrap_or("").to_string();
if exe.eq_ignore_ascii_case("javaw.exe") || exe.eq_ignore_ascii_case("java.exe") {
out.push(JavaProcess { pid, title, exe });
}
}
out
}
fn last_error(what: &str) -> String {
format!("{what} failed (error {})", unsafe { GetLastError() })
}
/// True when the module file name is loaded in the process.
fn module_loaded(pid: u32, file_name: &str) -> bool {
unsafe {
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, pid);
if snapshot.is_null() || snapshot as isize == -1 {
return false;
}
let mut entry: ModuleEntry32W = std::mem::zeroed();
entry.size = std::mem::size_of::<ModuleEntry32W>() as u32;
let mut found = false;
let mut ok = Module32FirstW(snapshot, &mut entry);
while ok != 0 {
let len = entry.sz_module.iter().position(|&c| c == 0).unwrap_or(entry.sz_module.len());
if String::from_utf16_lossy(&entry.sz_module[..len]).eq_ignore_ascii_case(file_name) {
found = true;
break;
}
ok = Module32NextW(snapshot, &mut entry);
}
CloseHandle(snapshot);
found
}
}
/// LoadLibraryW(dll) in the target process through CreateRemoteThread.
pub fn inject(pid: u32, dll: &Path) -> Result<(), String> {
unsafe {
let access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ;
let process = OpenProcess(access, 0, pid);
if process.is_null() {
return Err(last_error("OpenProcess (run as the same user, or as administrator)"));
}
let result = inject_into(process, pid, dll);
CloseHandle(process);
result
}
}
unsafe fn inject_into(process: Handle, pid: u32, dll: &Path) -> Result<(), String> {
let mut wow64 = 0;
if IsWow64Process(process, &mut wow64) != 0 && wow64 != 0 {
return Err("the target is a 32-bit JVM; only 64-bit JVMs are supported".into());
}
let file_name = dll.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if module_loaded(pid, &file_name) {
return Err(format!("{file_name} is already loaded in process {pid}"));
}
let path = wide(dll);
let bytes = path.len() * 2;
let remote = VirtualAllocEx(process, std::ptr::null_mut(), bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if remote.is_null() {
return Err(last_error("VirtualAllocEx"));
}
let cleanup = |err: String| {
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
Err(err)
};
if WriteProcessMemory(process, remote, path.as_ptr() as *const c_void, bytes, std::ptr::null_mut()) == 0 {
return cleanup(last_error("WriteProcessMemory"));
}
// kernel32 is mapped at the same base in every process of a boot session, so the local address is valid remotely.
let kernel32 = GetModuleHandleW(wide(Path::new("kernel32.dll")).as_ptr());
let load_library = GetProcAddress(kernel32, c"LoadLibraryW".as_ptr() as *const u8);
if load_library.is_null() {
return cleanup(last_error("resolve LoadLibraryW"));
}
let thread = CreateRemoteThread(process, std::ptr::null_mut(), 0, load_library, remote, 0, std::ptr::null_mut());
if thread.is_null() {
return cleanup(last_error("CreateRemoteThread"));
}
let wait = WaitForSingleObject(thread, 30_000);
let mut exit = 0;
GetExitCodeThread(thread, &mut exit);
CloseHandle(thread);
if wait != WAIT_OBJECT_0 {
// The thread may still be running inside the target; leave the buffer alone.
return Err("remote LoadLibraryW did not finish within 30 seconds".into());
}
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
// LoadLibraryW's return value is truncated to 32 bits by GetExitCodeThread, so confirm through the module list.
for _ in 0..20 {
if module_loaded(pid, &file_name) {
return Ok(());
}
std::thread::sleep(Duration::from_millis(100));
}
Err(format!("LoadLibraryW returned {exit:#x} but {file_name} is not mapped (missing dependency or blocked by security software?)"))
}
/// Opens a URL in the default web browser.
pub fn open_url(url: &str) -> bool {
let verb: Vec<u16> = "open".encode_utf16().collect();
let target: Vec<u16> = url.encode_utf16().chain(std::iter::once(0)).collect();
// ShellExecute returns a value greater than 32 on success
unsafe { ShellExecuteW(std::ptr::null_mut(), verb.as_ptr(), target.as_ptr(), std::ptr::null(), std::ptr::null(), 1) > 32 }
}
+139 -30
View File
@@ -1,14 +1,16 @@
//! JVMTI state and the two natives of dev.zprestige.prestige.inject.NativeBridge.
use crate::jni::*;
use std::ffi::{c_char, c_void};
use std::sync::atomic::{AtomicPtr, AtomicUsize, Ordering};
use std::sync::atomic::{AtomicPtr, Ordering};
use std::sync::Mutex;
static JVMTI: AtomicPtr<*const *const c_void> = AtomicPtr::new(std::ptr::null_mut());
/// jclass whose bytes the ClassFileLoadHook should capture during the current RetransformClasses call (0 = none).
static CAPTURE_TARGET: AtomicUsize = AtomicUsize::new(0);
static CAPTURED: Mutex<Option<Vec<u8>>> = Mutex::new(None);
/// Serialises classBytes calls: CAPTURE_TARGET holds a thread-local jclass reference, so only one thread may capture at a time.
/// jclass references (as addresses) whose bytes the ClassFileLoadHook captures during the current RetransformClasses call.
/// Never held across the JVMTI call: the hook runs on the calling thread and locks it again.
static CAPTURE_TARGETS: Mutex<Vec<usize>> = Mutex::new(Vec::new());
/// Captured bytes, index-aligned with CAPTURE_TARGETS.
static CAPTURED: Mutex<Vec<Option<Vec<u8>>>> = Mutex::new(Vec::new());
/// Serialises classBytes calls: CAPTURE_TARGETS holds thread-local jclass references, so only one thread may capture at a time.
static CAPTURE_LOCK: Mutex<()> = Mutex::new(());
/// Acquires the JVMTI capabilities and enables the class file load hook. Called once from the worker thread.
@@ -22,7 +24,7 @@ pub unsafe fn init_jvmti(vm: VmPtr) -> Result<(), String> {
if rc != 0 {
return Err(format!("AddCapabilities failed ({rc})"));
}
enable_class_file_load_hook(jvmti, class_file_load_hook)?;
install_class_file_load_hook(jvmti, class_file_load_hook)?;
JVMTI.store(jvmti, Ordering::SeqCst);
Ok(())
}
@@ -40,14 +42,22 @@ unsafe extern "system" fn class_file_load_hook(
_new_len: *mut JInt,
_new_data: *mut *mut u8,
) {
let target = CAPTURE_TARGET.load(Ordering::SeqCst);
if target == 0 || class_being_redefined.is_null() || data.is_null() {
if class_being_redefined.is_null() || data.is_null() {
return;
}
let targets: Vec<usize> = match CAPTURE_TARGETS.lock() {
Ok(t) if !t.is_empty() => t.clone(),
_ => return,
};
let jni = Jni::new(env);
if jni.is_same_object(class_being_redefined, target as JObject) {
if let Ok(mut slot) = CAPTURED.lock() {
*slot = Some(std::slice::from_raw_parts(data, data_len as usize).to_vec());
for (i, &target) in targets.iter().enumerate() {
if jni.is_same_object(class_being_redefined, target as JObject) {
if let Ok(mut slots) = CAPTURED.lock() {
if let Some(slot) = slots.get_mut(i) {
*slot = Some(std::slice::from_raw_parts(data, data_len as usize).to_vec());
}
}
return;
}
}
}
@@ -58,22 +68,39 @@ pub unsafe extern "system" fn native_class_bytes(env: EnvPtr, _cls: JClass, targ
if jvmti.is_null() {
return std::ptr::null_mut();
}
let _serial = CAPTURE_LOCK.lock();
if let Ok(mut slot) = CAPTURED.lock() {
*slot = None;
match capture(jvmti, &[target]).and_then(|mut v| v.pop().flatten()) {
Some(bytes) => to_byte_array(&Jni::new(env), &bytes),
None => std::ptr::null_mut(),
}
}
/// Retransforms `classes` in one JVMTI call and returns the captured bytes, index-aligned (None = not captured).
unsafe fn capture(jvmti: JvmtiPtr, classes: &[JClass]) -> Option<Vec<Option<Vec<u8>>>> {
let _serial = CAPTURE_LOCK.lock();
if let Ok(mut c) = CAPTURED.lock() {
*c = vec![None; classes.len()];
}
if let Ok(mut t) = CAPTURE_TARGETS.lock() {
*t = classes.iter().map(|&c| c as usize).collect();
}
let enabled = set_class_file_load_hook_enabled(jvmti, true);
let rc = if enabled.is_ok() { retransform_classes(jvmti, classes) } else { -1 };
let _ = set_class_file_load_hook_enabled(jvmti, false);
if let Ok(mut t) = CAPTURE_TARGETS.lock() {
t.clear();
}
if let Err(e) = enabled {
crate::log!("classBytes: {e}");
return None;
}
CAPTURE_TARGET.store(target as usize, Ordering::SeqCst);
let rc = retransform_class(jvmti, target);
CAPTURE_TARGET.store(0, Ordering::SeqCst);
if rc != 0 {
crate::log!("RetransformClasses failed ({rc})");
return std::ptr::null_mut();
return None;
}
let bytes = CAPTURED.lock().ok().and_then(|mut slot| slot.take());
let Some(bytes) = bytes else {
return std::ptr::null_mut();
};
let jni = Jni::new(env);
CAPTURED.lock().ok().map(|mut c| std::mem::take(&mut *c))
}
unsafe fn to_byte_array(jni: &Jni, bytes: &[u8]) -> JObject {
let array = jni.new_byte_array(bytes.len() as JInt);
if !array.is_null() {
jni.set_byte_array_region(array, 0, bytes.len() as JInt, bytes.as_ptr());
@@ -81,6 +108,80 @@ pub unsafe extern "system" fn native_class_bytes(env: EnvPtr, _cls: JClass, targ
array
}
/// byte[][] classBytesBatch(Class[]): classBytes for many classes with a single RetransformClasses call (one safepoint, one pass
/// over the other agents' transformers). Entries are null for classes that were not captured; the result is null on failure.
pub unsafe extern "system" fn native_class_bytes_batch(env: EnvPtr, _cls: JClass, targets: JObject) -> JObject {
let jvmti = JVMTI.load(Ordering::SeqCst);
if jvmti.is_null() || targets.is_null() {
return std::ptr::null_mut();
}
let jni = Jni::new(env);
let n = jni.get_array_length(targets);
if n <= 0 {
return std::ptr::null_mut();
}
jni.ensure_local_capacity(2 * n + 16);
let classes: Vec<JClass> = (0..n).map(|i| jni.get_object_array_element(targets, i)).collect();
let Some(captured) = capture(jvmti, &classes) else {
return std::ptr::null_mut();
};
let byte_array_class = jni.find_class("[B");
let result = jni.new_object_array(n, byte_array_class, std::ptr::null_mut());
if result.is_null() {
return std::ptr::null_mut();
}
for (i, bytes) in captured.iter().enumerate() {
if let Some(bytes) = bytes {
let array = to_byte_array(&jni, bytes);
if !array.is_null() {
jni.set_object_array_element(result, i as JInt, array);
}
}
}
result
}
/// int redefineBatch(Class[], byte[][]): JVMTI RedefineClasses of many classes in one call (one safepoint). It is all or nothing:
/// the JVMTI error code is returned (0 = success) and no class is changed on failure.
pub unsafe extern "system" fn native_redefine_batch(env: EnvPtr, _cls: JClass, targets: JObject, bytes: JObject) -> JInt {
let jvmti = JVMTI.load(Ordering::SeqCst);
if jvmti.is_null() || targets.is_null() || bytes.is_null() {
return -1;
}
let jni = Jni::new(env);
let n = jni.get_array_length(targets);
if n <= 0 || jni.get_array_length(bytes) != n {
return -1;
}
jni.ensure_local_capacity(2 * n + 16);
let mut buffers: Vec<Vec<u8>> = Vec::with_capacity(n as usize);
let mut classes: Vec<JClass> = Vec::with_capacity(n as usize);
for i in 0..n {
let array = jni.get_object_array_element(bytes, i);
if array.is_null() {
return -1;
}
let len = jni.get_array_length(array);
if len < 0 {
return -1;
}
let mut buf = vec![0u8; len as usize];
jni.get_byte_array_region(array, 0, len, buf.as_mut_ptr());
buffers.push(buf);
classes.push(jni.get_object_array_element(targets, i));
}
let defs: Vec<ClassDefinition> = classes
.iter()
.zip(&buffers)
.map(|(&klass, buf)| ClassDefinition { klass, class_byte_count: buf.len() as JInt, class_bytes: buf.as_ptr() })
.collect();
let rc = redefine_classes(jvmti, &defs);
if rc != 0 {
crate::log!("RedefineClasses (batch of {n}) failed ({rc})");
}
rc
}
/// int redefine(Class, byte[]): JVMTI RedefineClasses of a single class; returns the JVMTI error code (0 = success).
pub unsafe extern "system" fn native_redefine(env: EnvPtr, _cls: JClass, target: JClass, bytes: JObject) -> JInt {
let jvmti = JVMTI.load(Ordering::SeqCst);
@@ -111,6 +212,7 @@ pub unsafe extern "system" fn native_log(env: EnvPtr, _cls: JClass, message: JOb
crate::log!("[java] {text}");
}
#[cfg(windows)]
extern "system" {
fn FreeLibraryAndExitThread(module: *mut c_void, exit_code: u32) -> !;
}
@@ -129,11 +231,18 @@ pub unsafe extern "system" fn native_release(env: EnvPtr, cls: JClass) {
if rc != JNI_OK {
crate::log!("release: UnregisterNatives failed ({rc})");
}
crate::log!("[java] released, unloading the loader");
let module = crate::MODULE as usize;
// The thread below runs code from this DLL until FreeLibraryAndExitThread; the delay lets in-flight native calls return.
let _ = std::thread::Builder::new().name("prestige-unload".into()).spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(2000));
FreeLibraryAndExitThread(module as *mut c_void, 0);
});
#[cfg(windows)]
{
crate::log!("[java] released, unloading the loader");
let module = crate::MODULE as usize;
// The thread below runs code from this DLL until FreeLibraryAndExitThread; the delay lets in-flight native calls return.
let _ = std::thread::Builder::new().name("prestige-unload".into()).spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(2000));
FreeLibraryAndExitThread(module as *mut c_void, 0);
});
}
// On unix the library is deliberately NOT dlclose'd: a thread of ours would return into unmapped code (and the
// JVM may still hold the agent handle). The hook and natives are gone; the code just stays resident.
#[cfg(unix)]
crate::log!("[java] released (library stays resident on this platform)");
}
+26 -13
View File
@@ -12,7 +12,6 @@ pub type JBool = u8;
pub type EnvPtr = *mut *const *const c_void;
pub const JNI_OK: JInt = 0;
pub const JNI_VERSION_1_8: JInt = 0x0001_0008;
pub const JVMTI_VERSION_1_2: JInt = 0x3001_0200;
#[repr(C)]
@@ -60,9 +59,6 @@ impl Jni {
pub unsafe fn new_global_ref(&self, o: JObject) -> JObject {
f!(self, 21, unsafe extern "system" fn(EnvPtr, JObject) -> JObject)(self.env, o)
}
pub unsafe fn delete_local_ref(&self, o: JObject) {
f!(self, 23, unsafe extern "system" fn(EnvPtr, JObject))(self.env, o)
}
pub unsafe fn is_same_object(&self, a: JObject, b: JObject) -> bool {
f!(self, 24, unsafe extern "system" fn(EnvPtr, JObject, JObject) -> JBool)(self.env, a, b) != 0
}
@@ -108,6 +104,15 @@ impl Jni {
pub unsafe fn get_array_length(&self, a: JObject) -> JInt {
f!(self, 171, unsafe extern "system" fn(EnvPtr, JObject) -> JInt)(self.env, a)
}
pub unsafe fn get_object_array_element(&self, a: JObject, i: JInt) -> JObject {
f!(self, 173, unsafe extern "system" fn(EnvPtr, JObject, JInt) -> JObject)(self.env, a, i)
}
pub unsafe fn set_object_array_element(&self, a: JObject, i: JInt, v: JObject) {
f!(self, 174, unsafe extern "system" fn(EnvPtr, JObject, JInt, JObject))(self.env, a, i, v)
}
pub unsafe fn ensure_local_capacity(&self, capacity: JInt) -> JInt {
f!(self, 26, unsafe extern "system" fn(EnvPtr, JInt) -> JInt)(self.env, capacity)
}
pub unsafe fn new_object_array(&self, len: JInt, c: JClass, init: JObject) -> JObject {
f!(self, 172, unsafe extern "system" fn(EnvPtr, JInt, JClass, JObject) -> JObject)(self.env, len, c, init)
}
@@ -126,9 +131,6 @@ impl Jni {
pub unsafe fn unregister_natives(&self, c: JClass) -> JInt {
f!(self, 216, unsafe extern "system" fn(EnvPtr, JClass) -> JInt)(self.env, c)
}
pub unsafe fn exception_check(&self) -> bool {
f!(self, 228, unsafe extern "system" fn(EnvPtr) -> JBool)(self.env) != 0
}
/// Returns "ExceptionClass: message" of the pending exception and clears it.
pub unsafe fn take_exception(&self) -> Option<String> {
@@ -236,8 +238,8 @@ pub unsafe fn add_capabilities(j: JvmtiPtr, caps: &Capabilities) -> JInt {
f(j, caps)
}
/// Installs the ClassFileLoadHook callback (field 5 of jvmtiEventCallbacks) and enables the event.
pub unsafe fn enable_class_file_load_hook(j: JvmtiPtr, hook: ClassFileLoadHook) -> Result<(), String> {
/// Installs the ClassFileLoadHook callback (field 5 of jvmtiEventCallbacks); the event itself stays off until enabled.
pub unsafe fn install_class_file_load_hook(j: JvmtiPtr, hook: ClassFileLoadHook) -> Result<(), String> {
let mut callbacks = [0usize; 40];
callbacks[4] = hook as usize;
let set_callbacks = std::mem::transmute::<*const c_void, unsafe extern "system" fn(JvmtiPtr, *const usize, JInt) -> JInt>(*(*j).add(121));
@@ -245,17 +247,28 @@ pub unsafe fn enable_class_file_load_hook(j: JvmtiPtr, hook: ClassFileLoadHook)
if rc != 0 {
return Err(format!("SetEventCallbacks failed ({rc})"));
}
Ok(())
}
/// Turns the ClassFileLoadHook event on or off. It is only on while class bytes are being captured: with the event enabled the
/// JVM hands the bytes of every class it loads to the agent, which slows class loading down for the whole game.
pub unsafe fn set_class_file_load_hook_enabled(j: JvmtiPtr, enabled: bool) -> Result<(), String> {
let set_mode = std::mem::transmute::<*const c_void, unsafe extern "C" fn(JvmtiPtr, JInt, JInt, JObject, ...) -> JInt>(*(*j).add(1));
let rc = set_mode(j, 1, EVENT_CLASS_FILE_LOAD_HOOK, std::ptr::null_mut());
let rc = set_mode(j, enabled as JInt, EVENT_CLASS_FILE_LOAD_HOOK, std::ptr::null_mut());
if rc != 0 {
return Err(format!("SetEventNotificationMode failed ({rc})"));
return Err(format!("SetEventNotificationMode({enabled}) failed ({rc})"));
}
Ok(())
}
pub unsafe fn retransform_class(j: JvmtiPtr, class: JClass) -> JInt {
pub unsafe fn retransform_classes(j: JvmtiPtr, classes: &[JClass]) -> JInt {
let f = std::mem::transmute::<*const c_void, unsafe extern "system" fn(JvmtiPtr, JInt, *const JClass) -> JInt>(*(*j).add(151));
f(j, 1, &class)
f(j, classes.len() as JInt, classes.as_ptr())
}
pub unsafe fn redefine_classes(j: JvmtiPtr, defs: &[ClassDefinition]) -> JInt {
let f = std::mem::transmute::<*const c_void, unsafe extern "system" fn(JvmtiPtr, JInt, *const ClassDefinition) -> JInt>(*(*j).add(86));
f(j, defs.len() as JInt, defs.as_ptr())
}
pub unsafe fn redefine_class(j: JvmtiPtr, def: &ClassDefinition) -> JInt {
+31
View File
@@ -10,14 +10,18 @@ mod worker;
use std::ffi::c_void;
#[cfg(windows)]
const DLL_PROCESS_ATTACH: u32 = 1;
#[cfg(windows)]
extern "system" {
fn DisableThreadLibraryCalls(module: *mut c_void) -> i32;
}
#[cfg(windows)]
pub(crate) static mut MODULE: *mut c_void = std::ptr::null_mut();
#[cfg(windows)]
#[no_mangle]
pub extern "system" fn DllMain(module: *mut c_void, reason: u32, _reserved: *mut c_void) -> i32 {
if reason == DLL_PROCESS_ATTACH {
@@ -30,3 +34,30 @@ pub extern "system" fn DllMain(module: *mut c_void, reason: u32, _reserved: *mut
}
1
}
/// The JavaVM handed to Agent_OnAttach / Agent_OnLoad by the JVM (unix); read by the worker.
#[cfg(unix)]
pub(crate) static VM: std::sync::atomic::AtomicPtr<c_void> = std::sync::atomic::AtomicPtr::new(std::ptr::null_mut());
#[cfg(unix)]
fn start_worker(vm: *mut c_void) -> i32 {
VM.store(vm, std::sync::atomic::Ordering::SeqCst);
match std::thread::Builder::new().name("prestige-loader".into()).spawn(worker::run) {
Ok(_) => 0,
Err(_) => -1,
}
}
/// Called by HotSpot when the library is loaded through the attach protocol (JVMTI.agent_load).
#[cfg(unix)]
#[no_mangle]
pub extern "C" fn Agent_OnAttach(vm: *mut c_void, _options: *mut std::ffi::c_char, _reserved: *mut c_void) -> i32 {
start_worker(vm)
}
/// Called by the JVM when the library is given as -agentpath at start-up.
#[cfg(unix)]
#[no_mangle]
pub extern "C" fn Agent_OnLoad(vm: *mut c_void, _options: *mut std::ffi::c_char, _reserved: *mut c_void) -> i32 {
start_worker(vm)
}
+38 -4
View File
@@ -1,21 +1,55 @@
use std::ffi::c_void;
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(windows)]
extern "system" {
fn GetModuleFileNameW(module: *mut c_void, buf: *mut u16, size: u32) -> u32;
}
#[cfg(unix)]
#[repr(C)]
struct DlInfo {
dli_fname: *const std::ffi::c_char,
dli_fbase: *mut c_void,
dli_sname: *const std::ffi::c_char,
dli_saddr: *mut c_void,
}
#[cfg(unix)]
extern "C" {
fn dladdr(addr: *const c_void, info: *mut DlInfo) -> i32;
}
#[cfg(unix)]
fn module_path() -> PathBuf {
fn anchor() {}
let mut info = DlInfo { dli_fname: std::ptr::null(), dli_fbase: std::ptr::null_mut(), dli_sname: std::ptr::null(), dli_saddr: std::ptr::null_mut() };
let ok = unsafe { dladdr(anchor as fn() as usize as *const c_void, &mut info) };
if ok != 0 && !info.dli_fname.is_null() {
let name = unsafe { std::ffi::CStr::from_ptr(info.dli_fname) };
PathBuf::from(std::ffi::OsStr::from_bytes(name.to_bytes()))
} else {
std::env::temp_dir().join("libprestige_native")
}
}
#[cfg(windows)]
fn module_path() -> PathBuf {
let mut buf = [0u16; 1024];
let len = unsafe { GetModuleFileNameW(crate::MODULE, buf.as_mut_ptr(), buf.len() as u32) } as usize;
PathBuf::from(String::from_utf16_lossy(&buf[..len]))
}
fn log_path() -> &'static PathBuf {
static PATH: OnceLock<PathBuf> = OnceLock::new();
PATH.get_or_init(|| {
let mut buf = [0u16; 1024];
let len = unsafe { GetModuleFileNameW(crate::MODULE, buf.as_mut_ptr(), buf.len() as u32) } as usize;
let dll = PathBuf::from(String::from_utf16_lossy(&buf[..len]));
dll.with_file_name("prestige-native.log")
module_path().with_file_name("prestige-native.log")
})
}
+122 -15
View File
@@ -1,15 +1,18 @@
//! The loader worker: JVM discovery, payload installation into Fabric's Knot class loader, native registration, bootstrap.
use crate::bridge::{init_jvmti, native_class_bytes, native_log, native_redefine, native_release};
use crate::bridge::{init_jvmti, native_class_bytes, native_class_bytes_batch, native_log, native_redefine, native_redefine_batch, native_release};
use crate::jni::*;
use crate::log;
use std::ffi::{c_char, c_void};
#[cfg(windows)]
use std::ffi::c_char;
use std::ffi::c_void;
use std::path::PathBuf;
#[cfg(windows)]
use std::time::Duration;
#[cfg(windows)]
extern "system" {
fn GetModuleHandleA(name: *const c_char) -> *mut c_void;
fn GetProcAddress(module: *mut c_void, name: *const c_char) -> *mut c_void;
fn GetCurrentProcessId() -> u32;
}
static PAYLOAD: &[u8] = include_bytes!(env!("PRESTIGE_PAYLOAD_JAR"));
@@ -18,7 +21,7 @@ const NATIVE_BRIDGE: &str = "dev.zprestige.prestige.inject.NativeBridge";
const BOOTSTRAP: &str = "dev.zprestige.prestige.inject.Bootstrap";
pub fn run() {
log!("worker started, pid {}", unsafe { GetCurrentProcessId() });
log!("worker started, pid {}", std::process::id());
match unsafe { install() } {
Ok(()) => log!("bootstrap returned"),
Err(e) => log!("FAILED: {e}"),
@@ -50,7 +53,7 @@ unsafe fn install() -> Result<(), String> {
register_natives(&jni, loader)?;
log!("natives registered");
let bootstrap = class_for_name(&jni, BOOTSTRAP, loader)?;
let bootstrap = class_for_name(&jni, BOOTSTRAP, loader, true)?;
let start = jni.get_static_method_id(bootstrap, "start", "()V");
jni.check("resolve Bootstrap.start")?;
// Bootstrap.start waits for the client, installs the hooks and schedules the client initialisation.
@@ -59,7 +62,18 @@ unsafe fn install() -> Result<(), String> {
Ok(())
}
/// The JavaVM passed to Agent_OnAttach / Agent_OnLoad.
#[cfg(unix)]
unsafe fn find_vm() -> Result<VmPtr, String> {
let vm = crate::VM.load(std::sync::atomic::Ordering::SeqCst) as VmPtr;
if vm.is_null() {
return Err("no JavaVM was passed to the agent".into());
}
Ok(vm)
}
/// Waits for jvm.dll and a created JavaVM (the injector may run before Minecraft has started its JVM).
#[cfg(windows)]
unsafe fn find_vm() -> Result<VmPtr, String> {
let mut module = std::ptr::null_mut();
for _ in 0..600 {
@@ -91,7 +105,7 @@ unsafe fn find_vm() -> Result<VmPtr, String> {
fn write_payload() -> Result<PathBuf, String> {
let dir = std::env::temp_dir().join("PrestigeLoader");
std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
let path = dir.join(format!("prestige-payload-{}.jar", unsafe { GetCurrentProcessId() }));
let path = dir.join(format!("prestige-payload-{}.jar", std::process::id()));
std::fs::write(&path, PAYLOAD).map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path)
}
@@ -99,9 +113,8 @@ fn write_payload() -> Result<PathBuf, String> {
/// FabricLauncherBase.getLauncher().addToClassPath(path) so the payload and the game classes share the Knot class loader.
/// Returns a global reference to the Knot target class loader.
unsafe fn add_to_knot(jni: &Jni, jar: &std::path::Path) -> Result<JObject, String> {
// A thread attached from native code resolves classes through the system class loader, which holds fabric-loader.
let base = jni.find_class("net/fabricmc/loader/impl/launch/FabricLauncherBase");
jni.check("FabricLauncherBase (is this a Fabric instance?)")?;
jni.ensure_local_capacity(4096);
let (base, fabric_loader) = locate_fabric(jni)?;
let get_launcher = jni.get_static_method_id(base, "getLauncher", "()Lnet/fabricmc/loader/impl/launch/FabricLauncher;");
jni.check("FabricLauncherBase.getLauncher")?;
let launcher = jni.call_static_object(base, get_launcher, &[]);
@@ -110,8 +123,7 @@ unsafe fn add_to_knot(jni: &Jni, jar: &std::path::Path) -> Result<JObject, Strin
return Err("Fabric launcher is not available yet".into());
}
let launcher_class = jni.find_class("net/fabricmc/loader/impl/launch/FabricLauncher");
jni.check("FabricLauncher")?;
let launcher_class = load_fabric_class(jni, "net.fabricmc.loader.impl.launch.FabricLauncher", fabric_loader)?;
let add = jni.get_method_id(launcher_class, "addToClassPath", "(Ljava/nio/file/Path;[Ljava/lang/String;)V");
jni.check("FabricLauncher.addToClassPath")?;
let target = jni.get_method_id(launcher_class, "getTargetClassLoader", "()Ljava/lang/ClassLoader;");
@@ -140,13 +152,98 @@ unsafe fn add_to_knot(jni: &Jni, jar: &std::path::Path) -> Result<JObject, Strin
Ok(jni.new_global_ref(loader))
}
/// Class.forName(name, true, loader)
unsafe fn class_for_name(jni: &Jni, name: &str, loader: JObject) -> Result<JClass, String> {
const FABRIC_BASE: &str = "net.fabricmc.loader.impl.launch.FabricLauncherBase";
/// Finds the class loader that can see fabric-loader. The system class loader is tried first (official launcher, most
/// others); launchers that run the game from their own URLClassLoader (Prism/MultiMC, many modpack launchers) keep
/// fabric-loader out of it, so every thread's context class loader is probed too. Returns the FabricLauncherBase class
/// and the loader it was found through (null = FindClass / system loader).
unsafe fn locate_fabric(jni: &Jni) -> Result<(JClass, JObject), String> {
let base = jni.find_class(&FABRIC_BASE.replace('.', "/"));
if jni.take_exception().is_none() && !base.is_null() {
return Ok((base, std::ptr::null_mut()));
}
log!("FabricLauncherBase is not visible to the system class loader, probing thread context class loaders");
let loaders = thread_loaders(jni)?;
for &loader in &loaders {
if let Ok(class) = class_for_name(jni, FABRIC_BASE, loader, false) {
log!("fabric-loader found through {}", describe(jni, loader));
return Ok((class, loader));
}
}
let seen: Vec<String> = loaders.iter().map(|&l| describe(jni, l)).collect();
Err(format!(
"net.fabricmc.loader is not visible to any class loader ({} probed: {}); is this a Fabric instance?",
seen.len(),
seen.join(" | ")
))
}
/// Distinct context class loaders of all live threads (the game threads inherit the launcher's loader).
unsafe fn thread_loaders(jni: &Jni) -> Result<Vec<JObject>, String> {
let thread_class = jni.find_class("java/lang/Thread");
let get_all = jni.get_static_method_id(thread_class, "getAllStackTraces", "()Ljava/util/Map;");
let get_context = jni.get_method_id(thread_class, "getContextClassLoader", "()Ljava/lang/ClassLoader;");
let map_class = jni.find_class("java/util/Map");
let key_set = jni.get_method_id(map_class, "keySet", "()Ljava/util/Set;");
let set_class = jni.find_class("java/util/Set");
let to_array = jni.get_method_id(set_class, "toArray", "()[Ljava/lang/Object;");
jni.check("Thread/Map/Set lookup")?;
let map = jni.call_static_object(thread_class, get_all, &[]);
jni.check("Thread.getAllStackTraces")?;
let set = jni.call_object(jni_non_null(map)?, key_set, &[]);
let threads = jni.call_object(jni_non_null(set)?, to_array, &[]);
jni.check("thread list")?;
let mut loaders: Vec<JObject> = Vec::new();
for i in 0..jni.get_array_length(jni_non_null(threads)?) {
let thread = jni.get_object_array_element(threads, i);
if thread.is_null() {
continue;
}
let loader = jni.call_object(thread, get_context, &[]);
if jni.take_exception().is_some() || loader.is_null() {
continue;
}
if !loaders.iter().any(|&known| jni.is_same_object(known, loader)) {
loaders.push(loader);
}
}
Ok(loaders)
}
unsafe fn jni_non_null(o: JObject) -> Result<JObject, String> {
if o.is_null() { Err("unexpected null from the JVM".into()) } else { Ok(o) }
}
/// Object.toString() of a class loader for the log.
unsafe fn describe(jni: &Jni, o: JObject) -> String {
let class = jni.get_object_class(o);
let to_string = jni.get_method_id(class, "toString", "()Ljava/lang/String;");
if to_string.is_null() {
jni.exception_clear();
return "<loader>".into();
}
let s = jni.call_object(o, to_string, &[]);
if jni.take_exception().is_some() || s.is_null() { "<loader>".into() } else { jni.get_string_utf(s) }
}
/// Loads a fabric-loader class through the loader found by `locate_fabric` (null = system class loader).
unsafe fn load_fabric_class(jni: &Jni, name: &str, loader: JObject) -> Result<JClass, String> {
if loader.is_null() {
let class = jni.find_class(&name.replace('.', "/"));
jni.check(name)?;
return Ok(class);
}
class_for_name(jni, name, loader, true)
}
/// Class.forName(name, initialize, loader)
unsafe fn class_for_name(jni: &Jni, name: &str, loader: JObject, initialize: bool) -> Result<JClass, String> {
let class_class = jni.find_class("java/lang/Class");
let for_name = jni.get_static_method_id(class_class, "forName", "(Ljava/lang/String;ZLjava/lang/ClassLoader;)Ljava/lang/Class;");
jni.check("Class.forName")?;
let name_string = jni.new_string(name);
let class = jni.call_static_object(class_class, for_name, &[obj(name_string), boolean(true), obj(loader)]);
let class = jni.call_static_object(class_class, for_name, &[obj(name_string), boolean(initialize), obj(loader)]);
jni.check(&format!("load {name}"))?;
if class.is_null() {
return Err(format!("{name} not found"));
@@ -155,7 +252,7 @@ unsafe fn class_for_name(jni: &Jni, name: &str, loader: JObject) -> Result<JClas
}
unsafe fn register_natives(jni: &Jni, loader: JObject) -> Result<(), String> {
let class = class_for_name(jni, NATIVE_BRIDGE, loader)?;
let class = class_for_name(jni, NATIVE_BRIDGE, loader, true)?;
let methods = [
NativeMethod {
name: c"classBytes".as_ptr(),
@@ -167,6 +264,16 @@ unsafe fn register_natives(jni: &Jni, loader: JObject) -> Result<(), String> {
signature: c"(Ljava/lang/Class;[B)I".as_ptr(),
fn_ptr: native_redefine as *const () as usize as *mut c_void,
},
NativeMethod {
name: c"classBytesBatch".as_ptr(),
signature: c"([Ljava/lang/Class;)[[B".as_ptr(),
fn_ptr: native_class_bytes_batch as *const () as usize as *mut c_void,
},
NativeMethod {
name: c"redefineBatch".as_ptr(),
signature: c"([Ljava/lang/Class;[[B)I".as_ptr(),
fn_ptr: native_redefine_batch as *const () as usize as *mut c_void,
},
NativeMethod {
name: c"release".as_ptr(),
signature: c"()V".as_ptr(),