Add project files (part 1/2)

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
tonihaxandClaude Sonnet 5 committed 2026-11-05 19:45:34 +01:00
1 parent 3721eaecb2
commit 73baf8d02d
274 files changed
+83593

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
cmake_minimum_required(VERSION 3.20)
project(slinky-inject LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
add_executable(slinky-inject
main.cpp
tui.cpp
)
if(WIN32)
target_sources(slinky-inject PRIVATE inject_windows.cpp)
target_compile_definitions(slinky-inject PRIVATE _WIN32_WINNT=0x0601)
# A standalone .exe: no MinGW runtime DLLs to copy beside it.
target_link_options(slinky-inject PRIVATE -static -static-libgcc -static-libstdc++)
else()
target_sources(slinky-inject PRIVATE inject_linux.cpp)
target_link_libraries(slinky-inject PRIVATE ${CMAKE_DL_LIBS} pthread)
endif()
# Copy the client library next to the injector when it is built in the same
# tree (the top-level CMakeLists.txt does this via add_subdirectory).
# A separate always-run target: a POST_BUILD step on slinky-inject only ran when
# the injector itself was relinked, so the sibling copy went stale whenever only
# the client changed.
if(TARGET slinky)
add_custom_target(slinky-copy-beside-injector ALL
COMMAND ${CMAKE_COMMAND} -E copy_if_different
$<TARGET_FILE:slinky>
$<TARGET_FILE_DIR:slinky-inject>/$<TARGET_FILE_NAME:slinky>
DEPENDS slinky slinky-inject
VERBATIM)
endif()
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# Build slinky-inject for the host Linux.
set -e
cd "$(dirname "$0")"
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j"$(nproc)"
echo "built: $(pwd)/build/slinky-inject"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# Cross-build slinky-inject.exe with MinGW-w64.
set -e
cd "$(dirname "$0")"
cmake -S . -B build-windows \
-DCMAKE_SYSTEM_NAME=Windows \
-DCMAKE_C_COMPILER=x86_64-w64-mingw32-gcc \
-DCMAKE_CXX_COMPILER=x86_64-w64-mingw32-g++ \
-DCMAKE_BUILD_TYPE=Release
cmake --build build-windows -j"$(nproc)"
echo "built: $(pwd)/build-windows/slinky-inject.exe"
+35
View File
@@ -0,0 +1,35 @@
// Process discovery and library injection, with no opinion about the UI.
#pragma once
#include <functional>
#include <string>
#include <vector>
namespace Injector {
struct Process {
unsigned long pid = 0;
std::string name;
std::string command_line;
};
using LogFn = std::function<void(const std::string &)>;
std::vector<Process> ListProcesses();
// Finds the running Minecraft JVM. On Linux that is a java process whose
// command line names the game; on Windows it is the LWJGL/GLFW window's owner.
bool FindMinecraft(Process &out, std::string &error);
// Finds a process by pid, exact name, or case-insensitive substring of the
// name/command line. Accepts a decimal pid in `target`.
bool FindTarget(const std::string &target, Process &out, std::string &error);
// Loads `library` into `pid` through ptrace+dlopen (Linux) or
// CreateRemoteThread+LoadLibraryW (Windows). Returns false on failure.
bool Inject(unsigned long pid, const std::string &library, const LogFn &log);
// Why injection may fail before it starts, for the error message.
std::string EnvironmentHint();
} // namespace Injector
+527
View File
@@ -0,0 +1,527 @@
// 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 <algorithm>
#include <cctype>
#include <cerrno>
#include <climits>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iterator>
#include <optional>
#include <sstream>
#include <string>
#include <vector>
#include <dirent.h>
#include <dlfcn.h>
#include <elf.h>
#include <fcntl.h>
#include <sys/ptrace.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/uio.h>
#include <sys/user.h>
#include <sys/wait.h>
#include <unistd.h>
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<MappedRegion> read_maps(unsigned long pid) {
std::vector<MappedRegion> 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<uint8_t> 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<uint8_t>((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
}
return {};
}
uint64_t elf_function_value(const std::vector<uint8_t> &bytes, const char *name) {
if (bytes.size() < sizeof(Elf64_Ehdr)) return 0;
const auto *header = reinterpret_cast<const Elf64_Ehdr *>(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<const Elf64_Shdr *>(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 &section = 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<const Elf64_Sym *>(bytes.data() + section.sh_offset);
const char *names = reinterpret_cast<const char *>(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<uintptr_t> resolve_dlopen_from_elf(unsigned long pid, const std::vector<MappedRegion> &regions) {
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<uintptr_t> 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 &region : local_regions) {
if ((uintptr_t)local_dlopen >= region.start && (uintptr_t)local_dlopen < region.end &&
!region.path.empty() && region.path[0] == '/') {
owner = &region;
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 &region : 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<void *>(data), size};
iovec remote{reinterpret_cast<void *>(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<const uint8_t *>(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<void *>(address + done), reinterpret_cast<void *>(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<void *>(call.rsp),
reinterpret_cast<void *>(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<void *>((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/<pid>/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<char> 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<Process> ListProcesses() {
std::vector<Process> 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 <injector>)";
}
return {};
}
} // namespace Injector
+213
View File
@@ -0,0 +1,213 @@
// Windows injection: CreateRemoteThread(LoadLibraryW) into the game's process.
// The client DLL's DllMain starts the client, so loading it is the whole job.
#ifdef _WIN32
#include "inject.hpp"
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <string>
#include <vector>
#include <windows.h>
#include <tlhelp32.h>
namespace Injector {
namespace {
std::string narrow(const std::wstring &text) {
if (text.empty()) return {};
int n = WideCharToMultiByte(CP_UTF8, 0, text.data(), (int)text.size(), nullptr, 0, nullptr, nullptr);
std::string out((size_t)n, '\0');
WideCharToMultiByte(CP_UTF8, 0, text.data(), (int)text.size(), out.data(), n, nullptr, nullptr);
return out;
}
std::wstring widen(const std::string &text) {
if (text.empty()) return {};
int n = MultiByteToWideChar(CP_UTF8, 0, text.data(), (int)text.size(), nullptr, 0);
std::wstring out((size_t)n, L'\0');
MultiByteToWideChar(CP_UTF8, 0, text.data(), (int)text.size(), out.data(), n);
return out;
}
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 process_name(DWORD pid) {
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (!process) return {};
wchar_t path[1024];
DWORD size = (DWORD)(sizeof path / sizeof path[0]);
std::string result;
if (QueryFullProcessImageNameW(process, 0, path, &size)) {
std::wstring full(path, size);
size_t slash = full.find_last_of(L"\\/");
result = narrow(slash == std::wstring::npos ? full : full.substr(slash + 1));
}
CloseHandle(process);
return result;
}
} // namespace
std::vector<Process> ListProcesses() {
std::vector<Process> out;
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == INVALID_HANDLE_VALUE) return out;
PROCESSENTRY32W entry{};
entry.dwSize = sizeof entry;
if (Process32FirstW(snapshot, &entry)) {
do {
out.push_back(Process{entry.th32ProcessID, narrow(entry.szExeFile), {}});
} while (Process32NextW(snapshot, &entry));
}
CloseHandle(snapshot);
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) {
const wchar_t *classes[] = {L"LWJGL", L"GLFW30"};
for (const wchar_t *cls : classes) {
HWND window = FindWindowW(cls, nullptr);
if (!window) continue;
DWORD pid = 0;
GetWindowThreadProcessId(window, &pid);
if (!pid) continue;
out.pid = pid;
out.name = process_name(pid);
return true;
}
// No window class match: fall back to the first javaw.exe/java.exe, which
// is right when the game is still starting or the window class differs.
for (const Process &process : ListProcesses()) {
std::string name = lower(process.name);
if (name == "javaw.exe" || name == "java.exe") {
out = process;
return true;
}
}
error = "Minecraft is not running (no LWJGL/GLFW30 window and no javaw.exe)";
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 = process_name((DWORD)out.pid);
if (out.pid && !out.name.empty()) return true;
error = "no process with pid " + target;
return false;
}
std::string needle = lower(target);
auto all = ListProcesses();
for (const Process &process : all) {
if (lower(process.name) == needle) {
out = process;
return true;
}
}
for (const Process &process : all) {
if (lower(process.name).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::wstring wide_library = widen(library);
DWORD full_size = GetFullPathNameW(wide_library.c_str(), 0, nullptr, nullptr);
if (full_size) {
std::wstring full(full_size, L'\0');
DWORD written = GetFullPathNameW(wide_library.c_str(), full_size, full.data(), nullptr);
if (written && written < full_size) {
full.resize(written);
wide_library = full;
}
}
log("injecting " + narrow(wide_library) + " into pid " + std::to_string(pid));
HANDLE process = OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION |
PROCESS_VM_WRITE | PROCESS_VM_READ,
FALSE, (DWORD)pid);
if (!process) {
DWORD err = GetLastError();
if (err == ERROR_ACCESS_DENIED) log("access denied -- try running the injector as administrator");
else log("OpenProcess failed (" + std::to_string(err) + ")");
return false;
}
SIZE_T bytes = (wide_library.size() + 1) * sizeof(wchar_t);
void *remote = VirtualAllocEx(process, nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!remote) {
log("VirtualAllocEx failed (" + std::to_string(GetLastError()) + ")");
CloseHandle(process);
return false;
}
bool ok = false;
SIZE_T written = 0;
if (!WriteProcessMemory(process, remote, wide_library.c_str(), bytes, &written)) {
log("WriteProcessMemory failed (" + std::to_string(GetLastError()) + ")");
} else {
HMODULE kernel = GetModuleHandleW(L"kernel32.dll");
auto load_library = reinterpret_cast<LPTHREAD_START_ROUTINE>(
GetProcAddress(kernel, "LoadLibraryW"));
if (!load_library) {
log("could not find LoadLibraryW");
} else {
HANDLE thread = CreateRemoteThread(process, nullptr, 0, load_library, remote, 0, nullptr);
if (!thread) {
log("CreateRemoteThread failed (" + std::to_string(GetLastError()) + ")");
} else {
WaitForSingleObject(thread, INFINITE);
DWORD exit_code = 0;
if (GetExitCodeThread(thread, &exit_code)) {
// The HMODULE return is truncated to a DWORD here; only
// zero (failure) is meaningful.
ok = exit_code != 0;
if (!ok) log("LoadLibraryW returned NULL in the target");
} else {
log("GetExitCodeThread failed (" + std::to_string(GetLastError()) + ")");
}
CloseHandle(thread);
}
}
}
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
CloseHandle(process);
return ok;
}
std::string EnvironmentHint() {
return "if injection fails with access denied, run the injector as administrator";
}
} // namespace Injector
#endif // _WIN32
+327
View File
@@ -0,0 +1,327 @@
// slinky-inject: the loader's terminal UI and a cross-platform injector.
//
// slinky-inject find Minecraft, inject libslinky.so /
// slinky.dll from beside the injector
// slinky-inject <pid|name> same, but choose the target
// slinky-inject <pid|name> <library> inject that library
// slinky-inject --lib <library> choose the library only
// slinky-inject --pid <pid> choose the target only
// slinky-inject --list list visible processes
//
// Linux uses ptrace + the target's own dlopen; Windows uses
// CreateRemoteThread(LoadLibraryW). The animation is the one from the Go
// loader (animation/animation.go).
#include "inject.hpp"
#include "tui.hpp"
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cctype>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <iomanip>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#ifdef _WIN32
#include <windows.h>
#else
#include <limits.h>
#include <unistd.h>
#endif
namespace {
struct Options {
bool list = false;
bool help = false;
bool no_anim = false;
bool pid_given = false;
unsigned long pid = 0;
std::string target;
std::string library;
};
void usage(const char *program) {
std::cout <<
"usage: " << program << " [options] [target] [library]\n"
"\n"
" target pid or process name (default: the running Minecraft)\n"
" library path to libslinky.so / slinky.dll (default: beside this program)\n"
"\n"
"options:\n"
" -p, --pid <pid> target a pid\n"
" -n, --name <name> target a process name (substring match)\n"
" -l, --lib <path> library to inject\n"
" --list list processes and exit\n"
" --no-anim plain output, no animation\n"
" -h, --help this text\n";
}
bool parse_options(int argc, char **argv, Options &options, std::string &error) {
std::vector<std::string> positional;
for (int i = 1; i < argc; i++) {
std::string arg = argv[i];
auto value = [&](const char *what) -> bool {
if (i + 1 >= argc) {
error = std::string(what) + " needs a value";
return false;
}
return true;
};
if (arg == "-h" || arg == "--help") {
options.help = true;
} else if (arg == "--list") {
options.list = true;
} else if (arg == "--no-anim") {
options.no_anim = true;
} else if (arg == "-p" || arg == "--pid") {
if (!value("--pid")) return false;
char *end = nullptr;
options.pid = std::strtoul(argv[++i], &end, 10);
options.pid_given = true;
if (!end || *end || !options.pid) {
error = "invalid pid '" + std::string(argv[i]) + "'";
return false;
}
} else if (arg == "-n" || arg == "--name") {
if (!value("--name")) return false;
options.target = argv[++i];
} else if (arg == "-l" || arg == "--lib") {
if (!value("--lib")) return false;
options.library = argv[++i];
} else if (!arg.empty() && arg[0] == '-') {
error = "unknown option '" + arg + "'";
return false;
} else {
positional.push_back(arg);
}
}
// One positional that names a library is a library, not a target.
if (positional.size() == 1 && options.target.empty() && !options.pid_given) {
std::string lower = positional[0];
std::transform(lower.begin(), lower.end(), lower.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
if (lower.size() > 3 && (lower.substr(lower.size() - 3) == ".so" ||
lower.substr(lower.size() - 4) == ".dll")) {
options.library = positional[0];
positional.clear();
}
}
if (positional.size() > 2) {
error = "too many positional arguments";
return false;
}
if (positional.size() >= 1 && options.target.empty() && !options.pid_given) options.target = positional[0];
if (positional.size() >= 2 && options.library.empty()) options.library = positional[1];
return true;
}
std::filesystem::path executable_path() {
#ifdef _WIN32
wchar_t buffer[MAX_PATH];
DWORD n = GetModuleFileNameW(nullptr, buffer, MAX_PATH);
return std::filesystem::path(std::wstring(buffer, n));
#else
char buffer[PATH_MAX];
ssize_t n = readlink("/proc/self/exe", buffer, sizeof buffer - 1);
if (n <= 0) return {};
buffer[n] = 0;
return std::filesystem::path(buffer);
#endif
}
std::string default_library() {
#ifdef _WIN32
const char *names[] = {"slinky.dll", "libslinky.dll"};
const char *build[] = {"build/cmake/slinky.dll", "../build/cmake/slinky.dll"};
#else
const char *names[] = {"libslinky.so", "slinky.so"};
const char *build[] = {"build/linux/libslinky.so", "../build/linux/libslinky.so"};
#endif
std::error_code ec;
std::filesystem::path exe = executable_path();
std::vector<std::filesystem::path> candidates;
if (!exe.empty()) {
for (const char *name : names) candidates.push_back(exe.parent_path() / name);
}
for (const char *name : names) candidates.push_back(std::filesystem::current_path(ec) / name);
for (const char *path : build) candidates.push_back(std::filesystem::current_path(ec) / path);
#ifndef _WIN32
candidates.push_back("/usr/local/lib/libslinky.so");
candidates.push_back("/usr/lib/libslinky.so");
#endif
for (const auto &candidate : candidates) {
if (std::filesystem::exists(candidate, ec)) {
auto canonical = std::filesystem::weakly_canonical(candidate, ec);
return ec ? candidate.string() : canonical.string();
}
}
return {};
}
void sleep_ms(int ms) {
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
}
void render_for(Tui::Screen &screen, int milliseconds, const std::string &title, const std::string &message,
bool error = false) {
auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(milliseconds);
do {
screen.Render(title, message, error);
sleep_ms(16);
} while (std::chrono::steady_clock::now() < end);
}
void print_list(const std::vector<Injector::Process> &processes) {
std::cout << " PID NAME COMMAND\n";
for (const auto &process : processes) {
std::string command = process.command_line;
if (command.size() > 100) command = command.substr(0, 97) + "...";
std::cout << std::setw(6) << process.pid << " " << std::setw(20) << std::left << process.name << " "
<< command << "\n";
}
}
} // namespace
int main(int argc, char **argv) {
Options options;
std::string error;
if (!parse_options(argc, argv, options, error)) {
std::cerr << "slinky-inject: " << error << "\n\n";
usage(argv[0]);
return 1;
}
if (options.help) {
usage(argv[0]);
return 0;
}
if (options.list) {
print_list(Injector::ListProcesses());
return 0;
}
const bool interactive = Tui::EnableAnsi() && Tui::StdoutIsTty() && !options.no_anim;
Tui::Screen screen(interactive);
if (!options.library.empty()) {
std::error_code ec;
if (!std::filesystem::exists(options.library, ec)) {
std::cerr << "slinky-inject: library not found: " << options.library << "\n";
return 1;
}
} else {
options.library = default_library();
if (options.library.empty()) {
if (interactive) {
screen.Render("ERROR",
"Library not found. Put libslinky.so/slinky.dll beside the injector, "
"or pass its path as the second argument.",
true);
sleep_ms(3000);
} else {
std::cerr << "slinky-inject: library not found (pass its path as the second argument)\n";
}
return 1;
}
}
// ---- find the target ----
Injector::Process target;
if (interactive) {
render_for(screen, 450, "FINDING MINECRAFT", "Looking for the game process...");
} else {
std::cout << "Finding Minecraft...\n";
}
bool found = false;
if (options.pid_given) {
target.pid = options.pid;
found = true;
} else if (!options.target.empty()) {
found = Injector::FindTarget(options.target, target, error);
} else {
found = Injector::FindMinecraft(target, error);
}
if (!found) {
std::string hint = Injector::EnvironmentHint();
std::string message = error;
if (!hint.empty()) message += "\n\n" + hint;
if (interactive) {
screen.Render("ERROR", message, true);
sleep_ms(4000);
} else {
std::cerr << "slinky-inject: " << message << "\n";
}
return 1;
}
// ---- inject, with the animation still running ----
std::string last_log = "Injecting into Minecraft...";
std::mutex log_mutex;
std::atomic<bool> done{false};
std::atomic<bool> injected{false};
auto started = std::chrono::steady_clock::now();
std::thread worker([&]() {
injected.store(Injector::Inject(
target.pid, options.library,
[&](const std::string &line) {
std::lock_guard<std::mutex> lock(log_mutex);
last_log = line;
}));
done.store(true);
});
if (interactive) {
for (;;) {
{
std::lock_guard<std::mutex> lock(log_mutex);
screen.Render("INJECTING", last_log);
}
sleep_ms(16);
bool finished = done.load();
auto elapsed = std::chrono::steady_clock::now() - started;
if (finished && elapsed > std::chrono::milliseconds(1200)) break;
}
}
worker.join();
if (!injected.load()) {
std::string hint = Injector::EnvironmentHint();
std::string message = "Injection failed. " + last_log;
if (!hint.empty()) message += "\n\n" + hint;
if (interactive) {
screen.Render("ERROR", message, true);
sleep_ms(5000);
} else {
std::cerr << "slinky-inject: " << message << "\n";
}
return 1;
}
// ---- success countdown, exactly the Go loader's five seconds ----
if (interactive) {
for (int i = 5; i > 0; --i) {
std::string title = "Injected successfully. Loader will close in " + std::to_string(i) + "s.";
render_for(screen, 1000, title, "DONE");
}
} else {
std::cout << "Injected successfully into pid " << target.pid << ".\n";
}
return 0;
}
+193
View File
@@ -0,0 +1,193 @@
#include "tui.hpp"
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <random>
#include <string>
#include <thread>
#ifdef _WIN32
#include <io.h>
#include <windows.h>
#else
#include <unistd.h>
#endif
namespace Tui {
namespace {
// Exact frames from the Go loader. The trailing spaces are part of the art:
// they keep the right edge of the box aligned between frames.
const char *kLogo[4] = {
" _ \n"
"(( ____ \n"
" \\\\________________( ( e\\____o\n"
" ( _____/ \n"
" \\___________________/ \n"
" / \\ \\ / ",
" _ \n"
" (( ____ \n"
" ||________________( ( e\\____o\n"
" ( _____/ \n"
" \\___________________/ \n"
" ) | | ) ",
" _ \n"
" // ____ \n"
" //________________( ( e\\____o\n"
" ( _____/ \n"
" \\___________________/ \n"
" \\ / / \\ ",
" _ \n"
" (( ____ \n"
" ||________________( ( e\\____o\n"
" ( _____/ \n"
" \\___________________/ \n"
" | ) ) | ",
};
const char *kErrorLogo =
"\n"
" ____ \n"
" _________________( ( x\\____o\n"
"// _____/ \n"
"||____________________/ \n"
" | | | | ";
const char *kOrange = "\x1b[38;5;208m";
const char *kRed = "\x1b[91m";
const char *kReset = "\x1b[0m";
std::string make_line() {
std::random_device device;
std::mt19937 rng(device());
std::string line;
line.reserve(25);
for (int i = 0; i < 25; i++) line.push_back(rng() & 1 ? '_' : '-');
return line;
}
const std::string &base_line() {
static const std::string line = make_line();
return line;
}
} // namespace
bool EnableAnsi() {
#ifdef _WIN32
HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE);
DWORD mode = 0;
if (!out || out == INVALID_HANDLE_VALUE || !GetConsoleMode(out, &mode)) return false;
return SetConsoleMode(out, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0;
#else
return true;
#endif
}
bool StdoutIsTty() {
#ifdef _WIN32
return _isatty(_fileno(stdout)) != 0;
#else
return isatty(STDOUT_FILENO) != 0;
#endif
}
std::string Logo(int frame) {
return kLogo[((frame % 4) + 4) % 4];
}
std::string ErrorLogo() { return kErrorLogo; }
// The line is the base pattern shifted one character per frame, exactly as the
// Go loader's init() builds its 30 frames.
std::string SlinkyLine(int frame) {
const std::string &base = base_line();
if (base.empty()) return {};
int shift = ((frame % (int)base.size()) + (int)base.size()) % (int)base.size();
return base.substr(shift) + base.substr(0, shift);
}
Screen::Screen(bool animate) : animate_(animate && StdoutIsTty()) {
if (animate_) {
std::fputs("\x1b[?1049h\x1b[?25l", stdout);
std::fflush(stdout);
}
}
Screen::~Screen() {
if (animate_) {
std::fputs("\x1b[?25h\x1b[?1049l", stdout);
std::fflush(stdout);
}
}
void Screen::Render(const std::string &title, const std::string &message, bool error) {
if (!animate_) {
// Non-interactive output: one line per state change is enough.
std::fputs(error ? "ERROR: " : "", stdout);
std::fputs(title.c_str(), stdout);
if (!message.empty()) {
std::fputs(" - ", stdout);
std::fputs(message.c_str(), stdout);
}
std::fputc('\n', stdout);
std::fflush(stdout);
return;
}
// Build the whole frame first. Clearing the screen before every 16 ms redraw made the
// console blank between frames (the flicker); instead the cursor goes home and each line is
// overwritten in one write, with erase-to-end-of-line cleaning up shorter content.
const int frame_index = Frame();
std::string body;
body.reserve(1024);
if (error) {
body += kRed;
body += kErrorLogo;
} else {
body += kLogo[frame_index % 4];
}
body += '\n';
body += SlinkyLine(frame_index);
body += "\n\n> ";
body += (error ? kRed : kOrange);
body += title;
body += kReset;
body += "\n\n";
body += message;
body += '\n';
// Nothing changed since the last call: leave the terminal alone.
if (body == last_body_) return;
last_body_ = body;
std::string out;
out.reserve(body.size() + 64);
// Synchronized output: supporting terminals swap the completed frame atomically.
out += "\x1b[?2026h";
out += "\x1b[H";
for (char c : body) {
if (c == '\n') {
out += "\x1b[K\n";
} else {
out += c;
}
}
out += "\x1b[K\x1b[J"; // clear the rest of the last line and anything below the frame
out += "\x1b[?2026l";
std::fwrite(out.data(), 1, out.size(), stdout);
std::fflush(stdout);
}
int Screen::Frame() const {
auto elapsed = std::chrono::steady_clock::now() - start_;
return (int)(std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count() / kFrameMs);
}
} // namespace Tui
+40
View File
@@ -0,0 +1,40 @@
// The loader's terminal UI: the four-frame Slinky logo and the sliding
// underscore line, ported from the Go loader (animation/animation.go) so the
// injector looks and moves exactly like it.
#pragma once
#include <chrono>
#include <string>
namespace Tui {
bool EnableAnsi(); // Windows: turn on VT processing; Linux: no-op
bool StdoutIsTty();
std::string Logo(int frame);
std::string ErrorLogo();
std::string SlinkyLine(int frame);
class Screen {
public:
explicit Screen(bool animate);
~Screen();
Screen(const Screen&) = delete;
Screen& operator=(const Screen&) = delete;
void Render(const std::string& title, const std::string& message, bool error = false);
private:
// The animation advances with time, not with redraws: the caller redraws every ~16 ms so status
// messages show up at once, which made a frame-per-redraw animation run at 60 fps.
static constexpr int kFrameMs = 100;
int Frame() const;
bool animate_;
std::chrono::steady_clock::time_point start_ = std::chrono::steady_clock::now();
// Last rendered content: the caller redraws at 60 Hz, but the frame only changes every
// kFrameMs (or on a status change), so identical redraws are skipped.
std::string last_body_;
};
} // namespace Tui