// Linux injection: ptrace the target and make it call its own dlopen(). // // This is the same technique as the Catware injector: attach to the process, // save its registers, resolve dlopen in the target (preferring the target's own // libc ELF), point rip at it with the library path on a scratch stack, and use // a return address of 0 as the completion signal. The client library's ELF // constructor then starts the client, exactly as DllMain does on Windows. // // The library is injected from a private copy under a fresh name so that a // re-injection after Unload starts a new copy with fresh statics; the old // mapping is never unloaded (-z nodelete). #include "inject.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace Injector { namespace { std::string lower(std::string text) { std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return (char)std::tolower(c); }); return text; } std::string trim(std::string text) { while (!text.empty() && (text.back() == '\n' || text.back() == '\r' || text.back() == ' ')) text.pop_back(); size_t start = text.find_first_not_of(" \t"); return start == std::string::npos ? std::string() : text.substr(start); } std::string read_small_file(const std::string &path) { std::ifstream in(path, std::ios::binary); if (!in) return {}; std::ostringstream out; out << in.rdbuf(); return out.str(); } std::string command_line_of(unsigned long pid) { std::string data = read_small_file("/proc/" + std::to_string(pid) + "/cmdline"); for (char &c : data) { if (c == '\0') c = ' '; } return trim(data); } std::string name_of(unsigned long pid) { return trim(read_small_file("/proc/" + std::to_string(pid) + "/comm")); } // ---- ELF / maps helpers ---- struct MappedRegion { uintptr_t start = 0; uintptr_t end = 0; uintptr_t offset = 0; std::string path; }; std::vector read_maps(unsigned long pid) { std::vector regions; std::ifstream maps("/proc/" + std::to_string(pid) + "/maps"); std::string line; while (std::getline(maps, line)) { uintptr_t start = 0, end = 0, offset = 0; char path[512] = {0}; int parsed = std::sscanf(line.c_str(), "%lx-%lx %*4s %lx %*x:%*x %*d %511[^\n]", &start, &end, &offset, path); if (parsed < 3) continue; MappedRegion region; region.start = start; region.end = end; region.offset = offset; region.path = parsed == 4 ? path : ""; size_t first = region.path.find_first_not_of(' '); if (first == std::string::npos) region.path.clear(); else region.path.erase(0, first); regions.push_back(region); } return regions; } std::vector read_target_file(unsigned long pid, const std::string &path) { // The target's mount namespace first: in a Flatpak or other container the same path on the host is a // different libc build, and its symbol offsets would point into the wrong place. std::string through_root = "/proc/" + std::to_string(pid) + "/root" + path; for (const std::string &candidate : {through_root, path}) { std::ifstream in(candidate, std::ios::binary); if (!in) continue; return std::vector((std::istreambuf_iterator(in)), std::istreambuf_iterator()); } return {}; } uint64_t elf_function_value(const std::vector &bytes, const char *name) { if (bytes.size() < sizeof(Elf64_Ehdr)) return 0; const auto *header = reinterpret_cast(bytes.data()); if (std::memcmp(header->e_ident, ELFMAG, SELFMAG) != 0 || header->e_ident[EI_CLASS] != ELFCLASS64 || header->e_shoff == 0 || header->e_shnum == 0 || header->e_shoff + (uint64_t)header->e_shnum * sizeof(Elf64_Shdr) > bytes.size()) { return 0; } const auto *sections = reinterpret_cast(bytes.data() + header->e_shoff); for (uint32_t want : {(uint32_t)SHT_DYNSYM, (uint32_t)SHT_SYMTAB}) { for (int i = 0; i < header->e_shnum; i++) { const Elf64_Shdr §ion = sections[i]; if (section.sh_type != want || section.sh_entsize == 0 || section.sh_link >= header->e_shnum || section.sh_offset + section.sh_size > bytes.size()) { continue; } const auto *symbols = reinterpret_cast(bytes.data() + section.sh_offset); const char *names = reinterpret_cast(bytes.data() + sections[section.sh_link].sh_offset); size_t count = section.sh_size / section.sh_entsize; for (size_t s = 0; s < count; s++) { if (ELF64_ST_TYPE(symbols[s].st_info) != STT_FUNC || symbols[s].st_value == 0) continue; if (std::strcmp(names + symbols[s].st_name, name) == 0) return symbols[s].st_value; } } } return 0; } std::optional resolve_dlopen_from_elf(unsigned long pid, const std::vector ®ions) { for (const MappedRegion &header : regions) { if (header.offset != 0) continue; bool is_libc = header.path.find("libc.so.6") != std::string::npos || header.path.find("libdl.so.2") != std::string::npos; if (!is_libc) continue; uint64_t value = elf_function_value(read_target_file(pid, header.path), "dlopen"); if (!value) continue; for (const MappedRegion &segment : regions) { if (segment.path == header.path && value >= segment.offset && value < segment.offset + (segment.end - segment.start)) { return segment.start + (uintptr_t)(value - segment.offset); } } } return std::nullopt; } std::optional resolve_remote_dlopen(unsigned long pid, const LogFn &log) { void *local_dlopen = dlsym(RTLD_DEFAULT, "dlopen"); if (!local_dlopen) { log(std::string("dlsym(dlopen) failed: ") + dlerror()); return std::nullopt; } const auto local_regions = read_maps(getpid()); const MappedRegion *owner = nullptr; for (const auto ®ion : local_regions) { if ((uintptr_t)local_dlopen >= region.start && (uintptr_t)local_dlopen < region.end && !region.path.empty() && region.path[0] == '/') { owner = ®ion; break; } } if (!owner) { log("could not find dlopen in the injector's own maps"); return std::nullopt; } const auto remote_regions = read_maps(pid); // The target's own libc first. A Flatpak/container runtime is a different // build at the same path, where our offset would land in the wrong place. if (auto from_elf = resolve_dlopen_from_elf(pid, remote_regions)) return from_elf; // Otherwise match the same file and the same segment offset. uintptr_t within = (uintptr_t)local_dlopen - owner->start; for (const auto ®ion : remote_regions) { if (region.path == owner->path && region.offset == owner->offset) { return region.start + within; } } log("could not resolve dlopen in the target (no matching libc segment)"); return std::nullopt; } // ---- ptrace plumbing ---- bool wait_stopped(pid_t pid, int &status) { for (;;) { pid_t r = waitpid(pid, &status, 0); if (r == pid) return true; if (r < 0 && errno == EINTR) continue; return false; } } bool write_remote(pid_t pid, uintptr_t address, const void *data, size_t size) { iovec local{const_cast(data), size}; iovec remote{reinterpret_cast(address), size}; if (process_vm_writev(pid, &local, 1, &remote, 1, 0) == (ssize_t)size) return true; // Fallback for kernels without process_vm_writev. const uint8_t *bytes = static_cast(data); for (size_t done = 0; done < size;) { long word = 0; size_t n = std::min(sizeof(word), size - done); std::memcpy(&word, bytes + done, n); if (ptrace(PTRACE_POKEDATA, pid, reinterpret_cast(address + done), reinterpret_cast(word)) != 0) { return false; } done += n; } return true; } bool inject_path(pid_t pid, const std::string &path, const LogFn &log) { if (ptrace(PTRACE_ATTACH, pid, nullptr, nullptr) != 0) { log(std::string("PTRACE_ATTACH failed: ") + std::strerror(errno)); return false; } int status = 0; if (!wait_stopped(pid, status)) { log("waitpid after attach failed"); ptrace(PTRACE_DETACH, pid, nullptr, nullptr); return false; } user_regs_struct saved{}; if (ptrace(PTRACE_GETREGS, pid, nullptr, &saved) != 0) { log(std::string("PTRACE_GETREGS failed: ") + std::strerror(errno)); ptrace(PTRACE_DETACH, pid, nullptr, nullptr); return false; } auto remote_dlopen = resolve_remote_dlopen(pid, log); if (!remote_dlopen) { ptrace(PTRACE_SETREGS, pid, nullptr, &saved); ptrace(PTRACE_DETACH, pid, nullptr, nullptr); return false; } // The stack grows down from rsp. The path must sit above the call stack, // or dlopen overwrites its own filename argument while loading. constexpr size_t scratch_offset = 256; constexpr size_t call_stack_offset = 16384; uintptr_t scratch = (saved.rsp - scratch_offset) & ~(uintptr_t)0xF; uintptr_t call_rsp = (saved.rsp - call_stack_offset) & ~(uintptr_t)0xF; std::string padded = path; padded.resize(((padded.size() / 8) + 2) * 8, '\0'); if (!write_remote(pid, scratch, padded.data(), padded.size())) { log(std::string("failed to write library path into target: ") + std::strerror(errno)); ptrace(PTRACE_SETREGS, pid, nullptr, &saved); ptrace(PTRACE_DETACH, pid, nullptr, nullptr); return false; } // A return address of 0 makes dlopen's `ret` fault at rip == 0. That // private fault is the completion signal; no shared code is patched, so // the other JVM threads can keep running untouched. constexpr uintptr_t return_address = 0; user_regs_struct call = saved; call.rdi = scratch; call.rsi = RTLD_NOW; call.rip = *remote_dlopen; call.orig_rax = (unsigned long long)-1; // no syscall to restart call.rsp = call_rsp - 8; auto restore = [&]() { ptrace(PTRACE_SETREGS, pid, nullptr, &saved); ptrace(PTRACE_DETACH, pid, nullptr, nullptr); }; if (ptrace(PTRACE_POKEDATA, pid, reinterpret_cast(call.rsp), reinterpret_cast(return_address)) != 0) { log(std::string("failed to write completion address: ") + std::strerror(errno)); restore(); return false; } if (ptrace(PTRACE_SETREGS, pid, nullptr, &call) != 0) { log(std::string("PTRACE_SETREGS failed: ") + std::strerror(errno)); restore(); return false; } bool ok = false; int pending_signal = 0; user_regs_struct fault{}; for (int attempt = 0; attempt < (1 << 20); attempt++) { if (ptrace(PTRACE_CONT, pid, nullptr, reinterpret_cast((uintptr_t)pending_signal)) != 0) { log(std::string("PTRACE_CONT failed: ") + std::strerror(errno)); restore(); return false; } pending_signal = 0; if (!wait_stopped(pid, status)) { log("target vanished while injecting"); return false; } if (!WIFSTOPPED(status)) { log("target exited while injecting"); return false; } int signal = WSTOPSIG(status); if (signal == SIGSEGV) { ptrace(PTRACE_GETREGS, pid, nullptr, &fault); if (fault.rip == return_address) { ok = true; break; } pending_signal = SIGSEGV; // HotSpot's own fault: deliver it continue; } pending_signal = signal; // timers, SIGQUIT, ...: the target's own } if (ok) { log("dlopen returned handle 0x" + [&] { char buf[32]; std::snprintf(buf, sizeof buf, "%llx", (unsigned long long)fault.rax); return std::string(buf); }()); if (fault.rax == 0) { ok = false; log("dlopen returned NULL -- the library failed to load in the target"); } } else { log("dlopen did not return through the completion address"); } if (ptrace(PTRACE_SETREGS, pid, nullptr, &saved) != 0) { log(std::string("warning: failed to restore registers: ") + std::strerror(errno)); } if (ptrace(PTRACE_DETACH, pid, nullptr, nullptr) != 0) { log(std::string("warning: PTRACE_DETACH failed: ") + std::strerror(errno)); } return ok; } // The copy is written through /proc//root/tmp so that a target with its own /tmp (PrivateTmp, Flatpak, // pressure-vessel) can open it; `host_path` is where the injector sees it, `target_path` what dlopen gets. bool private_copy(const std::string &library, unsigned long pid, std::string &host_path, std::string &target_path, const LogFn &log) { char source[PATH_MAX]; if (!realpath(library.c_str(), source)) { log(std::string("cannot resolve ") + library + ": " + std::strerror(errno)); return false; } std::ifstream in(source, std::ios::binary); if (!in) { log(std::string("cannot open ") + source); return false; } std::string pattern = "/proc/" + std::to_string(pid) + "/root/tmp/slinky-inject-XXXXXX"; int fd = mkstemp(pattern.data()); if (fd >= 0) { target_path = pattern.substr(pattern.find("/tmp/")); } else { pattern = "/tmp/slinky-inject-XXXXXX"; fd = mkstemp(pattern.data()); target_path = pattern; } if (fd < 0) { log(std::string("cannot create a private library copy: ") + std::strerror(errno)); return false; } fchmod(fd, 0644); std::vector buffer(64 * 1024); bool ok = true; while (in) { in.read(buffer.data(), (std::streamsize)buffer.size()); std::streamsize got = in.gcount(); for (std::streamsize done = 0; done < got;) { ssize_t n = write(fd, buffer.data() + done, (size_t)(got - done)); if (n < 0) { if (errno == EINTR) continue; ok = false; break; } done += n; } if (!ok) break; } if (close(fd) != 0) ok = false; if (!ok) { unlink(pattern.c_str()); log("failed to write the private library copy"); return false; } host_path = pattern; return true; } } // namespace std::vector ListProcesses() { std::vector out; DIR *proc = opendir("/proc"); if (!proc) return out; while (dirent *entry = readdir(proc)) { if (entry->d_name[0] < '0' || entry->d_name[0] > '9') continue; unsigned long pid = std::strtoul(entry->d_name, nullptr, 10); if (!pid) continue; std::string cmdline = command_line_of(pid); if (cmdline.empty()) continue; // kernel thread out.push_back(Process{pid, name_of(pid), cmdline}); } closedir(proc); std::sort(out.begin(), out.end(), [](const Process &a, const Process &b) { if (a.name != b.name) return a.name < b.name; return a.pid < b.pid; }); return out; } bool FindMinecraft(Process &out, std::string &error) { int best_score = 0; for (const Process &process : ListProcesses()) { std::string cmd = lower(process.command_line); std::string name = lower(process.name); bool java = name.find("java") != std::string::npos || cmd.find("/java ") != std::string::npos; if (!java) continue; int score = 0; if (cmd.find("net.minecraft") != std::string::npos) score += 100; if (cmd.find("minecraft") != std::string::npos) score += 50; if (cmd.find("fabric") != std::string::npos) score += 10; if (cmd.find("forge") != std::string::npos) score += 10; if (cmd.find("--gamedir") != std::string::npos) score += 10; if (score > best_score) { best_score = score; out = process; } } if (best_score > 0) return true; error = "Minecraft is not running (no java process with minecraft in its command line)"; return false; } bool FindTarget(const std::string &target, Process &out, std::string &error) { if (target.empty()) { error = "no target given"; return false; } bool numeric = std::all_of(target.begin(), target.end(), [](unsigned char c) { return std::isdigit(c); }); if (numeric) { out.pid = std::strtoul(target.c_str(), nullptr, 10); out.name = name_of(out.pid); out.command_line = command_line_of(out.pid); if (out.pid && !out.name.empty()) return true; error = "no process with pid " + target; return false; } std::string needle = lower(target); for (const Process &process : ListProcesses()) { if (lower(process.name) == needle) { out = process; return true; } } for (const Process &process : ListProcesses()) { if (lower(process.name).find(needle) != std::string::npos || lower(process.command_line).find(needle) != std::string::npos) { out = process; return true; } } error = "no process matching '" + target + "'"; return false; } bool Inject(unsigned long pid, const std::string &library, const LogFn &log) { std::string host_copy, target_copy; if (!private_copy(library, pid, host_copy, target_copy, log)) return false; log("injecting " + library + " as " + target_copy + " into pid " + std::to_string(pid)); bool ok = inject_path((pid_t)pid, target_copy, log); // The loaded mapping keeps the inode alive, so a later copy can never alias it. unlink(host_copy.c_str()); return ok; } std::string EnvironmentHint() { std::ifstream scope_file("/proc/sys/kernel/yama/ptrace_scope"); int scope = -1; if (scope_file >> scope && scope > 0 && geteuid() != 0) { return "kernel.yama.ptrace_scope is " + std::to_string(scope) + "; run the injector with sudo, or set it to 0 (sudo sysctl kernel.yama.ptrace_scope=0), " "or give the binary CAP_SYS_PTRACE (sudo setcap cap_sys_ptrace+ep )"; } return {}; } } // namespace Injector