1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! 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(), ®s 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())
}