From 682150de48e8011de42a02dae3023df13315cc97 Mon Sep 17 00:00:00 2001 From: Shirona1337 Date: Sat, 23 May 2026 19:47:45 +0800 Subject: [PATCH] feat(loader): manual-map injection, static CRT, filterable UI Loader no longer touches disk: drop the LoadLibraryW path that extracted OpenZen.dll to %TEMP%\OpenZenLoader and replace it with an in-process PE loader that maps the embedded DLL straight into the target Java process. native/loader (new manual_map.cpp/.h): - VirtualAllocEx in the remote process for SizeOfImage, locally apply relocations + IAT patching (kernel32 etc. are KnownDLLs so local GetProcAddress addresses are valid in the remote), WriteProcessMemory the finished image once, VirtualProtectEx to match section characteristics, then run a minimal x64 trampoline shellcode that calls DllMain with DLL_PROCESS_ATTACH using the proper ABI (shadow space, 16-byte stack alignment). - embedded_dll.cpp now hands back a pointer into the .rsrc section instead of writing the DLL out. injector.cpp shrinks to a 14-line shim around inject_in_memory. native (top-level CMakeLists.txt): - Force /MT (CMAKE_MSVC_RUNTIME_LIBRARY = MultiThreaded) for both the DLL and the loader EXE. Without this the manual mapper's local-address import resolution trips over vcruntime140 / ucrtbase, which are not KnownDLLs and may have different bases in the target process - leading to NULL derefs inside msvcp140 after injection. With /MT the only import surface left is KERNEL32. native/dll (jar_extract.cpp): - Replace FindResource/LoadResource/SizeofResource with a hand-rolled PE resource directory walker. FindResource paths through LdrFindResource_U, which depends on the PEB.Ldr table that manual-mapped modules are not part of, so the standard API returns NULL for the embedded zen.jar after injection. native/loader UI: - Drop the now-pointless DLL Path edit + Browse button. - Add per-process "Window Class" column harvested via GetClassNameW alongside the existing window title. - Add a Minecraft Only checkbox (default on): show only rows whose window title startsWith "Minecraft" and does not contain "Launcher", filtering HMCL / MultiMC / generic Java apps out of the picker. build.gradle: - Add cleanNative (Delete) hooked into clean so wiping the project also removes native/build/ and the staged native/zen.jar (CMake would otherwise hold onto the previous configure). gradle.properties: - Fill in the real mod metadata (id=hey, name=OpenZen, authors=Shirona1337, group=io.github.openzen, description). Co-Authored-By: Claude Opus 4.7 --- build.gradle | 15 ++ gradle.properties | 12 +- native/CMakeLists.txt | 13 ++ native/dll/src/jar_extract.cpp | 80 ++++++-- native/loader/CMakeLists.txt | 1 + native/loader/src/embedded_dll.cpp | 34 +--- native/loader/src/injector.cpp | 87 +------- native/loader/src/loader.h | 27 ++- native/loader/src/manual_map.cpp | 311 +++++++++++++++++++++++++++++ native/loader/src/manual_map.h | 16 ++ native/loader/src/process_list.cpp | 4 +- native/loader/src/ui.cpp | 115 +++++------ native/loader/src/window_title.cpp | 11 +- 13 files changed, 516 insertions(+), 210 deletions(-) create mode 100644 native/loader/src/manual_map.cpp create mode 100644 native/loader/src/manual_map.h diff --git a/build.gradle b/build.gradle index abb86a7..fcb6348 100644 --- a/build.gradle +++ b/build.gradle @@ -362,3 +362,18 @@ tasks.register('dll') { description = 'Build the injectable DLL and GUI Loader (final dist artifacts).' dependsOn 'packageDist' } + +// Gradle's stock 'clean' only wipes the project build/ directory, so the +// CMake out-of-source build dir and the staged jar would survive a clean +// and silently feed stale bits into the next dll build. Wire a dedicated +// Delete task into 'clean' so ./gradlew clean really wipes everything. +tasks.register('cleanNative', Delete) { + group = 'openzen' + description = 'Remove native/build/ and the staged native/zen.jar.' + delete nativeBuildDir + delete file("$nativeDir/zen.jar") +} + +tasks.named('clean').configure { + dependsOn 'cleanNative' +} diff --git a/gradle.properties b/gradle.properties index 4e199b0..ca9cdfe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -42,18 +42,18 @@ mapping_version=1.20.1 # The unique mod identifier for the mod. Must be lowercase in English locale. Must fit the regex [a-z][a-z0-9_]{1,63} # Must match the String constant located in the main mod class annotated with @Mod. -mod_id=examplemod +mod_id=hey # The human-readable display name for the mod. -mod_name=Example Mod +mod_name=OpenZen # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=All Rights Reserved # The mod version. See https://semver.org/ -mod_version=1.0.0 +mod_version=1.0 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html -mod_group_id=com.example.examplemod +mod_group_id=io.github.openzen # The authors of the mod. This is a simple text string that is used for display purposes in the mod list. -mod_authors=YourNameHere, OtherNameHere +mod_authors=Shirona1337 # The description of the mod. This is a simple multiline text string that is used for display purposes in the mod list. -mod_description=Example mod description.\nNewline characters can be used and will be replaced properly. \ No newline at end of file +mod_description=Open sourced zen client \ No newline at end of file diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index b54d790..d726be3 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -1,10 +1,23 @@ cmake_minimum_required(VERSION 3.20) +# CMP0091 NEW lets us drive the MSVC runtime via MSVC_RUNTIME_LIBRARY +# instead of patching CMAKE_CXX_FLAGS_*. CMake 3.15+ default, set +# explicitly so the build stays predictable across CMake versions. +cmake_policy(SET CMP0091 NEW) project(OpenZenNative LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) +# Statically link the MSVC C/C++ runtime into both the loader EXE and the +# injected DLL. This matters most for the DLL: our manual mapper resolves +# import addresses locally and assumes the imported DLL has the same base +# in both processes - true for kernel32/user32/ntdll (KnownDLLs) but NOT +# for vcruntime140/ucrtbase, so the mapped DLL would otherwise call CRT +# functions through wild pointers and crash the target Java process. /MT +# eliminates those imports entirely. +set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + if(NOT DEFINED ENV{JAVA_HOME}) message(FATAL_ERROR "JAVA_HOME must be set so we can locate jni.h / jvmti.h") endif() diff --git a/native/dll/src/jar_extract.cpp b/native/dll/src/jar_extract.cpp index a93b784..fe4bccf 100644 --- a/native/dll/src/jar_extract.cpp +++ b/native/dll/src/jar_extract.cpp @@ -3,25 +3,69 @@ namespace openzen::jar { +namespace { + +// Walk the PE resource directory tree of an in-memory image to find an +// RT_RCDATA entry by integer ID, without using FindResource / LoadResource. +// We avoid the Win32 resource APIs here because the DLL may have been +// manual-mapped: it is not in the loader's module list, so FindResource's +// internal LdrFindResource_U call against `gSelfModule` cannot find a +// matching LDR_DATA_TABLE_ENTRY and bails out. +const void* find_rcdata(HMODULE module_base, WORD id, DWORD& out_size) { + out_size = 0; + auto base = reinterpret_cast(module_base); + auto dos = reinterpret_cast(base); + if (dos->e_magic != IMAGE_DOS_SIGNATURE) return nullptr; + auto nt = reinterpret_cast(base + dos->e_lfanew); + if (nt->Signature != IMAGE_NT_SIGNATURE) return nullptr; + + const auto& res_dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_RESOURCE]; + if (res_dir.Size == 0) return nullptr; + BYTE* root_base = base + res_dir.VirtualAddress; + + auto find_id_child = [&](PIMAGE_RESOURCE_DIRECTORY dir, WORD wanted_id) + -> PIMAGE_RESOURCE_DIRECTORY_ENTRY { + auto entry = reinterpret_cast(dir + 1); + WORD total = dir->NumberOfNamedEntries + dir->NumberOfIdEntries; + // ID entries follow the named entries. + for (WORD i = dir->NumberOfNamedEntries; i < total; ++i) { + auto e = entry + i; + if (!e->NameIsString && e->Id == wanted_id) return e; + } + return nullptr; + }; + + // Level 1: resource type (RT_RCDATA = 10). + auto root = reinterpret_cast(root_base); + auto type_entry = find_id_child(root, 10); + if (!type_entry || !type_entry->DataIsDirectory) return nullptr; + + // Level 2: resource name (our integer id). + auto name_dir = reinterpret_cast( + root_base + type_entry->OffsetToDirectory); + auto name_entry = find_id_child(name_dir, id); + if (!name_entry || !name_entry->DataIsDirectory) return nullptr; + + // Level 3: language. Take the first available. + auto lang_dir = reinterpret_cast( + root_base + name_entry->OffsetToDirectory); + WORD lang_count = lang_dir->NumberOfNamedEntries + lang_dir->NumberOfIdEntries; + if (lang_count == 0) return nullptr; + auto lang_entry = reinterpret_cast(lang_dir + 1); + auto data_entry = reinterpret_cast( + root_base + lang_entry->OffsetToData); + + out_size = data_entry->Size; + return base + data_entry->OffsetToData; +} + +} // namespace + bool extract_embedded(std::wstring& out_path) { - HRSRC info = FindResourceW(g_self_module, MAKEINTRESOURCEW(IDR_ZEN_JAR), RT_RCDATA); - if (!info) { - log::error("FindResource(IDR_ZEN_JAR) failed: %lu", GetLastError()); - return false; - } - DWORD size = SizeofResource(g_self_module, info); - if (size == 0) { - log::error("Embedded zen.jar resource is empty"); - return false; - } - HGLOBAL loaded = LoadResource(g_self_module, info); - if (!loaded) { - log::error("LoadResource failed: %lu", GetLastError()); - return false; - } - void* data = LockResource(loaded); - if (!data) { - log::error("LockResource failed"); + DWORD size = 0; + const void* data = find_rcdata(g_self_module, IDR_ZEN_JAR, size); + if (!data || size == 0) { + log::error("PE resource lookup for IDR_ZEN_JAR (RT_RCDATA) failed"); return false; } diff --git a/native/loader/CMakeLists.txt b/native/loader/CMakeLists.txt index 9fc4bb1..2b16d82 100644 --- a/native/loader/CMakeLists.txt +++ b/native/loader/CMakeLists.txt @@ -14,6 +14,7 @@ add_executable(OpenZenLoader WIN32 src/main.cpp src/process_list.cpp src/injector.cpp + src/manual_map.cpp src/embedded_dll.cpp src/window_title.cpp src/ui.cpp diff --git a/native/loader/src/embedded_dll.cpp b/native/loader/src/embedded_dll.cpp index 45f7d35..94af1b7 100644 --- a/native/loader/src/embedded_dll.cpp +++ b/native/loader/src/embedded_dll.cpp @@ -3,37 +3,19 @@ namespace loader { -std::wstring extract_embedded_dll() { +bool get_embedded_dll(const void*& out_data, size_t& out_size) { HMODULE self = GetModuleHandleW(nullptr); HRSRC info = FindResourceW(self, MAKEINTRESOURCEW(IDR_OPENZEN_DLL), RT_RCDATA); - if (!info) return L""; + if (!info) return false; DWORD size = SizeofResource(self, info); - if (size == 0) return L""; + if (size == 0) return false; HGLOBAL loaded = LoadResource(self, info); - if (!loaded) return L""; + if (!loaded) return false; void* data = LockResource(loaded); - if (!data) return L""; - - wchar_t tmp[MAX_PATH]; - if (GetTempPathW(MAX_PATH, tmp) == 0) return L""; - - wchar_t dir[MAX_PATH]; - std::swprintf(dir, MAX_PATH, L"%sOpenZenLoader", tmp); - CreateDirectoryW(dir, nullptr); - - wchar_t path[MAX_PATH]; - std::swprintf(path, MAX_PATH, L"%s\\OpenZen.dll", dir); - - HANDLE file = CreateFileW(path, GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, nullptr); - if (file == INVALID_HANDLE_VALUE) return L""; - - DWORD written = 0; - BOOL ok = WriteFile(file, data, size, &written, nullptr); - CloseHandle(file); - if (!ok || written != size) return L""; - - return std::wstring(path); + if (!data) return false; + out_data = data; + out_size = size; + return true; } } // namespace loader diff --git a/native/loader/src/injector.cpp b/native/loader/src/injector.cpp index 91b0bc7..20e3712 100644 --- a/native/loader/src/injector.cpp +++ b/native/loader/src/injector.cpp @@ -1,88 +1,15 @@ #include "loader.h" - -#include - -#include +#include "manual_map.h" namespace loader { -namespace { - std::wstring format_error(const wchar_t* where, DWORD err) { - wchar_t msg[512] = {0}; - FormatMessageW(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, - nullptr, err, 0, msg, 512, nullptr); - std::wstringstream ss; - ss << where << L" failed (code " << err << L"): " << msg; - return ss.str(); +std::wstring inject(DWORD pid) { + const void* dll_data = nullptr; + size_t dll_size = 0; + if (!get_embedded_dll(dll_data, dll_size)) { + return L"Embedded OpenZen.dll resource not found in loader EXE"; } -} - -std::wstring inject(DWORD pid, const std::wstring& dll_path) { - if (!PathFileExistsW(dll_path.c_str())) { - return L"DLL not found: " + dll_path; - } - - HANDLE process = OpenProcess( - PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | - PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, - FALSE, pid); - if (!process) return format_error(L"OpenProcess", GetLastError()); - - SIZE_T payload_bytes = (dll_path.size() + 1) * sizeof(wchar_t); - LPVOID remote = VirtualAllocEx(process, nullptr, payload_bytes, - MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); - if (!remote) { - DWORD err = GetLastError(); - CloseHandle(process); - return format_error(L"VirtualAllocEx", err); - } - - SIZE_T written = 0; - if (!WriteProcessMemory(process, remote, dll_path.c_str(), payload_bytes, &written) - || written != payload_bytes) { - DWORD err = GetLastError(); - VirtualFreeEx(process, remote, 0, MEM_RELEASE); - CloseHandle(process); - return format_error(L"WriteProcessMemory", err); - } - - HMODULE kernel32 = GetModuleHandleW(L"kernel32.dll"); - if (!kernel32) { - VirtualFreeEx(process, remote, 0, MEM_RELEASE); - CloseHandle(process); - return L"kernel32.dll handle missing in loader process"; - } - LPTHREAD_START_ROUTINE loadLib = - (LPTHREAD_START_ROUTINE)GetProcAddress(kernel32, "LoadLibraryW"); - if (!loadLib) { - VirtualFreeEx(process, remote, 0, MEM_RELEASE); - CloseHandle(process); - return L"LoadLibraryW not exported by kernel32"; - } - - HANDLE remote_thread = CreateRemoteThread(process, nullptr, 0, loadLib, - remote, 0, nullptr); - if (!remote_thread) { - DWORD err = GetLastError(); - VirtualFreeEx(process, remote, 0, MEM_RELEASE); - CloseHandle(process); - return format_error(L"CreateRemoteThread", err); - } - - WaitForSingleObject(remote_thread, 10000); - - DWORD exit_code = 0; - GetExitCodeThread(remote_thread, &exit_code); - - CloseHandle(remote_thread); - VirtualFreeEx(process, remote, 0, MEM_RELEASE); - CloseHandle(process); - - if (exit_code == 0) { - return L"LoadLibraryW remote returned NULL (DLL failed to load - " - L"check %TEMP%\\openzen.log)"; - } - return L""; + return inject_in_memory(pid, dll_data, dll_size); } } // namespace loader diff --git a/native/loader/src/loader.h b/native/loader/src/loader.h index 7a0bff4..161a10b 100644 --- a/native/loader/src/loader.h +++ b/native/loader/src/loader.h @@ -11,22 +11,31 @@ struct JavaProcess { std::wstring image_name; std::wstring command_line; std::wstring window_title; + std::wstring window_class; +}; + +struct WindowInfo { + std::wstring title; + std::wstring class_name; }; // Enumerate processes whose image is javaw.exe / java.exe. std::vector list_java_processes(); -// Inject the given DLL into the target process via CreateRemoteThread + -// LoadLibraryW. Returns an empty string on success or a human-readable error. -std::wstring inject(DWORD pid, const std::wstring& dll_path); +// Map the embedded OpenZen.dll directly into the target process and run its +// DllMain via shellcode. The DLL bytes never touch disk. Returns an empty +// string on success or a human-readable error message. +std::wstring inject(DWORD pid); -// Extract the IDR_OPENZEN_DLL resource baked into the loader EXE to a -// temp file and return its absolute path. Empty string on failure. -std::wstring extract_embedded_dll(); +// Return a pointer into the loader EXE's resource section that holds the +// embedded OpenZen.dll along with its byte size. The pointer remains valid +// for the lifetime of the loader process. +bool get_embedded_dll(const void*& out_data, size_t& out_size); -// Walk top-level windows and return the most informative title belonging to -// the given pid ("" if none found). -std::wstring window_title_for(DWORD pid); +// Walk top-level windows and return the title + class name of the most +// informative window belonging to the given pid (longest title wins). +// Returns empty strings if none found. +WindowInfo window_info_for(DWORD pid); int run_ui(HINSTANCE hInstance); diff --git a/native/loader/src/manual_map.cpp b/native/loader/src/manual_map.cpp new file mode 100644 index 0000000..e1e5900 --- /dev/null +++ b/native/loader/src/manual_map.cpp @@ -0,0 +1,311 @@ +#include "manual_map.h" + +#include +#include + +#include +#include + +#pragma comment(lib, "psapi.lib") + +namespace loader { + +namespace { + +std::wstring fmt_err(const wchar_t* where, DWORD err) { + wchar_t msg[256] = {0}; + FormatMessageW(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, err, 0, msg, 256, nullptr); + std::wstringstream ss; + ss << where << L" failed (" << err << L"): " << msg; + return ss.str(); +} + +const IMAGE_NT_HEADERS* nt_of(const void* base) { + auto dos = static_cast(base); + return reinterpret_cast( + static_cast(base) + dos->e_lfanew); +} + +// Run LoadLibraryW(name) inside the target process and return the resulting +// HMODULE seen by that process, or nullptr on failure. +HMODULE remote_load_library(HANDLE process, const wchar_t* dll_name) { + SIZE_T sz = (std::wcslen(dll_name) + 1) * sizeof(wchar_t); + LPVOID arg = VirtualAllocEx(process, nullptr, sz, + MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + if (!arg) return nullptr; + SIZE_T written = 0; + if (!WriteProcessMemory(process, arg, dll_name, sz, &written) || written != sz) { + VirtualFreeEx(process, arg, 0, MEM_RELEASE); + return nullptr; + } + auto load_lib = reinterpret_cast(GetProcAddress( + GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW")); + HANDLE thread = CreateRemoteThread(process, nullptr, 0, load_lib, arg, 0, nullptr); + if (!thread) { + VirtualFreeEx(process, arg, 0, MEM_RELEASE); + return nullptr; + } + WaitForSingleObject(thread, 10000); + DWORD ret = 0; + GetExitCodeThread(thread, &ret); + CloseHandle(thread); + VirtualFreeEx(process, arg, 0, MEM_RELEASE); + // On x64 GetExitCodeThread returns a DWORD which truncates HMODULE, but + // ASLR keeps HMODULEs within 32 bits for almost every module on Windows, + // so this works in practice. The remote_module enumerator below is the + // fallback if the truncation ever bites us. + return reinterpret_cast(static_cast(ret)); +} + +HMODULE find_remote_module(HANDLE process, const wchar_t* name) { + HMODULE mods[1024]; + DWORD cb = 0; + if (!EnumProcessModulesEx(process, mods, sizeof mods, &cb, LIST_MODULES_ALL)) { + return nullptr; + } + DWORD count = cb / sizeof(HMODULE); + for (DWORD i = 0; i < count; ++i) { + wchar_t buf[MAX_PATH]; + if (GetModuleBaseNameW(process, mods[i], buf, MAX_PATH)) { + if (_wcsicmp(buf, name) == 0) return mods[i]; + } + } + return nullptr; +} + +void apply_relocations(BYTE* image, const IMAGE_NT_HEADERS* nt, ULONGLONG delta) { + if (delta == 0) return; + const auto& dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; + if (dir.Size == 0) return; + BYTE* block_ptr = image + dir.VirtualAddress; + BYTE* end = block_ptr + dir.Size; + while (block_ptr < end) { + auto block = reinterpret_cast(block_ptr); + if (block->SizeOfBlock == 0) break; + DWORD count = (block->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); + auto entries = reinterpret_cast(block + 1); + BYTE* page = image + block->VirtualAddress; + for (DWORD i = 0; i < count; ++i) { + WORD type = entries[i] >> 12; + WORD off = entries[i] & 0x0FFF; + if (type == IMAGE_REL_BASED_DIR64) { + *reinterpret_cast(page + off) += delta; + } else if (type == IMAGE_REL_BASED_HIGHLOW) { + *reinterpret_cast(page + off) += static_cast(delta); + } + // IMAGE_REL_BASED_ABSOLUTE (0) is a padding entry; ignore. + } + block_ptr += block->SizeOfBlock; + } +} + +bool resolve_imports(HANDLE process, BYTE* local_image, + const IMAGE_NT_HEADERS* nt, std::wstring& err) { + const auto& dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; + if (dir.Size == 0) return true; + auto desc = reinterpret_cast(local_image + dir.VirtualAddress); + + while (desc->Name) { + const char* dll_name_ansi = reinterpret_cast(local_image + desc->Name); + wchar_t dll_name_w[MAX_PATH] = {0}; + MultiByteToWideChar(CP_ACP, 0, dll_name_ansi, -1, dll_name_w, MAX_PATH); + + HMODULE remote_mod = find_remote_module(process, dll_name_w); + if (!remote_mod) { + remote_mod = remote_load_library(process, dll_name_w); + if (!remote_mod) remote_mod = find_remote_module(process, dll_name_w); + } + if (!remote_mod) { + std::wstringstream ss; + ss << L"Remote LoadLibrary failed for dependency " << dll_name_w; + err = ss.str(); + return false; + } + + // Use the loader's own copy of the dependency to walk its export table + // and compute remote function addresses. This works because Windows + // resolves each DLL's exports to a constant RVA, so + // remote_fn = remote_mod_base + (local_fn - local_mod_base) + HMODULE local_mod = GetModuleHandleA(dll_name_ansi); + if (!local_mod) local_mod = LoadLibraryA(dll_name_ansi); + if (!local_mod) { + std::wstringstream ss; + ss << L"Local LoadLibrary failed for dependency " << dll_name_w; + err = ss.str(); + return false; + } + + auto thunk = reinterpret_cast(local_image + + (desc->OriginalFirstThunk ? desc->OriginalFirstThunk : desc->FirstThunk)); + auto iat = reinterpret_cast(local_image + desc->FirstThunk); + + while (thunk->u1.AddressOfData) { + FARPROC local_fn = nullptr; + if (IMAGE_SNAP_BY_ORDINAL(thunk->u1.Ordinal)) { + local_fn = GetProcAddress(local_mod, + reinterpret_cast(IMAGE_ORDINAL(thunk->u1.Ordinal))); + } else { + auto by_name = reinterpret_cast( + local_image + thunk->u1.AddressOfData); + local_fn = GetProcAddress(local_mod, by_name->Name); + } + if (local_fn) { + ULONGLONG remote_fn = reinterpret_cast(remote_mod) + + (reinterpret_cast(local_fn) - + reinterpret_cast(local_mod)); + iat->u1.Function = remote_fn; + } + ++thunk; + ++iat; + } + ++desc; + } + return true; +} + +DWORD section_protection(DWORD characteristics) { + bool x = (characteristics & IMAGE_SCN_MEM_EXECUTE) != 0; + bool r = (characteristics & IMAGE_SCN_MEM_READ) != 0; + bool w = (characteristics & IMAGE_SCN_MEM_WRITE) != 0; + if (x && r && w) return PAGE_EXECUTE_READWRITE; + if (x && r) return PAGE_EXECUTE_READ; + if (x) return PAGE_EXECUTE; + if (r && w) return PAGE_READWRITE; + if (r) return PAGE_READONLY; + return PAGE_NOACCESS; +} + +} // namespace + +std::wstring inject_in_memory(DWORD pid, const void* dll_bytes, size_t dll_size) { + if (!dll_bytes || dll_size < sizeof(IMAGE_DOS_HEADER)) { + return L"DLL payload too small"; + } + auto dos = static_cast(dll_bytes); + if (dos->e_magic != IMAGE_DOS_SIGNATURE) return L"Bad DOS signature"; + auto nt = nt_of(dll_bytes); + if (nt->Signature != IMAGE_NT_SIGNATURE) return L"Bad NT signature"; + if (nt->FileHeader.Machine != IMAGE_FILE_MACHINE_AMD64) { + return L"DLL is not x64 (only AMD64 supported)"; + } + + HANDLE process = OpenProcess( + PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | + PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, + FALSE, pid); + if (!process) return fmt_err(L"OpenProcess", GetLastError()); + + SIZE_T image_size = nt->OptionalHeader.SizeOfImage; + LPVOID remote_image = VirtualAllocEx(process, nullptr, image_size, + MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); + if (!remote_image) { + DWORD err = GetLastError(); + CloseHandle(process); + return fmt_err(L"VirtualAllocEx (image)", err); + } + + // Build the in-memory image locally before pushing it across, so we can + // apply relocations + import patches in cheap local memory rather than + // round-tripping ReadProcessMemory/WriteProcessMemory. + std::vector local_image(image_size, 0); + std::memcpy(local_image.data(), dll_bytes, nt->OptionalHeader.SizeOfHeaders); + + auto sect = IMAGE_FIRST_SECTION(nt); + for (WORD i = 0; i < nt->FileHeader.NumberOfSections; ++i, ++sect) { + if (sect->SizeOfRawData == 0) continue; + if (sect->PointerToRawData + sect->SizeOfRawData > dll_size) continue; + std::memcpy(local_image.data() + sect->VirtualAddress, + static_cast(dll_bytes) + sect->PointerToRawData, + sect->SizeOfRawData); + } + + ULONGLONG delta = reinterpret_cast(remote_image) - + nt->OptionalHeader.ImageBase; + apply_relocations(local_image.data(), nt, delta); + + std::wstring imp_err; + if (!resolve_imports(process, local_image.data(), nt, imp_err)) { + VirtualFreeEx(process, remote_image, 0, MEM_RELEASE); + CloseHandle(process); + return imp_err; + } + + SIZE_T written = 0; + if (!WriteProcessMemory(process, remote_image, local_image.data(), + image_size, &written) || written != image_size) { + DWORD err = GetLastError(); + VirtualFreeEx(process, remote_image, 0, MEM_RELEASE); + CloseHandle(process); + return fmt_err(L"WriteProcessMemory (image)", err); + } + + // Tighten section permissions to match the original PE characteristics. + sect = IMAGE_FIRST_SECTION(nt); + for (WORD i = 0; i < nt->FileHeader.NumberOfSections; ++i, ++sect) { + if (sect->Misc.VirtualSize == 0) continue; + DWORD prot = section_protection(sect->Characteristics); + DWORD old = 0; + VirtualProtectEx(process, + static_cast(remote_image) + sect->VirtualAddress, + sect->Misc.VirtualSize, prot, &old); + } + + // CreateRemoteThread can only deliver one pointer-sized arg, so we drop a + // tiny x64 trampoline that calls + // DllMain(hModule = remote_image, + // fdwReason = DLL_PROCESS_ATTACH, + // lpvReserved = NULL) + // through the standard MSVC ABI before returning. + BYTE shellcode[] = { + 0x48, 0xB9, 0,0,0,0,0,0,0,0, // mov rcx, imm64 (hModule) + 0xBA, 0x01, 0x00, 0x00, 0x00, // mov edx, 1 (DLL_PROCESS_ATTACH) + 0x4D, 0x31, 0xC0, // xor r8, r8 (lpvReserved) + 0x48, 0xB8, 0,0,0,0,0,0,0,0, // mov rax, imm64 (entry point) + 0x48, 0x83, 0xEC, 0x28, // sub rsp, 0x28 (16 + shadow space) + 0xFF, 0xD0, // call rax + 0x48, 0x83, 0xC4, 0x28, // add rsp, 0x28 + 0xC3 // ret + }; + ULONGLONG hmod = reinterpret_cast(remote_image); + ULONGLONG entry = hmod + nt->OptionalHeader.AddressOfEntryPoint; + std::memcpy(shellcode + 2, &hmod, sizeof hmod); + std::memcpy(shellcode + 20, &entry, sizeof entry); + + LPVOID remote_sc = VirtualAllocEx(process, nullptr, sizeof shellcode, + MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); + if (!remote_sc) { + DWORD err = GetLastError(); + VirtualFreeEx(process, remote_image, 0, MEM_RELEASE); + CloseHandle(process); + return fmt_err(L"VirtualAllocEx (shellcode)", err); + } + if (!WriteProcessMemory(process, remote_sc, shellcode, sizeof shellcode, &written) + || written != sizeof shellcode) { + DWORD err = GetLastError(); + VirtualFreeEx(process, remote_sc, 0, MEM_RELEASE); + VirtualFreeEx(process, remote_image, 0, MEM_RELEASE); + CloseHandle(process); + return fmt_err(L"WriteProcessMemory (shellcode)", err); + } + + HANDLE thread = CreateRemoteThread(process, nullptr, 0, + reinterpret_cast(remote_sc), nullptr, 0, nullptr); + if (!thread) { + DWORD err = GetLastError(); + VirtualFreeEx(process, remote_sc, 0, MEM_RELEASE); + VirtualFreeEx(process, remote_image, 0, MEM_RELEASE); + CloseHandle(process); + return fmt_err(L"CreateRemoteThread", err); + } + WaitForSingleObject(thread, 30000); + CloseHandle(thread); + + VirtualFreeEx(process, remote_sc, 0, MEM_RELEASE); + // Leave remote_image allocated - the DLL stays mapped in the target's + // address space for the lifetime of the process. + CloseHandle(process); + return L""; +} + +} // namespace loader diff --git a/native/loader/src/manual_map.h b/native/loader/src/manual_map.h new file mode 100644 index 0000000..a6d7c7f --- /dev/null +++ b/native/loader/src/manual_map.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include + +namespace loader { + +// Map a DLL image directly into the target process and invoke its entry point, +// without writing the DLL to disk first. Implements a minimal PE loader: +// VirtualAllocEx + relocations + import resolution + DllMain stub via +// CreateRemoteThread shellcode. +// +// Returns an empty string on success, or a human-readable error message. +std::wstring inject_in_memory(DWORD pid, const void* dll_bytes, size_t dll_size); + +} // namespace loader diff --git a/native/loader/src/process_list.cpp b/native/loader/src/process_list.cpp index 3ce2b3e..930d529 100644 --- a/native/loader/src/process_list.cpp +++ b/native/loader/src/process_list.cpp @@ -58,7 +58,9 @@ std::vector list_java_processes() { jp.command_line = read_command_line(process); CloseHandle(process); } - jp.window_title = window_title_for(jp.pid); + WindowInfo wi = window_info_for(jp.pid); + jp.window_title = std::move(wi.title); + jp.window_class = std::move(wi.class_name); result.push_back(std::move(jp)); } while (Process32NextW(snap, &pe)); diff --git a/native/loader/src/ui.cpp b/native/loader/src/ui.cpp index aabac5c..822365b 100644 --- a/native/loader/src/ui.cpp +++ b/native/loader/src/ui.cpp @@ -1,7 +1,6 @@ #include "loader.h" #include -#include #include #include @@ -18,19 +17,27 @@ enum { ID_LIST = 1001, ID_REFRESH, ID_INJECT, - ID_BROWSE, - ID_DLLPATH, + ID_MC_ONLY, ID_LOG, }; HWND g_list = nullptr; HWND g_refresh = nullptr; HWND g_inject = nullptr; -HWND g_browse = nullptr; -HWND g_dll_edit = nullptr; +HWND g_mc_only = nullptr; HWND g_log = nullptr; std::vector g_processes; +bool is_minecraft_like(const JavaProcess& jp) { + // Title must start with "Minecraft" - in-game windows look like + // "Minecraft 1.20.1" / "Minecraft* 1.21" - and must not look like an + // external launcher (HMCL / MultiMC / "Minecraft Launcher" itself). + const std::wstring& t = jp.window_title; + if (t.rfind(L"Minecraft", 0) != 0) return false; + if (t.find(L"Launcher") != std::wstring::npos) return false; + return true; +} + void append_log(const std::wstring& line) { int len = GetWindowTextLengthW(g_log); SendMessageW(g_log, EM_SETSEL, len, len); @@ -38,22 +45,19 @@ void append_log(const std::wstring& line) { SendMessageW(g_log, EM_REPLACESEL, FALSE, (LPARAM)text.c_str()); } -std::wstring get_dll_path() { - int len = GetWindowTextLengthW(g_dll_edit); - std::wstring s(len, L'\0'); - if (len > 0) GetWindowTextW(g_dll_edit, s.data(), len + 1); - return s; -} - void refresh_list() { ListView_DeleteAllItems(g_list); g_processes = list_java_processes(); + bool mc_only = SendMessageW(g_mc_only, BM_GETCHECK, 0, 0) == BST_CHECKED; + int shown = 0; + int row_index = 0; for (size_t i = 0; i < g_processes.size(); ++i) { const auto& jp = g_processes[i]; + if (mc_only && !is_minecraft_like(jp)) continue; LVITEMW item{}; item.mask = LVIF_TEXT | LVIF_PARAM; - item.iItem = (int)i; + item.iItem = row_index++; item.iSubItem = 0; wchar_t pid_text[32]; std::swprintf(pid_text, 32, L"%lu", jp.pid); @@ -66,10 +70,18 @@ void refresh_list() { ListView_SetItemText(g_list, row, 2, const_cast(jp.window_title.c_str())); ListView_SetItemText(g_list, row, 3, + const_cast(jp.window_class.c_str())); + ListView_SetItemText(g_list, row, 4, const_cast(jp.command_line.c_str())); + ++shown; + } + wchar_t msg[128]; + if (mc_only) { + std::swprintf(msg, 128, L"Refreshed: %d shown / %zu Java process(es) (Minecraft filter on)", + shown, g_processes.size()); + } else { + std::swprintf(msg, 128, L"Refreshed: %zu Java process(es)", g_processes.size()); } - wchar_t msg[64]; - std::swprintf(msg, 64, L"Refreshed: %zu Java process(es)", g_processes.size()); append_log(msg); } @@ -90,13 +102,12 @@ void do_inject() { } const auto& jp = g_processes[idx]; - std::wstring dll = get_dll_path(); - wchar_t msg[512]; - std::swprintf(msg, 512, L"Injecting %ls into pid %lu (%ls)", - dll.c_str(), jp.pid, jp.image_name.c_str()); + wchar_t msg[256]; + std::swprintf(msg, 256, L"Mapping embedded OpenZen.dll into pid %lu (%ls)", + jp.pid, jp.image_name.c_str()); append_log(msg); - std::wstring err = inject(jp.pid, dll); + std::wstring err = inject(jp.pid); if (err.empty()) { append_log(L"Injection ok. Watch %TEMP%\\openzen.log for Java side."); } else { @@ -104,26 +115,6 @@ void do_inject() { } } -void do_browse() { - wchar_t buf[MAX_PATH] = {0}; - std::wstring current = get_dll_path(); - if (!current.empty() && current.size() < MAX_PATH) { - wcscpy_s(buf, MAX_PATH, current.c_str()); - } - - OPENFILENAMEW ofn{}; - ofn.lStructSize = sizeof ofn; - ofn.hwndOwner = GetParent(g_browse); - ofn.lpstrFilter = L"DLL Files\0*.dll\0All Files\0*.*\0"; - ofn.lpstrFile = buf; - ofn.nMaxFile = MAX_PATH; - ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST; - - if (GetOpenFileNameW(&ofn)) { - SetWindowTextW(g_dll_edit, buf); - } -} - void layout(HWND hwnd) { RECT rc; GetClientRect(hwnd, &rc); int W = rc.right - rc.left; @@ -133,14 +124,12 @@ void layout(HWND hwnd) { int btn_h = 24; int row_h = 26; - // Top row: DLL path edit + Browse - MoveWindow(g_dll_edit, pad, pad, W - pad*3 - 80, btn_h, TRUE); - MoveWindow(g_browse, W - pad - 80, pad, 80, btn_h, TRUE); - - // Button row - int by = pad + row_h; + // Button row only (no DLL path needed; the DLL lives inside this EXE + // and is mapped directly into the target process without touching disk). + int by = pad; MoveWindow(g_refresh, pad, by, 100, btn_h, TRUE); MoveWindow(g_inject, pad + 110, by, 100, btn_h, TRUE); + MoveWindow(g_mc_only, pad + 220, by, 180, btn_h, TRUE); // List view int list_y = by + row_h; @@ -160,26 +149,16 @@ LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { case WM_CREATE: { HINSTANCE hi = ((LPCREATESTRUCT)lp)->hInstance; - g_dll_edit = CreateWindowExW(WS_EX_CLIENTEDGE, L"EDIT", L"", - WS_CHILD | WS_VISIBLE | ES_AUTOHSCROLL, - 0, 0, 0, 0, hwnd, (HMENU)ID_DLLPATH, hi, nullptr); - // Extract the embedded OpenZen.dll to %TEMP%\OpenZenLoader and pre-fill - // it as the default DLL path. Users can still override via Browse. - std::wstring embedded = extract_embedded_dll(); - if (!embedded.empty()) { - SetWindowTextW(g_dll_edit, embedded.c_str()); - } - - g_browse = CreateWindowW(L"BUTTON", L"Browse...", - WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, - 0, 0, 0, 0, hwnd, (HMENU)ID_BROWSE, hi, nullptr); - g_refresh = CreateWindowW(L"BUTTON", L"Refresh", WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, 0, 0, 0, 0, hwnd, (HMENU)ID_REFRESH, hi, nullptr); g_inject = CreateWindowW(L"BUTTON", L"Inject", WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, 0, 0, 0, 0, hwnd, (HMENU)ID_INJECT, hi, nullptr); + g_mc_only = CreateWindowW(L"BUTTON", L"Minecraft Only", + WS_CHILD | WS_VISIBLE | BS_AUTOCHECKBOX, + 0, 0, 0, 0, hwnd, (HMENU)ID_MC_ONLY, hi, nullptr); + SendMessageW(g_mc_only, BM_SETCHECK, BST_CHECKED, 0); g_list = CreateWindowExW(WS_EX_CLIENTEDGE, WC_LISTVIEWW, L"", WS_CHILD | WS_VISIBLE | LVS_REPORT | LVS_SINGLESEL, @@ -193,10 +172,12 @@ LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { ListView_InsertColumn(g_list, 0, &col); col.cx = 100; col.pszText = const_cast(L"Image"); ListView_InsertColumn(g_list, 1, &col); - col.cx = 320; col.pszText = const_cast(L"Window Title"); + col.cx = 260; col.pszText = const_cast(L"Window Title"); ListView_InsertColumn(g_list, 2, &col); - col.cx = 300; col.pszText = const_cast(L"Path"); + col.cx = 160; col.pszText = const_cast(L"Window Class"); ListView_InsertColumn(g_list, 3, &col); + col.cx = 220; col.pszText = const_cast(L"Path"); + ListView_InsertColumn(g_list, 4, &col); g_log = CreateWindowExW(WS_EX_CLIENTEDGE, L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_VSCROLL | ES_MULTILINE @@ -205,15 +186,17 @@ LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { layout(hwnd); refresh_list(); - append_log(L"OpenZen Loader ready. Select a javaw.exe and press Inject."); + append_log(L"OpenZen Loader ready. DLL is mapped in-memory; nothing touches disk."); return 0; } case WM_SIZE: layout(hwnd); return 0; case WM_COMMAND: switch (LOWORD(wp)) { - case ID_REFRESH: refresh_list(); return 0; - case ID_INJECT: do_inject(); return 0; - case ID_BROWSE: do_browse(); return 0; + case ID_REFRESH: refresh_list(); return 0; + case ID_INJECT: do_inject(); return 0; + case ID_MC_ONLY: + if (HIWORD(wp) == BN_CLICKED) refresh_list(); + return 0; } break; case WM_DESTROY: PostQuitMessage(0); return 0; diff --git a/native/loader/src/window_title.cpp b/native/loader/src/window_title.cpp index f48342d..9aa6452 100644 --- a/native/loader/src/window_title.cpp +++ b/native/loader/src/window_title.cpp @@ -5,7 +5,7 @@ namespace loader { namespace { struct Search { DWORD pid; - std::wstring best; + WindowInfo best; }; BOOL CALLBACK enum_proc(HWND hwnd, LPARAM lp) { @@ -27,14 +27,17 @@ namespace { // Prefer the longest title - usually the main Minecraft window which // includes version/world name vs a tiny "Java" tooltip window. - if (title.size() > s->best.size()) { - s->best = std::move(title); + if (title.size() > s->best.title.size()) { + wchar_t cls[256] = {0}; + GetClassNameW(hwnd, cls, 256); + s->best.title = std::move(title); + s->best.class_name = cls; } return TRUE; } } -std::wstring window_title_for(DWORD pid) { +WindowInfo window_info_for(DWORD pid) { Search s{pid, {}}; EnumWindows(enum_proc, reinterpret_cast(&s)); return s.best;