From 11c790dd04bf3475cf34644f40355c1c530dca8d Mon Sep 17 00:00:00 2001 From: mohui666 Date: Sat, 15 Aug 2026 19:40:35 +0800 Subject: [PATCH 1/4] Fix cross-platform injection compatibility --- .github/workflows/build-be.yml | 6 + README.md | 89 +++- assets/nix/run.sh | 77 +++- assets/windows/doorstop_config.ini | 2 +- src/bootstrap.c | 152 +++++-- src/bootstrap.h | 3 +- src/config/config.h | 9 +- src/crt.h | 5 +- src/nix/entrypoint.c | 144 +++++-- src/nix/plthook/plthook_osx.c | 391 +++--------------- src/windows/config.c | 137 ++++-- src/windows/entrypoint.c | 38 +- src/windows/hook.h | 241 +++++++++-- src/windows/proxy/proxy.h | 15 +- src/windows/util.c | 68 ++- tests/nix/fixtures/fake-arch.sh | 20 + tests/nix/fixtures/fake-defaults.sh | 2 + tests/nix/fixtures/fake-file.sh | 2 + tests/nix/fixtures/fake-sysctl.sh | 2 + tests/nix/fixtures/fake-uname.sh | 2 + tests/nix/fixtures/game.sh | 12 + tests/nix/fixtures/macos-dlsym-smoke.c | 93 +++++ .../fixtures/macos-unityplayer-interpose.c | 22 + tests/nix/fixtures/unityplayer-dup2-smoke.c | 38 ++ tests/nix/fixtures/unityplayer-dup2.c | 5 + tests/nix/run-macos-interpose-smoke.sh | 53 +++ tests/nix/run-regressions.sh | 179 ++++++++ tests/windows/config-util-regressions.c | 168 ++++++++ tests/windows/iat-hook-regressions.c | 184 +++++++++ tests/windows/run-regressions.ps1 | 58 +++ tests/windows/test-crt.c | 71 ++++ 31 files changed, 1776 insertions(+), 512 deletions(-) create mode 100644 tests/nix/fixtures/fake-arch.sh create mode 100644 tests/nix/fixtures/fake-defaults.sh create mode 100644 tests/nix/fixtures/fake-file.sh create mode 100644 tests/nix/fixtures/fake-sysctl.sh create mode 100644 tests/nix/fixtures/fake-uname.sh create mode 100644 tests/nix/fixtures/game.sh create mode 100644 tests/nix/fixtures/macos-dlsym-smoke.c create mode 100644 tests/nix/fixtures/macos-unityplayer-interpose.c create mode 100644 tests/nix/fixtures/unityplayer-dup2-smoke.c create mode 100644 tests/nix/fixtures/unityplayer-dup2.c create mode 100644 tests/nix/run-macos-interpose-smoke.sh create mode 100644 tests/nix/run-regressions.sh create mode 100644 tests/windows/config-util-regressions.c create mode 100644 tests/windows/iat-hook-regressions.c create mode 100644 tests/windows/run-regressions.ps1 create mode 100644 tests/windows/test-crt.c diff --git a/.github/workflows/build-be.yml b/.github/workflows/build-be.yml index 42cbcca..cebe87b 100644 --- a/.github/workflows/build-be.yml +++ b/.github/workflows/build-be.yml @@ -15,6 +15,8 @@ jobs: New-Item -ItemType Directory -Force -Path ./artifacts/release/x86 New-Item -ItemType Directory -Force -Path ./artifacts/verbose/x64 New-Item -ItemType Directory -Force -Path ./artifacts/verbose/x86 + - name: Run Windows regression tests + run: ./tests/windows/run-regressions.ps1 - name: Build Release run: | ./build.ps1 @@ -73,6 +75,8 @@ jobs: cp assets/nix/run.sh artifacts/release/x86/run.sh cp assets/nix/run.sh artifacts/release/x64/run.sh cp LICENSE artifacts/release/LICENSE + - name: Run nix regression tests + run: sh tests/nix/run-regressions.sh build/linux/x64/release/libdoorstop.so - name: Build Verbose run: | ./build.sh -with_logging @@ -128,6 +132,8 @@ jobs: cp build/macosx/universal/release/.doorstop_version artifacts/release/universal/.doorstop_version cp assets/nix/run.sh artifacts/release/universal/run.sh cp LICENSE artifacts/release/LICENSE + - name: Run macOS interposition smoke test + run: sh tests/nix/run-macos-interpose-smoke.sh build/macosx/universal/release/libdoorstop.dylib - name: Build Verbose run: | ./build.sh -with_logging diff --git a/README.md b/README.md index dda8d33..f127286 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,11 @@ Doorstop sets some environment variables useful for code execution: | Environment variable | Description | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `DOORSTOP_INITIALIZED` | Always set to `TRUE`. Use to determine if your code is run via Doorstop. | -| `DOORSTOP_INVOKE_DLL_PATH` | Path to the assembly executed by Doorstop relative to the current working directory. | +| `DOORSTOP_INITIALIZED` | Set to `TRUE` when Doorstop bootstraps a managed target. Use to determine if your code is run via Doorstop. | +| `DOORSTOP_INVOKE_DLL_PATH` | Full path to the assembly executed by Doorstop. | | `DOORSTOP_PROCESS_PATH` | Path to the application executable where the injected assembly is run. | | `DOORSTOP_MANAGED_FOLDER_DIR` | *UnityMono*: Path to the game's `Managed` folder. *Il2Cpp*: Path to CoreCLR's base class library folder. | -| `DOORSTOP_DLL_SEARCH_DIRS` | Paths where the runtime searchs assemblies from by default, separated by OS-specific separator (`;` on windows and `:` on *nix). | +| `DOORSTOP_DLL_SEARCH_DIRS` | Paths where the runtime searches for assemblies by default, separated by the OS-specific separator (`;` on Windows and `:` on Unix). | | `DOORSTOP_MONO_LIB_PATH` | *Only on UnityMono*: Full path to the mono runtime library. | ### Debugging @@ -142,6 +142,89 @@ All Doorstop arguments start with `--doorstop-` and always contain an argument. | `--doorstop-clr-corlib-dir string` | Path to coreclr library that contains the CoreCLR runtime | | `--doorstop-clr-runtime-coreclr-path string` | Path to the directory containing the managed core libraries for CoreCLR (`mscorlib`, `System`, etc.) | +## Troubleshooting and compatibility + +### Choosing a Windows proxy DLL + +Windows builds can be installed as `winhttp.dll`, `version.dll`, or +`dxgi.dll`. If a game or launcher uses WinHTTP during very early startup, +proxying `winhttp.dll` can conflict with that initialization. Rename the +Doorstop proxy to `dxgi.dll` (or `version.dll`) and keep +`doorstop_config.ini` beside it. This is particularly useful for games that +silently exit before Doorstop reaches the managed bootstrap. +Install only one of these proxy DLL names at a time. + +### Native Unix games, Proton, and relative paths + +`run.sh` is for native Linux and macOS executables. A Windows PE executable +running through Wine or Proton must use the Windows Doorstop build and a +Windows proxy DLL instead. + +Relative executable and `target_assembly` paths in `run.sh` are resolved from +the script directory, so the script can be launched from another working +directory. Each relative entry in the `dll_search_path_override` list is +resolved from that directory as well. The target assembly's parent directory +is not added to Mono's search path automatically. Separate multiple paths with +`;` on Windows and `:` on Unix. + +### Debug-only mode + +Mono debugging can be enabled without loading a target assembly. Set +`debug_enabled=true` (or `debug_enable=1` in `run.sh`) and leave +`target_assembly` empty. Doorstop installs the Mono initialization hook and +configures the debugger, then skips the managed entrypoint. + +Doorstop accepts `localhost` for the Mono debugger and canonicalizes it to +`127.0.0.1`; using `127.0.0.1` in the IDE avoids an IPv6 (`::1`) DNS choice on +clients that do not retry IPv4. An explicitly configured IPv6 listener remains +distinct. Doorstop also honors dnSpy's `DNSPY_UNITY_DBG2` environment variable, +so clear a stale value when debugging is unexpectedly enabled. + +On modern macOS Mach-O images that use chained fixups, Doorstop uses dyld +interposition for runtime symbol lookup, boot.config access, and UnityPlayer's +stdout protection. Those stdio hooks verify that their caller is UnityPlayer, +so an inherited `DYLD_INSERT_LIBRARIES` value does not change shell or launcher +redirection. Legacy Mono players that bind initialization directly continue to +use the traditional Mach-O lazy-bind path. + +Reconnect behavior after an IDE disconnect is implemented by the Mono runtime +bundled with the game. Some older Unity Mono versions do not reliably reopen +their listener; Doorstop cannot replace that runtime-side connection loop. + +### Games that restart themselves + +Steam and self-restarting games can copy `DOORSTOP_DISABLE` and +`DOORSTOP_INITIALIZED` into the replacement process. Set +`ignore_disable_switch=true` on Windows, or `ignore_disable_switch=1` in +`run.sh`, when the launcher is known to do this. Doorstop then clears both +inherited markers; the Mono bootstrap also uses a process-local guard to +prevent genuine duplicate initialization. + +### Waiting until a game assembly is loaded + +Doorstop deliberately invokes `Doorstop.Entrypoint.Start()` before game +assemblies. A target that needs types from `Assembly-CSharp` can use the +managed assembly-load event instead of a version-specific native hook: + +```cs +AppDomain.CurrentDomain.AssemblyLoad += (_, eventArgs) => +{ + if (eventArgs.LoadedAssembly.GetName().Name == "Assembly-CSharp") + OnGameAssemblyLoaded(); +}; +``` + +Register the handler in `Start()` and make the callback one-shot if the loader +must run only once. + +### RenderDoc and other native hook tools + +Doorstop preserves and calls through a pre-existing `GetProcAddress` IAT hook, +which allows common RenderDoc/apitrace injection orders to coexist. Tools that +replace other required import-table entries can still conflict; in that case, +start through Doorstop first, load or attach the graphics tool from the managed +entrypoint, and then capture the running process. + ## License diff --git a/assets/nix/run.sh b/assets/nix/run.sh index 6992417..732a52f 100755 --- a/assets/nix/run.sh +++ b/assets/nix/run.sh @@ -27,7 +27,8 @@ target_assembly="Doorstop.dll" # Overrides the default boot.config file path boot_config_override= -# If enabled, DOORSTOP_DISABLE env var value is ignored +# If enabled, inherited DOORSTOP_DISABLE and DOORSTOP_INITIALIZED markers are +# ignored so launchers can safely start a replacement game process # USE THIS ONLY WHEN ASKED TO OR YOU KNOW WHAT THIS MEANS ignore_disable_switch="0" @@ -62,9 +63,20 @@ corlib_dir="" # Everything past this point is the actual script set -e +# Use POSIX-compatible way to get the directory of the script. Relative game +# paths are resolved from here, not from the caller's working directory. +a="/$0"; a=${a%/*}; a=${a#/}; a=${a:-.}; BASEDIR=$(cd "$a" || exit; pwd -P) + +script_path() { + case "$1" in + /*) printf '%s\n' "$1" ;; + *) printf '%s\n' "${BASEDIR}/$1" ;; + esac +} + # Special case: program is launched via Steam on Linux # In that case rerun the script via their bootstrapper to delay adding Doorstop to LD_PRELOAD -# This is required until https://github.com/NeighTools/UnityDoorstop/issues/88 is resolved +# and avoid injecting Doorstop into the bootstrapper and overlay helpers. for a in "$@"; do if [ "$a" = "SteamLaunch" ]; then rotated=0; max=$# @@ -90,18 +102,20 @@ for a in "$@"; do done # Handle first param being executable name -if [ -x "$1" ] ; then - executable_name="$1" - shift +if [ -n "$1" ]; then + first_arg_path="$(script_path "$1")" + if [ -x "$first_arg_path" ] ; then + executable_name="$1" + shift + fi fi -if [ -z "${executable_name}" ] || [ ! -x "${executable_name}" ]; then +executable_path_from_base="$(script_path "$executable_name")" +if [ -z "${executable_name}" ] || [ ! -x "${executable_path_from_base}" ]; then echo "Please set executable_name to a valid name in a text editor or as the first command line parameter" 1>&2 exit 1 fi - -# Use POSIX-compatible way to get the directory of the executable -a="/$0"; a=${a%/*}; a=${a#/}; a=${a:-.}; BASEDIR=$(cd "$a" || exit; pwd -P) +executable_name="${executable_path_from_base}" arch="" executable_path="" @@ -115,6 +129,38 @@ abs_path() { echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" } +normalize_search_paths() { + remaining_paths="$1" + normalized_paths="" + + while :; do + case "$remaining_paths" in + *:*) + search_path=${remaining_paths%%:*} + remaining_paths=${remaining_paths#*:} + has_more_paths=1 + ;; + *) + search_path=$remaining_paths + has_more_paths=0 + ;; + esac + + if [ -n "$search_path" ]; then + search_path="$(abs_path "$search_path")" + if [ -n "$normalized_paths" ]; then + normalized_paths="${normalized_paths}:${search_path}" + else + normalized_paths=$search_path + fi + fi + + [ "$has_more_paths" -eq 1 ] || break + done + + printf '%s\n' "$normalized_paths" +} + # Set executable path and the extension to use for the libdoorstop shared object as well as check whether we're running on Apple Silicon os_type="$(uname -s)" case ${os_type} in @@ -288,7 +334,10 @@ while [ $i -lt $max ]; do i=$((i+1)) done -target_assembly="$(abs_path "$target_assembly")" +if [ -n "$target_assembly" ]; then + target_assembly="$(abs_path "$target_assembly")" +fi +dll_search_path_override="$(normalize_search_paths "$dll_search_path_override")" # Move variables to environment export DOORSTOP_ENABLED="$enabled" @@ -326,8 +375,12 @@ if [ -n "${is_apple_silicon}" ]; then # We need to use arch for Apple Silicon to allow the executable to be run natively as otherwise if # the executable is universal, supporting both x86_64 and arm64, MacOs will still run it as x86_64 # if the parent process is running as x86. - # arch also strips the DYLD_INSERT_LIBRARIES env var so we have to pass that in manually - exec arch -e DYLD_INSERT_LIBRARIES="${DYLD_INSERT_LIBRARIES}" "$executable_path" "$@" + # Keep the inserted library out of the arm64e arch helper itself, then add + # it back only for the game process. This must use the shell builtin unset; + # an external env helper would encounter the same architecture mismatch. + doorstop_insert="${DYLD_INSERT_LIBRARIES}" + unset DYLD_INSERT_LIBRARIES + exec arch -e DYLD_INSERT_LIBRARIES="${doorstop_insert}" "$executable_path" "$@" else exec "$executable_path" "$@" fi diff --git a/assets/windows/doorstop_config.ini b/assets/windows/doorstop_config.ini index 2bc726b..d9cbe62 100644 --- a/assets/windows/doorstop_config.ini +++ b/assets/windows/doorstop_config.ini @@ -14,7 +14,7 @@ redirect_output_log=false # Overrides the default boot.config file path boot_config_override= -# If enabled, DOORSTOP_DISABLE env var value is ignored +# If enabled, inherited DOORSTOP_DISABLE and DOORSTOP_INITIALIZED values are ignored # USE THIS ONLY WHEN ASKED TO OR YOU KNOW WHAT THIS MEANS ignore_disable_switch=false diff --git a/src/bootstrap.c b/src/bootstrap.c index ed38f71..592b735 100644 --- a/src/bootstrap.c +++ b/src/bootstrap.c @@ -10,16 +10,58 @@ bool_t mono_debug_init_called = FALSE; bool_t mono_is_net35 = FALSE; +static bool_t doorstop_bootstrapped = FALSE; + +#define LOCALHOST_DEBUG_PREFIX TEXT("localhost:") +#define IPV4_LOOPBACK_DEBUG_PREFIX TEXT("127.0.0.1:") + +static bool_t debug_address_uses_localhost(const char_t *address) { + if (!address) + return FALSE; + + for (size_t i = 0; i < STR_LEN(LOCALHOST_DEBUG_PREFIX) - 1; i++) { + char_t actual = address[i]; + if (!actual) + return FALSE; + if (actual >= 'A' && actual <= 'Z') + actual += 'a' - 'A'; + if (actual != LOCALHOST_DEBUG_PREFIX[i]) + return FALSE; + } + return TRUE; +} void mono_doorstop_bootstrap(void *mono_domain) { - if (getenv(TEXT("DOORSTOP_INITIALIZED"))) { + if (doorstop_bootstrapped) { + LOG("Doorstop already bootstrapped in this process, skipping!"); + return; + } + + // Launchers such as Steam can copy this process marker into a newly + // started game. The ignore switch already opts out of the equivalent + // DOORSTOP_DISABLE check, so honor it here as well and use a process-local + // flag to guard genuine re-entry. + char_t *initialized_env = getenv(TEXT("DOORSTOP_INITIALIZED")); + if (initialized_env && !config.ignore_disabled_env) { LOG("DOORSTOP_INITIALIZED is set! Skipping!"); + shutenv(initialized_env); + return; + } + shutenv(initialized_env); + doorstop_bootstrapped = TRUE; + + // Debugging does not require a managed bootstrap assembly. In debug-only + // mode init_mono has already configured the debugger, so leave the runtime + // alone instead of passing a null path to the file and environment APIs. + if (!config.target_assembly || !file_exists(config.target_assembly)) { + LOG("No target assembly configured; managed bootstrap skipped"); return; } - setenv(TEXT("DOORSTOP_INITIALIZED"), TEXT("TRUE"), TRUE); mono.thread_set_main(mono.thread_current()); + setenv(TEXT("DOORSTOP_INITIALIZED"), TEXT("TRUE"), TRUE); + char_t *app_path = program_path(); if (mono.domain_set_config) { #define CONFIG_EXT TEXT(".config") @@ -57,7 +99,7 @@ void mono_doorstop_bootstrap(void *mono_domain) { free(norm_assembly_dir); LOG("Opening assembly: %s", config.target_assembly); - void *file = fopen(config.target_assembly, "r"); + void *file = fopen(config.target_assembly, TEXT("r")); if (!file) { LOG("Failed to open assembly: %s", config.target_assembly); return; @@ -65,8 +107,14 @@ void mono_doorstop_bootstrap(void *mono_domain) { size_t size = get_file_size(file); void *data = malloc(size); - fread(data, size, 1, file); + size_t bytes_read = fread(data, 1, size, file); fclose(file); + if (bytes_read != size) { + LOG("Failed to read complete assembly: %s (%d of %d bytes)", + config.target_assembly, (int)bytes_read, (int)size); + free(data); + return; + } LOG("Opened Assembly DLL (%d bytes); opening its main image", size); @@ -84,7 +132,7 @@ void mono_doorstop_bootstrap(void *mono_domain) { LOG("Image opened; loading included assembly"); s = MONO_IMAGE_OK; - void *assembly = mono.assembly_load_from_full(image, dll_path, &s, FALSE); + mono.assembly_load_from_full(image, dll_path, &s, FALSE); free(dll_path); if (s != MONO_IMAGE_OK) { LOG("Failed to load assembly: %s. Got result: %d\n", @@ -171,6 +219,13 @@ void *init_mono(const char *root_domain_name, const char *runtime_version) { char_t *full_path = get_full_path(path); + if (!full_path) { + LOG("Ignoring invalid root path: %s", path); + free(path); + path_start = i + 1; + continue; + } + if (strlen(override_dir_full) + strlen(full_path) + 2 > MAX_PATH) { LOG("Ignoring this root path because its absolute version " @@ -242,6 +297,11 @@ void il2cpp_doorstop_bootstrap() { return; } + if (!config.target_assembly || !file_exists(config.target_assembly)) { + LOG("No target assembly configured; CoreCLR bootstrap skipped"); + return; + } + LOG("CoreCLR runtime path: %s", config.clr_runtime_coreclr_path); LOG("CoreCLR corlib dir: %s", config.clr_corlib_dir); @@ -264,7 +324,6 @@ void il2cpp_doorstop_bootstrap() { char *app_path_n = narrow(app_path); char_t *target_dir = get_folder_name(config.target_assembly); - char *target_dir_n = narrow(target_dir); char_t *target_name = get_file_name(config.target_assembly, FALSE); char *target_name_n = narrow(target_name); @@ -327,8 +386,11 @@ int init_il2cpp(const char *domain_name) { void hook_mono_jit_parse_options(int argc, char **argv) { char_t *debug_options = getenv(TEXT("DNSPY_UNITY_DBG2")); + bool_t debug_options_from_env = debug_options != NULL; if (debug_options) { config.mono_debug_enabled = TRUE; + LOG("Mono debugging enabled by DNSPY_UNITY_DBG2; overriding Doorstop " + "debug options"); } if (config.mono_debug_enabled) { @@ -336,22 +398,45 @@ void hook_mono_jit_parse_options(int argc, char **argv) { int size = argc + 1; char **new_argv = calloc(size, sizeof(char *)); - memcpy(new_argv, argv, argc * sizeof(char *)); + if (argc > 0 && argv) + memcpy(new_argv, argv, argc * sizeof(char *)); - size_t debug_args_len = - STR_LEN(MONO_DEBUG_ARG_START) + strlen(config.mono_debug_address); - if (!config.mono_debug_suspend) { - if (mono_is_net35) { - debug_args_len += STR_LEN(MONO_DEBUG_NO_SUSPEND_NET35); - } else { - debug_args_len += STR_LEN(MONO_DEBUG_NO_SUSPEND); + if (!debug_options) { + const char_t *debug_address = config.mono_debug_address; + if (!debug_address || !debug_address[0]) { + debug_address = TEXT("127.0.0.1:10000"); + LOG("Mono debug address is empty; using %s", debug_address); + } + + const char_t *debug_address_suffix = NULL; + size_t debug_address_len = strlen(debug_address); + if (debug_address_uses_localhost(debug_address)) { + debug_address_suffix = + debug_address + STR_LEN(LOCALHOST_DEBUG_PREFIX) - 1; + debug_address_len = + STR_LEN(IPV4_LOOPBACK_DEBUG_PREFIX) - 1 + + strlen(debug_address_suffix); + LOG("Normalizing Mono debug host localhost to 127.0.0.1"); + } + + size_t debug_args_len = + STR_LEN(MONO_DEBUG_ARG_START) + debug_address_len; + if (!config.mono_debug_suspend) { + if (mono_is_net35) { + debug_args_len += STR_LEN(MONO_DEBUG_NO_SUSPEND_NET35); + } else { + debug_args_len += STR_LEN(MONO_DEBUG_NO_SUSPEND); + } } - } - if (!debug_options) { debug_options = calloc(debug_args_len + 1, sizeof(char_t)); strcat(debug_options, MONO_DEBUG_ARG_START); - strcat(debug_options, config.mono_debug_address); + if (debug_address_suffix) { + strcat(debug_options, IPV4_LOOPBACK_DEBUG_PREFIX); + strcat(debug_options, debug_address_suffix); + } else { + strcat(debug_options, debug_address); + } if (!config.mono_debug_suspend) { if (mono_is_net35) { strcat(debug_options, MONO_DEBUG_NO_SUSPEND_NET35); @@ -367,7 +452,10 @@ void hook_mono_jit_parse_options(int argc, char **argv) { new_argv[argc] = debug_options_n; mono.jit_parse_options(size, new_argv); - free(debug_options); + if (debug_options_from_env) + shutenv(debug_options); + else + free(debug_options); free(debug_options_n); free(new_argv); } else { @@ -396,15 +484,23 @@ void *hook_mono_image_open_from_data_with_name(void *data, strcat(new_full_path, name_file); if (file_exists(new_full_path)) { - void *file = fopen(new_full_path, "r"); - size_t size = get_file_size(file); - void *buf = malloc(size); - fread(buf, 1, size, file); - fclose(file); - result = mono.image_open_from_data_with_name(buf, size, need_copy, - status, refonly, name); - if (need_copy) - free(buf); + void *file = fopen(new_full_path, TEXT("r")); + if (file) { + size_t size = get_file_size(file); + void *buf = malloc(size); + size_t bytes_read = fread(buf, 1, size, file); + fclose(file); + if (bytes_read == size) { + result = mono.image_open_from_data_with_name( + buf, size, need_copy, status, refonly, name); + } else { + LOG("Failed to read complete override assembly: %s (%d " + "of %d bytes)", + new_full_path, (int)bytes_read, (int)size); + } + if (need_copy || bytes_read != size) + free(buf); + } } free(new_full_path); } @@ -419,4 +515,4 @@ void *hook_mono_image_open_from_data_with_name(void *data, void hook_mono_debug_init(MonoDebugFormat format) { mono_debug_init_called = TRUE; mono.debug_init(format); -} \ No newline at end of file +} diff --git a/src/bootstrap.h b/src/bootstrap.h index a29e511..f521288 100644 --- a/src/bootstrap.h +++ b/src/bootstrap.h @@ -6,6 +6,7 @@ #include "util/util.h" void *init_mono(const char *root_domain_name, const char *runtime_version); +void mono_doorstop_bootstrap(void *mono_domain); int init_il2cpp(const char *domain_name); void *hook_mono_image_open_from_data_with_name(void *data, unsigned long data_len, @@ -15,4 +16,4 @@ void *hook_mono_image_open_from_data_with_name(void *data, void hook_mono_jit_parse_options(int argc, char **argv); void hook_mono_debug_init(MonoDebugFormat format); -#endif \ No newline at end of file +#endif diff --git a/src/config/config.h b/src/config/config.h index 0a0814b..0417a2d 100644 --- a/src/config/config.h +++ b/src/config/config.h @@ -23,10 +23,11 @@ typedef struct { bool_t redirect_output_log; /** - * @brief Whether to ignore DOORSTOP_DISABLE. + * @brief Whether to ignore inherited Doorstop process markers. * - * If enabled, Doorstop will ignore DOORSTOP_DISABLE environment variable. - * This is sometimes useful with Steam games that break env var isolation. + * If enabled, Doorstop clears inherited DOORSTOP_DISABLE and + * DOORSTOP_INITIALIZED environment variables. This is sometimes useful + * with Steam games and self-restarting games that break env var isolation. */ bool_t ignore_disabled_env; @@ -92,4 +93,4 @@ extern void init_config_defaults(); * @brief Clean up configuration. */ extern void cleanup_config(); -#endif \ No newline at end of file +#endif diff --git a/src/crt.h b/src/crt.h index fae89c2..b53b266 100644 --- a/src/crt.h +++ b/src/crt.h @@ -4,6 +4,9 @@ #if _WIN32 #include "windows/wincrt.h" // Better default to account for longer name support +#ifdef MAX_PATH +#undef MAX_PATH +#endif #define MAX_PATH 1024 #if _WIN64 @@ -39,4 +42,4 @@ #endif -#endif \ No newline at end of file +#endif diff --git a/src/nix/entrypoint.c b/src/nix/entrypoint.c index 457e11d..58ad884 100644 --- a/src/nix/entrypoint.c +++ b/src/nix/entrypoint.c @@ -7,6 +7,7 @@ #include "./plthook/plthook.h" #if defined(__APPLE__) +#include #define PLTHOOK_OPEN_BY_HANDLE_OR_ADDRESS plthook_open_by_handle #else #define PLTHOOK_OPEN_BY_HANDLE_OR_ADDRESS plthook_open_by_address @@ -19,6 +20,29 @@ void capture_mono_path(void *handle) { } static bool_t initialized = FALSE; +static bool_t doorstop_ready = FALSE; + +#if defined(__APPLE__) +static bool_t apple_caller_is_unity_player(void *return_address) { + Dl_info info; + if (!doorstop_ready || !config.enabled || !return_address || + dladdr(return_address, &info) == 0 || !info.dli_fname) { + return FALSE; + } + + const char *image_name = strrchr(info.dli_fname, '/'); + image_name = image_name ? image_name + 1 : info.dli_fname; + static const char unity_player_name[] = "UnityPlayer"; + const size_t name_len = sizeof(unity_player_name) - 1; + return strncmp(image_name, unity_player_name, name_len) == 0 && + (image_name[name_len] == '\0' || image_name[name_len] == '.'); +} + +#define APPLE_CALLER_IS_UNITY_PLAYER() \ + apple_caller_is_unity_player( \ + __builtin_extract_return_addr(__builtin_return_address(0))) +#endif + void *dlsym_hook(void *handle, const char *name) { #define REDIRECT_INIT(init_name, init_func, target, extra_init) \ if (!strcmp(name, init_name)) { \ @@ -36,6 +60,9 @@ void *dlsym_hook(void *handle, const char *name) { // However, using handle seems to cause issues on some distros, so we pass // the resolved symbol instead. void *res = dlsym(handle, name); + if (!doorstop_ready || !config.enabled) { + return res; + } REDIRECT_INIT("il2cpp_init", load_il2cpp_funcs, init_il2cpp, {}); REDIRECT_INIT("mono_jit_init_version", load_mono_funcs, init_mono, capture_mono_path(res)); @@ -52,6 +79,10 @@ void *dlsym_hook(void *handle, const char *name) { } int fclose_hook(FILE *stream) { +#if defined(__APPLE__) + if (!APPLE_CALLER_IS_UNITY_PLAYER()) + return fclose(stream); +#endif // Some versions of Unity wrongly close stdout, which prevents writing // to console if (stream == stdout) @@ -61,8 +92,8 @@ int fclose_hook(FILE *stream) { char_t *default_boot_config_path = NULL; #if !defined(__APPLE__) -FILE *fopen64_hook(char *filename, char *mode) { - char *actual_file_name = filename; +FILE *fopen64_hook(const char *filename, const char *mode) { + const char *actual_file_name = filename; if (strcmp(filename, default_boot_config_path) == 0) { actual_file_name = config.boot_config_override; @@ -73,10 +104,16 @@ FILE *fopen64_hook(char *filename, char *mode) { } #endif -FILE *fopen_hook(char *filename, char *mode) { - char *actual_file_name = filename; +FILE *fopen_hook(const char *filename, const char *mode) { + const char *actual_file_name = filename; - if (strcmp(filename, default_boot_config_path) == 0) { +#if defined(__APPLE__) + if (!APPLE_CALLER_IS_UNITY_PLAYER()) + return fopen(filename, mode); +#endif + + if (filename && default_boot_config_path && config.boot_config_override && + strcmp(filename, default_boot_config_path) == 0) { actual_file_name = config.boot_config_override; LOG("Overriding boot.config to %s", actual_file_name); } @@ -85,40 +122,76 @@ FILE *fopen_hook(char *filename, char *mode) { } int dup2_hook(int od, int nd) { +#if defined(__APPLE__) + if (!APPLE_CALLER_IS_UNITY_PLAYER()) + return dup2(od, nd); +#endif // Newer versions of Unity redirect stdout to player.log, we don't want // that if (nd == fileno(stdout) || nd == fileno(stderr)) - return F_OK; + return nd; return dup2(od, nd); } +#if defined(__APPLE__) +// Modern Mach-O images commonly use chained fixups instead of a traditional +// lazy-symbol pointer table. Interpose the small set of required libc calls at +// dyld level. The stdio hooks above still act only for a UnityPlayer caller, so +// inherited injection cannot alter shell and launcher redirection semantics. +DYLD_INTERPOSE(dlsym_hook, dlsym) +DYLD_INTERPOSE(fopen_hook, fopen) +DYLD_INTERPOSE(fclose_hook, fclose) +DYLD_INTERPOSE(dup2_hook, dup2) +#endif + __attribute__((constructor)) void doorstop_ctor() { init_logger(); load_config(); + if (config.ignore_disabled_env) { + unsetenv("DOORSTOP_INITIALIZED"); + unsetenv("DOORSTOP_DISABLE"); + LOG("Cleared inherited DOORSTOP_INITIALIZED / DOORSTOP_DISABLE"); + } if (!config.enabled) { LOG("Doorstop not enabled! Skipping!"); return; } + doorstop_ready = TRUE; - plthook_t *hook; + plthook_t *hook = NULL; + bool_t hook_available = FALSE; + bool_t hooking_unity_player = FALSE; void *unity_player = plthook_handle_by_name("UnityPlayer"); if (unity_player && PLTHOOK_OPEN_BY_HANDLE_OR_ADDRESS(&hook, unity_player) == 0) { + hook_available = TRUE; + hooking_unity_player = TRUE; LOG("Found UnityPlayer, hooking into it instead"); - } else if (plthook_open(&hook, NULL) != 0) { + } else if (plthook_open(&hook, NULL) == 0) { + hook_available = TRUE; + } else { +#if defined(__APPLE__) + LOG("Failed to open a PLT hook target; continuing with dyld dlsym " + "interposition. Error: %s", + plthook_error()); +#else LOG("Failed to open current process PLT! Cannot run Doorstop! " "Error: " "%s\n", plthook_error()); return; +#endif } - if (plthook_replace(hook, "dlsym", &dlsym_hook, NULL) != 0) +#if !defined(__APPLE__) + if (hook_available && + plthook_replace(hook, "dlsym", &dlsym_hook, NULL) != 0) LOG("Failed to hook dlsym, ignoring it. Error: %s", plthook_error()); +#endif if (config.boot_config_override) { if (file_exists(config.boot_config_override)) { @@ -130,14 +203,16 @@ __attribute__((constructor)) void doorstop_ctor() { get_file_name(program_path(), FALSE)); strcat(default_boot_config_path, TEXT("_Data/boot.config")); + if (hook_available) { #if !defined(__APPLE__) - if (plthook_replace(hook, "fopen64", &fopen64_hook, NULL) != 0) - LOG("Failed to hook fopen64, ignoring it. Error: %s", - plthook_error()); + if (plthook_replace(hook, "fopen64", &fopen64_hook, NULL) != 0) + LOG("Failed to hook fopen64, ignoring it. Error: %s", + plthook_error()); #endif - if (plthook_replace(hook, "fopen", &fopen_hook, NULL) != 0) - LOG("Failed to hook fopen, ignoring it. Error: %s", - plthook_error()); + if (plthook_replace(hook, "fopen", &fopen_hook, NULL) != 0) + LOG("Failed to hook fopen, ignoring it. Error: %s", + plthook_error()); + } } else { LOG("The boot.config file won't be overriden because the provided " "one does not exist: %s", @@ -145,13 +220,19 @@ __attribute__((constructor)) void doorstop_ctor() { } } - if (plthook_replace(hook, "fclose", &fclose_hook, NULL) != 0) - LOG("Failed to hook fclose, ignoring it. Error: %s", - plthook_error()); + // Only suppress UnityPlayer's attempts to redirect or close standard + // output. LD_PRELOAD is inherited by child processes, and applying these + // hooks to their main executables breaks legitimate redirection such as + // command substitution in sh. + if (hooking_unity_player) { + if (plthook_replace(hook, "fclose", &fclose_hook, NULL) != 0) + LOG("Failed to hook fclose, ignoring it. Error: %s", + plthook_error()); - if (plthook_replace(hook, "dup2", &dup2_hook, NULL) != 0) - LOG("Failed to hook dup2, ignoring it. Error: %s", - plthook_error()); + if (plthook_replace(hook, "dup2", &dup2_hook, NULL) != 0) + LOG("Failed to hook dup2, ignoring it. Error: %s", + plthook_error()); + } #if defined(__APPLE__) /* @@ -159,14 +240,19 @@ __attribute__((constructor)) void doorstop_ctor() { loader directly. Because of this, there is no dlsym, in which case we need to apply a PLT hook. */ - void *mono_handle = plthook_handle_by_name("libmono"); + if (hook_available) { + void *mono_handle = plthook_handle_by_name("libmono"); - if (plthook_replace(hook, "mono_jit_init_version", &init_mono, NULL) != 0) - LOG("Failed to hook jit_init_version, ignoring it. This is probably fine unless you see other errors. Error: %s", - plthook_error()); - else if (mono_handle) - load_mono_funcs(mono_handle); + if (plthook_replace(hook, "mono_jit_init_version", &init_mono, NULL) != + 0) + LOG("Failed to hook jit_init_version, ignoring it. This is " + "probably fine unless you see other errors. Error: %s", + plthook_error()); + else if (mono_handle) + load_mono_funcs(mono_handle); + } #endif - plthook_close(hook); -} \ No newline at end of file + if (hook_available) + plthook_close(hook); +} diff --git a/src/nix/plthook/plthook_osx.c b/src/nix/plthook/plthook_osx.c index 5a0010e..8786b26 100644 --- a/src/nix/plthook/plthook_osx.c +++ b/src/nix/plthook/plthook_osx.c @@ -6,7 +6,7 @@ * * ------------------------------------------------------ * - * Copyright 2014-2019 Kubo Takehiro + * Copyright 2014-2024 Kubo Takehiro * * Redistribution and use in source and binary forms, with or without modification, are * permitted provided that the following conditions are met: @@ -40,7 +40,6 @@ #include #include #include -#include #include #include #include @@ -613,354 +612,58 @@ static void set_bind_addr(data_t *data, unsigned int *idx, const char *sym_name, static int read_chained_fixups(data_t *d, const struct mach_header *mh, const char *image_name) { - const uint8_t *ptr = (const uint8_t *)mh + d->chained_fixups->dataoff; - const uint8_t *end = ptr + d->chained_fixups->datasize; - const struct dyld_chained_fixups_header *header = (const struct dyld_chained_fixups_header *)ptr; - const struct dyld_chained_import *import = (const struct dyld_chained_import *)(ptr + header->imports_offset); - const struct dyld_chained_import_addend *import_addend = (const struct dyld_chained_import_addend *)(ptr + header->imports_offset); - const struct dyld_chained_import_addend64 *import_addend64 = (const struct dyld_chained_import_addend64 *)(ptr + header->imports_offset); - const char *symbol_pool = (const char*)ptr + header->symbols_offset; - int rv = PLTHOOK_INTERNAL_ERROR; - size_t size; - uint32_t i; -#ifdef PLTHOOK_DEBUG_FIXUPS - const struct dyld_chained_starts_in_image *starts = (const struct dyld_chained_starts_in_image *)(ptr + header->starts_offset); - FILE *fp = NULL; -#endif - if (d->got_addr == 0) { - set_errmsg("__got section is not found in %s", image_name); - rv = PLTHOOK_INVALID_FILE_FORMAT; - goto cleanup; - } - - DEBUG_FIXUPS("dyld_chained_fixups_header\n" - " fixups_version %u\n" - " starts_offset %u\n" - " imports_offset %u\n" - " symbols_offset %u\n" - " imports_count %u\n" - " imports_format %u\n" - " symbols_format %u\n", - header->fixups_version, - header->starts_offset, - header->imports_offset, - header->symbols_offset, - header->imports_count, - header->imports_format, - header->symbols_format); - if (header->fixups_version != 0) { - set_errmsg("unknown chained fixups version %u", header->fixups_version); - rv = PLTHOOK_INVALID_FILE_FORMAT; - goto cleanup; - } - - size = offsetof(plthook_t, entries) + sizeof(bind_address_t) * header->imports_count; - d->plthook = (plthook_t*)calloc(1, size); - if (d->plthook == NULL) { - set_errmsg("failed to allocate memory: %" PRIuPTR " bytes", size); - rv = PLTHOOK_OUT_OF_MEMORY; - goto cleanup; - } - d->plthook->num_entries = header->imports_count; - d->plthook->readonly_segment = 1; - - switch (header->imports_format) { - case DYLD_CHAINED_IMPORT: - DEBUG_FIXUPS("dyld_chained_import\n"); - break; - case DYLD_CHAINED_IMPORT_ADDEND: - DEBUG_FIXUPS("dyld_chained_import_addend\n"); - break; - case DYLD_CHAINED_IMPORT_ADDEND64: - DEBUG_FIXUPS("dyld_chained_import_addend64\n"); - break; - default: - set_errmsg("unknown imports format %u", header->imports_format); - rv = PLTHOOK_INVALID_FILE_FORMAT; - goto cleanup; + (void)mh; + const struct segment_command_64 *linkedit = + d->segments[d->linkedit_segment_idx]; + const uint64_t fixups_fileoff = d->chained_fixups->dataoff; + const uint64_t fixups_size = d->chained_fixups->datasize; + + /* + * linkedit_data_command offsets are relative to the Mach-O slice, while + * __LINKEDIT is mapped at vmaddr + slide. Validate the complete range + * before translating the file offset to a live address. This translation + * is independent of the outer FAT file's slice offset. + */ + if (fixups_fileoff < linkedit->fileoff || + fixups_fileoff - linkedit->fileoff > linkedit->filesize || + fixups_size > + linkedit->filesize - (fixups_fileoff - linkedit->fileoff)) { + set_errmsg("chained-fixups data is outside __LINKEDIT in %s", + image_name); + return PLTHOOK_INVALID_FILE_FORMAT; } - - for (i = 0; i < header->imports_count; i++) { - struct dyld_chained_import_addend64 imp; - switch (header->imports_format) { - case DYLD_CHAINED_IMPORT: - imp.lib_ordinal = import[i].lib_ordinal; - imp.weak_import = import[i].weak_import; - imp.name_offset = import[i].name_offset; - imp.addend = 0; - break; - case DYLD_CHAINED_IMPORT_ADDEND: - imp.lib_ordinal = import_addend[i].lib_ordinal; - imp.weak_import = import_addend[i].weak_import; - imp.name_offset = import_addend[i].name_offset; - imp.addend = import_addend[i].addend; - break; - case DYLD_CHAINED_IMPORT_ADDEND64: - imp = import_addend64[i]; - break; - } - const char *name = symbol_pool + imp.name_offset; - if (name > (const char*)end) { - DEBUG_FIXUPS(" lib_ordinal %u, weak_import %u, name_offset %u, addend %llu\n", - imp.lib_ordinal, imp.weak_import, imp.name_offset, imp.addend); - set_errmsg("invalid symbol name address"); - rv = PLTHOOK_INVALID_FILE_FORMAT; - goto cleanup; - } - DEBUG_FIXUPS(" lib_ordinal %u, weak_import %u, name_offset %u (%s), addend %llu\n", - imp.lib_ordinal, imp.weak_import, imp.name_offset, name, imp.addend); - d->plthook->entries[i].name = name; - d->plthook->entries[i].addr = (void**)(d->got_addr + i * sizeof(void*)); + if (fixups_size < sizeof(struct dyld_chained_fixups_header)) { + set_errmsg("chained-fixups header is truncated in %s", image_name); + return PLTHOOK_INVALID_FILE_FORMAT; } -#ifdef PLTHOOK_DEBUG_FIXUPS - fp = fopen(image_name, "r"); - if (fp == NULL) { - set_errmsg("failed to open file %s (error: %s)", image_name, strerror(errno)); - rv = PLTHOOK_FILE_NOT_FOUND; - goto cleanup; + const uint8_t *ptr = (const uint8_t *)( + linkedit->vmaddr - linkedit->fileoff + d->slide + fixups_fileoff); + const struct dyld_chained_fixups_header *header = + (const struct dyld_chained_fixups_header *)ptr; + if (header->fixups_version != 0) { + set_errmsg("unknown chained fixups version %u in %s", + header->fixups_version, image_name); + return PLTHOOK_INVALID_FILE_FORMAT; } - DEBUG_FIXUPS("dyld_chained_starts_in_image\n" - " seg_count %u\n", - starts->seg_count); - for (i = 0; i < starts->seg_count; i++) { - DEBUG_FIXUPS(" seg_info_offset[%u] %u\n", - i, starts->seg_info_offset[i]); - if (starts->seg_info_offset[i] == 0) { - continue; - } - const struct dyld_chained_starts_in_segment* seg = (const struct dyld_chained_starts_in_segment*)((char*)starts + starts->seg_info_offset[i]); - uint16_t j; - DEBUG_FIXUPS(" dyld_chained_starts_in_segment\n" - " size %u\n" - " page_size 0x%x\n" - " pointer_format %u\n" - " segment_offset %llu (0x%llx)\n" - " max_valid_pointer %u\n" - " page_count %u\n", - seg->size, seg->page_size, seg->pointer_format, seg->segment_offset, seg->segment_offset, seg->max_valid_pointer, seg->page_count); - for (j = 0; j < seg->page_count; j++) { - uint16_t index = j; - uint16_t break_loop = 1; - off_t offset; - - if (seg->page_start[j] == DYLD_CHAINED_PTR_START_NONE) { - DEBUG_FIXUPS(" page_start[%u] DYLD_CHAINED_PTR_START_NONE\n", j); - continue; - } - if (seg->page_start[j] & DYLD_CHAINED_PTR_START_MULTI) { - index = seg->page_start[j] & ~DYLD_CHAINED_PTR_START_MULTI; - DEBUG_FIXUPS(" page_start[%u] (DYLD_CHAINED_PTR_START_MULTI | %u)\n", j, index); - break_loop = 0; - } - while (1) { - if (index != j) { - DEBUG_FIXUPS(" page_start[%u] %u\n", index, seg->page_start[index]); - } - offset = seg->segment_offset + j * seg->page_size + (seg->page_start[index] & ~DYLD_CHAINED_PTR_START_MULTI); - switch (seg->pointer_format) { - case DYLD_CHAINED_PTR_64_OFFSET: { - union { - struct dyld_chained_ptr_64_rebase rebase; - struct dyld_chained_ptr_64_bind bind; - } buf; - - do { - if (fseeko(fp, offset, SEEK_SET) != 0) { - set_errmsg("failed to seek to %lld in %s", offset, image_name); - rv = PLTHOOK_INVALID_FILE_FORMAT; - goto cleanup; - } - if (fread(&buf, sizeof(buf), 1, fp) != 1) { - set_errmsg("failed to read fixup chain from %s", image_name); - rv = PLTHOOK_INVALID_FILE_FORMAT; - goto cleanup; - } - if (buf.rebase.bind) { - DEBUG_FIXUPS(" dyld_chained_ptr_64_bind\n" - " ordinal %d\n" - " addend %d\n" - " reserved %d\n" - " next %d\n" - " bind %d\n", - buf.bind.ordinal, - buf.bind.addend, - buf.bind.reserved, - buf.bind.next, - buf.bind.bind); - } else { - DEBUG_FIXUPS(" dyld_chained_ptr_64_rebase\n" - " target %llu\n" - " high8 %d\n" - " reserved %d\n" - " next %d\n" - " bind %d\n", - buf.rebase.target, - buf.rebase.high8, - buf.rebase.reserved, - buf.rebase.next, - buf.rebase.bind); - } - offset += buf.bind.next * 4; - } while (buf.bind.next != 0); - break; - } - case DYLD_CHAINED_PTR_ARM64E: - case DYLD_CHAINED_PTR_ARM64E_KERNEL: - case DYLD_CHAINED_PTR_ARM64E_USERLAND: - case DYLD_CHAINED_PTR_ARM64E_USERLAND24: { - // The following code isn't tested. - union { - struct dyld_chained_ptr_arm64e_rebase rebase; - struct dyld_chained_ptr_arm64e_bind bind; - struct dyld_chained_ptr_arm64e_bind24 bind24; - struct dyld_chained_ptr_arm64e_auth_rebase auth_rebase; - struct dyld_chained_ptr_arm64e_auth_bind auth_bind; - struct dyld_chained_ptr_arm64e_auth_bind24 auth_bind24; - } buf; + DEBUG_FIXUPS("dyld_chained_fixups_header\n" + " fixups_version %u\n" + " imports_count %u\n", + header->fixups_version, header->imports_count); + + /* + * Import-table order is not GOT-slot order. The removed implementation + * paired import[i] with __got[i], and its on-disk chain walk did not add a + * universal/FAT slice base. Either strategy can patch or read unrelated + * data. Doorstop uses guarded DYLD_INTERPOSE hooks for dlsym and the + * UnityPlayer stdio calls on macOS, so reject chained PLT enumeration until + * a slice-aware, real-chain iterator is available. + */ + set_errmsg("chained-fixup PLT enumeration is unsupported for %s", + image_name); + return PLTHOOK_INTERNAL_ERROR; - do { - if (fseeko(fp, offset, SEEK_SET) != 0) { - set_errmsg("failed to seek to %lld in %s", offset, image_name); - rv = PLTHOOK_INVALID_FILE_FORMAT; - goto cleanup; - } - if (fread(&buf, sizeof(buf), 1, fp) != 1) { - set_errmsg("failed to read fixup chain from %s", image_name); - rv = PLTHOOK_INVALID_FILE_FORMAT; - goto cleanup; - } - if (!buf.rebase.auth) { - if (!buf.rebase.bind) { - DEBUG_FIXUPS(" dyld_chained_ptr_arm64e_rebase\n" - " target %llu\n" - " high8 %d\n" - " next %d\n" - " bind %d\n" // == 0 - " auth %d\n", // == 0 - buf.rebase.target, - buf.rebase.high8, - buf.rebase.next, - buf.rebase.bind, - buf.rebase.auth); - } else if (seg->pointer_format != DYLD_CHAINED_PTR_ARM64E_USERLAND24) { - DEBUG_FIXUPS(" dyld_chained_ptr_arm64e_bind\n" - " ordinal %d\n" - " zero %d\n" - " addend %d\n" - " next %d\n" - " bind %d\n" // == 1 - " auth %d\n", // == 0 - buf.bind.ordinal, - buf.bind.zero, - buf.bind.addend, - buf.bind.next, - buf.bind.bind, - buf.bind.auth); - } else { - DEBUG_FIXUPS(" dyld_chained_ptr_arm64e_bind24\n" - " ordinal %d\n" - " zero %d\n" - " addend %d\n" - " next %d\n" - " bind %d\n" // == 1 - " auth %d\n", // == 0 - buf.bind24.ordinal, - buf.bind24.zero, - buf.bind24.addend, - buf.bind24.next, - buf.bind24.bind, - buf.bind24.auth); - } - } else { - if (!buf.rebase.bind) { - DEBUG_FIXUPS(" dyld_chained_ptr_arm64e_auth_rebase\n" - " target %u\n" - " diversity %d\n" - " addrDiv %d\n" - " key %d\n" - " next %d\n" - " bind %d\n" // == 0 - " auth %d\n", // == 1 - buf.auth_rebase.target, - buf.auth_rebase.diversity, - buf.auth_rebase.addrDiv, - buf.auth_rebase.key, - buf.auth_rebase.next, - buf.auth_rebase.bind, - buf.auth_rebase.auth); - } else if (seg->pointer_format != DYLD_CHAINED_PTR_ARM64E_USERLAND24) { - DEBUG_FIXUPS(" dyld_chained_ptr_arm64e_auth_bind\n" - " ordinal %d\n" - " zero %d\n" - " diversity %d\n" - " addrDiv %d\n" - " key %d\n" - " next %d\n" - " bind %d\n" // == 1 - " auth %d\n", // == 1 - buf.auth_bind.ordinal, - buf.auth_bind.zero, - buf.auth_bind.diversity, - buf.auth_bind.addrDiv, - buf.auth_bind.key, - buf.auth_bind.next, - buf.auth_bind.bind, - buf.auth_bind.auth); - } else { - DEBUG_FIXUPS(" dyld_chained_ptr_arm64e_auth_bind24\n" - " ordinal %d\n" - " zero %d\n" - " diversity %d\n" - " addrDiv %d\n" - " key %d\n" - " next %d\n" - " bind %d\n" // == 1 - " auth %d\n", // == 1 - buf.auth_bind24.ordinal, - buf.auth_bind24.zero, - buf.auth_bind24.diversity, - buf.auth_bind24.addrDiv, - buf.auth_bind24.key, - buf.auth_bind24.next, - buf.auth_bind24.bind, - buf.auth_bind24.auth); - } - } - if (seg->pointer_format == DYLD_CHAINED_PTR_ARM64E_KERNEL) { - offset += buf.rebase.next * 4; - } else { - offset += buf.rebase.next * 8; - } - } while (buf.rebase.next != 0); - break; - } - default: - DEBUG_FIXUPS("unsupported pointer_format: %u\n", seg->pointer_format); - break_loop = 1; - break; - } - if (break_loop) { - break; - } - break_loop = seg->page_start[++index] & DYLD_CHAINED_PTR_START_MULTI; - } // while (1) */ - } - } -#endif - rv = 0; -cleanup: -#ifdef PLTHOOK_DEBUG_FIXUPS - if (fp != NULL) { - fclose(fp); - } -#endif - if (rv != 0 && d->plthook) { - free(d->plthook); - d->plthook = NULL; - } - return rv; } int plthook_enum(plthook_t *plthook, unsigned int *pos, const char **name_out, void ***addr_out) diff --git a/src/windows/config.c b/src/windows/config.c index 6e90a44..8457884 100644 --- a/src/windows/config.c +++ b/src/windows/config.c @@ -5,9 +5,28 @@ #define CONFIG_NAME TEXT("doorstop_config.ini") #define DEFAULT_TARGET_ASSEMBLY TEXT("Doorstop.dll") +#define DEFAULT_MONO_DEBUG_ADDRESS TEXT("127.0.0.1:10000") +#define DOORSTOP_ARG_PREFIX TEXT("--doorstop-") #define EXE_EXTENSION_LENGTH 4 #define STR_EQUAL(str1, str2) (lstrcmpi(str1, str2) == 0) +static char_t *copy_string(const char_t *value) { + if (!value) + return NULL; + + const size_t len = strlen(value) + 1; + char_t *result = malloc(sizeof(char_t) * len); + strncpy(result, value, len); + return result; +} + +static bool_t is_doorstop_arg(const char_t *value) { + const size_t prefix_len = STR_LEN(DOORSTOP_ARG_PREFIX) - 1; + return value && strlen(value) >= prefix_len && + CompareString(LOCALE_INVARIANT, NORM_IGNORECASE, value, prefix_len, + DOORSTOP_ARG_PREFIX, prefix_len) == CSTR_EQUAL; +} + void load_bool_file(const char_t *path, const char_t *section, const char_t *key, const char_t *def, bool_t *value) { char_t enabled_string[256] = TEXT("true"); @@ -40,28 +59,51 @@ char_t *get_ini_entry(const char_t *config_file, const char_t *section, bool_t load_str_file(const char_t *path, const char_t *section, const char_t *key, const char_t *def, char_t **value) { char_t *tmp = get_ini_entry(path, section, key, def); - LOG("CONFIG: %s.%s = %s", section, key, tmp); - if (!tmp || strlen(tmp) == 0) + if (!tmp || strlen(tmp) == 0) { + LOG("CONFIG: %s.%s is empty", section, key); + if (tmp) + free(tmp); return FALSE; + } + + LOG("CONFIG: %s.%s = %s", section, key, tmp); + if (*value) + free(*value); *value = tmp; return TRUE; } void load_path_file(const char_t *path, const char_t *section, const char_t *key, const char_t *def, char_t **value) { - if (!load_str_file(path, section, key, def, value)) + char_t *tmp = NULL; + if (!load_str_file(path, section, key, def, &tmp)) + return; + + char_t *full_path = get_full_path(tmp); + if (!full_path) { + LOG("CONFIG: Failed to resolve %s.%s path: %s", section, key, tmp); + free(tmp); return; - char_t *tmp = *value; - *value = get_full_path(tmp); - LOG("(%s.%s) %s => %s", section, key, tmp, *value); + } + + LOG("(%s.%s) %s => %s", section, key, tmp, full_path); + if (*value) + free(*value); + *value = full_path; free(tmp); } static inline void init_config_file() { + config.mono_debug_address = copy_string(DEFAULT_MONO_DEBUG_ADDRESS); + if (!file_exists(CONFIG_NAME)) return; char_t *config_path = get_full_path(CONFIG_NAME); + if (!config_path) { + LOG("Failed to resolve config file path"); + return; + } load_bool_file(config_path, TEXT("General"), TEXT("enabled"), TEXT("true"), &config.enabled); @@ -82,7 +124,7 @@ static inline void init_config_file() { load_bool_file(config_path, TEXT("UnityMono"), TEXT("debug_suspend"), TEXT("false"), &config.mono_debug_suspend); load_str_file(config_path, TEXT("UnityMono"), TEXT("debug_address"), - TEXT("127.0.0.1:10000"), &config.mono_debug_address); + DEFAULT_MONO_DEBUG_ADDRESS, &config.mono_debug_address); load_path_file(config_path, TEXT("Il2Cpp"), TEXT("coreclr_path"), NULL, &config.clr_runtime_coreclr_path); @@ -94,40 +136,75 @@ static inline void init_config_file() { bool_t load_bool_argv(char_t **argv, int *i, int argc, const char_t *arg_name, bool_t *value) { - if (STR_EQUAL(argv[*i], arg_name) && *i < argc) { - char_t *par = argv[++*i]; - if (STR_EQUAL(par, TEXT("true"))) - *value = TRUE; - else if (STR_EQUAL(par, TEXT("false"))) - *value = FALSE; - LOG("ARGV: %s = %s", arg_name, par); + if (!argv || !i || *i < 0 || *i >= argc || !argv[*i] || + !STR_EQUAL(argv[*i], arg_name)) + return FALSE; + + if (*i + 1 >= argc || !argv[*i + 1] || is_doorstop_arg(argv[*i + 1])) { + LOG("ARGV: Missing value for %s", arg_name); return TRUE; } - return FALSE; + + char_t *par = argv[++*i]; + if (STR_EQUAL(par, TEXT("true"))) + *value = TRUE; + else if (STR_EQUAL(par, TEXT("false"))) + *value = FALSE; + else { + LOG("ARGV: Invalid boolean value for %s: %s", arg_name, par); + return TRUE; + } + + LOG("ARGV: %s = %s", arg_name, par); + return TRUE; } bool_t load_str_argv(char_t **argv, int *i, int argc, const char_t *arg_name, char_t **value) { - if (STR_EQUAL(argv[*i], arg_name) && *i < argc) { - if (*value != NULL) - free(*value); - const size_t len = strlen(argv[*i + 1]) + 1; - *value = malloc(sizeof(char_t) * len); - strncpy(*value, argv[++*i], len); - LOG("ARGV: %s = %s", arg_name, *value); + if (!argv || !i || *i < 0 || *i >= argc || !argv[*i] || + !STR_EQUAL(argv[*i], arg_name)) + return FALSE; + + if (*i + 1 >= argc || !argv[*i + 1] || is_doorstop_arg(argv[*i + 1])) { + LOG("ARGV: Missing value for %s", arg_name); return TRUE; } - return FALSE; + + char_t *new_value = copy_string(argv[++*i]); + if (*value) + free(*value); + *value = new_value; + LOG("ARGV: %s = %s", arg_name, *value); + return TRUE; } bool_t load_path_argv(char_t **argv, int *i, int argc, const char_t *arg_name, char_t **value) { - if (!load_str_argv(argv, i, argc, arg_name, value)) + if (!argv || !i || *i < 0 || *i >= argc || !argv[*i] || + !STR_EQUAL(argv[*i], arg_name)) return FALSE; - char_t *tmp = *value; - *value = get_full_path(tmp); - LOG("(%s) %s => %s", arg_name, tmp, *value); - free(tmp); + + if (*i + 1 >= argc || !argv[*i + 1] || is_doorstop_arg(argv[*i + 1])) { + LOG("ARGV: Missing value for %s", arg_name); + return TRUE; + } + + char_t *path = argv[++*i]; + if (strlen(path) == 0) { + LOG("ARGV: Empty path for %s", arg_name); + return TRUE; + } + + char_t *full_path = get_full_path(path); + if (!full_path) { + LOG("ARGV: Failed to resolve path for %s: %s", arg_name, path); + return TRUE; + } + + LOG("(%s) %s => %s", arg_name, path, full_path); + if (*value) + free(*value); + *value = full_path; return TRUE; } @@ -135,6 +212,10 @@ static inline void init_cmd_args() { char_t *args = GetCommandLine(); int argc = 0; char_t **argv = CommandLineToArgv(args, &argc); + if (!argv) { + LOG("Failed to parse command line arguments"); + return; + } #define PARSE_ARG(name, dest, parser) \ if (parser(argv, &i, argc, name, &(dest))) \ diff --git a/src/windows/entrypoint.c b/src/windows/entrypoint.c index 7de774b..2e25e4d 100644 --- a/src/windows/entrypoint.c +++ b/src/windows/entrypoint.c @@ -139,11 +139,14 @@ void capture_mono_path(void *handle) { } bool_t initialized = FALSE; -void *WINAPI get_proc_address_detour(void *module, char *name) { +typedef FARPROC(WINAPI *get_proc_address_fn)(HMODULE module, LPCSTR name); +static get_proc_address_fn previous_get_proc_address = GetProcAddress; + +FARPROC WINAPI get_proc_address_detour(HMODULE module, LPCSTR name) { // If the lpProcName pointer contains an ordinal rather than a string, // high-word value of the pointer is zero (see PR #66) #define REDIRECT_INIT(init_name, init_func, target, extra_init) \ - if (HIWORD(name) && lstrcmpA(name, init_name) == 0) { \ + if (!IS_INTRESOURCE(name) && lstrcmpA(name, init_name) == 0) { \ if (!initialized) { \ initialized = TRUE; \ LOG("Got %S at %p", init_name, module); \ @@ -151,7 +154,7 @@ void *WINAPI get_proc_address_detour(void *module, char *name) { init_func(module); \ LOG("Loaded all runtime functions\n") \ } \ - return (void *)(target); \ + return (FARPROC)(target); \ } REDIRECT_INIT("il2cpp_init", load_il2cpp_funcs, init_il2cpp, {}); @@ -165,7 +168,9 @@ void *WINAPI get_proc_address_detour(void *module, char *name) { REDIRECT_INIT("mono_debug_init", load_mono_funcs, hook_mono_debug_init, capture_mono_path(module)); - return (void *)GetProcAddress(module, name); + // Preserve hooks installed before Doorstop. Calling the kernel32 export + // directly here would bypass RenderDoc/apitrace's previous IAT detour. + return previous_get_proc_address(module, name); #undef REDIRECT_INIT } @@ -213,9 +218,21 @@ void inject(DoorstopPaths const *paths) { LOG("Installing IAT hooks"); bool_t ok = TRUE; -#define HOOK_SYS(mod, from, to) ok &= iat_hook(mod, "kernel32.dll", &from, &to) +#define HOOK_SYS(mod, from, to) \ + ok &= iat_hook(mod, "kernel32.dll", #from, (void *)&from, (void *)&to, \ + NULL) + + void *previous_get_proc = NULL; + bool_t get_proc_hooked = + iat_hook(target_module, "kernel32.dll", "GetProcAddress", + (void *)&GetProcAddress, (void *)&get_proc_address_detour, + &previous_get_proc); + ok &= get_proc_hooked; + if (get_proc_hooked && previous_get_proc && + previous_get_proc != (void *)&get_proc_address_detour) { + previous_get_proc_address = (get_proc_address_fn)previous_get_proc; + } - HOOK_SYS(target_module, GetProcAddress, get_proc_address_detour); HOOK_SYS(target_module, CloseHandle, close_handle_hook); if (config.boot_config_override) { if (file_exists(config.boot_config_override)) { @@ -284,12 +301,17 @@ BOOL WINAPI DllEntry(HINSTANCE hInstDll, DWORD reasonForDllLoad, load_config(); LOG("Config loaded"); + if (config.ignore_disabled_env) { + SetEnvironmentVariableW(L"DOORSTOP_INITIALIZED", NULL); + SetEnvironmentVariableW(L"DOORSTOP_DISABLE", NULL); + LOG("Cleared inherited DOORSTOP_INITIALIZED / DOORSTOP_DISABLE"); + } redirect_output_log(paths); - if (!file_exists(config.target_assembly)) { + if (!config.target_assembly || !file_exists(config.target_assembly)) { LOG("Could not find target assembly!"); - config.enabled = FALSE; + LOG("Continuing without a managed entrypoint"); } inject(paths); diff --git a/src/windows/hook.h b/src/windows/hook.h index 41167e1..9beda7b 100644 --- a/src/windows/hook.h +++ b/src/windows/hook.h @@ -9,6 +9,7 @@ #define HOOK_H #include "../util/util.h" +#include #include // PE format uses RVAs (Relative Virtual Addresses) to save addresses relative @@ -19,58 +20,230 @@ // in the PE file #define RVA2PTR(t, base, rva) ((t)(((PCHAR)(base)) + (rva))) +static bool_t iat_rva_valid(size_t image_size, ULONG_PTR rva, + size_t value_size) { + if (rva > image_size) + return FALSE; + return value_size <= image_size - (size_t)rva; +} + +static char iat_ascii_fold(char value) { + if (value >= 'A' && value <= 'Z') + return (char)(value + ('a' - 'A')); + return value; +} + +static bool_t iat_string_equals(char const *candidate, size_t candidate_size, + char const *expected, + bool_t case_insensitive) { + if (!candidate || !expected) + return FALSE; + + for (size_t i = 0; i < candidate_size; i++) { + char candidate_char = candidate[i]; + char expected_char = expected[i]; + if (case_insensitive) { + candidate_char = iat_ascii_fold(candidate_char); + expected_char = iat_ascii_fold(expected_char); + } + + if (candidate_char != expected_char) + return FALSE; + if (candidate_char == '\0') + return TRUE; + } + + // The string in the image was not terminated inside the mapped image. + return FALSE; +} + +static bool_t iat_replace_thunk(IMAGE_THUNK_DATA *thunk, + void *detour_function, + void **previous_function) { + if (!thunk || !detour_function) + return FALSE; + + DWORD old_state; + if (!VirtualProtect(&thunk->u1.Function, sizeof(thunk->u1.Function), + PAGE_READWRITE, &old_state)) + return FALSE; + + void *previous = InterlockedExchangePointer( + (PVOID volatile *)&thunk->u1.Function, detour_function); + + DWORD ignored_state; + VirtualProtect(&thunk->u1.Function, sizeof(thunk->u1.Function), old_state, + &ignored_state); + + if (previous_function) + *previous_function = previous; + return TRUE; +} + /** - * @brief Hooks the given function through the Import Address Table. - * This is a simplified version that doesn't does lookup directly in the - * initialized IAT. - * This is usable to hook system DLLs like kernel32.dll assuming the process - * wasn't already hooked. + * @brief Hooks an imported function through a module's Import Address Table. + * + * When OriginalFirstThunk is available, the immutable import metadata is used + * to identify the slot by DLL and function name. This continues to work when + * another tool has already replaced the initialized IAT value, provided the + * caller requests the previous function so it can preserve the hook chain. + * Images without OriginalFirstThunk safely fall back to matching the current + * IAT value. * * @param dll Module to hook - * @param target_dll Name of the target DLL to search in the IAT - * @param target_function Address of the target function to hook + * @param target_dll Name of the target DLL to search in the import table + * @param target_import Name of the imported function + * @param target_function Original function address used only for safe fallback * @param detour_function Address of the detour function + * @param previous_function Receives the function currently stored in the slot * @return bool_t TRUE if successful, otherwise FALSE */ -static bool_t iat_hook(void *dll, char const *target_dll, void *target_function, - void *detour_function) { - IMAGE_DOS_HEADER *mz = (PIMAGE_DOS_HEADER)dll; - - IMAGE_NT_HEADERS *nt = RVA2PTR(PIMAGE_NT_HEADERS, mz, mz->e_lfanew); - - IMAGE_IMPORT_DESCRIPTOR *imports = - RVA2PTR(IMAGE_IMPORT_DESCRIPTOR *, mz, - nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT] - .VirtualAddress); - - for (int i = 0; imports[i].Characteristics; i++) { - char *name = RVA2PTR(char *, mz, imports[i].Name); - - if (lstrcmpiA(name, target_dll) != 0) +static bool_t iat_hook(void *dll, char const *target_dll, + char const *target_import, void *target_function, + void *detour_function, void **previous_function) { + if (previous_function) + *previous_function = NULL; + if (!dll || !target_dll || !target_import || !target_function || + !detour_function) + return FALSE; + + MEMORY_BASIC_INFORMATION memory_info; + if (!VirtualQuery(dll, &memory_info, sizeof(memory_info)) || + memory_info.State != MEM_COMMIT) + return FALSE; + + PBYTE base = (PBYTE)dll; + PBYTE region_end = (PBYTE)memory_info.BaseAddress + memory_info.RegionSize; + if (base < (PBYTE)memory_info.BaseAddress || base >= region_end || + memory_info.AllocationBase != dll) + return FALSE; + size_t header_region_size = (size_t)(region_end - base); + if (header_region_size < sizeof(IMAGE_NT_HEADERS)) + return FALSE; + + IMAGE_DOS_HEADER *mz = (IMAGE_DOS_HEADER *)base; + if (mz->e_magic != IMAGE_DOS_SIGNATURE || mz->e_lfanew <= 0 || + (size_t)mz->e_lfanew > + header_region_size - sizeof(IMAGE_NT_HEADERS)) + return FALSE; + + IMAGE_NT_HEADERS *nt = + RVA2PTR(IMAGE_NT_HEADERS *, base, (size_t)mz->e_lfanew); + if (nt->Signature != IMAGE_NT_SIGNATURE || + nt->FileHeader.SizeOfOptionalHeader < sizeof(IMAGE_OPTIONAL_HEADER) || + nt->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR_MAGIC || + nt->OptionalHeader.NumberOfRvaAndSizes <= + IMAGE_DIRECTORY_ENTRY_IMPORT) + return FALSE; + + size_t image_size = nt->OptionalHeader.SizeOfImage; + IMAGE_DATA_DIRECTORY import_directory = + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; + if (image_size == 0 || image_size > MAXULONG_PTR - (ULONG_PTR)base || + import_directory.VirtualAddress == 0 || + import_directory.Size < sizeof(IMAGE_IMPORT_DESCRIPTOR) || + !iat_rva_valid(image_size, import_directory.VirtualAddress, + import_directory.Size)) + return FALSE; + + MEMORY_BASIC_INFORMATION image_end_info; + if (!VirtualQuery(base + image_size - 1, &image_end_info, + sizeof(image_end_info)) || + image_end_info.AllocationBase != memory_info.AllocationBase) + return FALSE; + + IMAGE_IMPORT_DESCRIPTOR *imports = RVA2PTR( + IMAGE_IMPORT_DESCRIPTOR *, base, import_directory.VirtualAddress); + size_t import_count = + import_directory.Size / sizeof(IMAGE_IMPORT_DESCRIPTOR); + + for (size_t i = 0; i < import_count; i++) { + IMAGE_IMPORT_DESCRIPTOR *import = &imports[i]; + if (import->Name == 0 && import->FirstThunk == 0 && + import->OriginalFirstThunk == 0) + break; + if (import->Name == 0 || import->FirstThunk == 0 || + !iat_rva_valid(image_size, import->Name, 1)) + return FALSE; + + char *dll_name = RVA2PTR(char *, base, import->Name); + if (!iat_string_equals(dll_name, image_size - import->Name, + target_dll, TRUE)) continue; - void **thunk = RVA2PTR(void **, mz, imports[i].FirstThunk); + if (!iat_rva_valid(image_size, import->FirstThunk, + sizeof(IMAGE_THUNK_DATA))) + return FALSE; + + IMAGE_THUNK_DATA *first_thunks = RVA2PTR( + IMAGE_THUNK_DATA *, base, import->FirstThunk); + size_t first_thunk_count = + (image_size - import->FirstThunk) / sizeof(IMAGE_THUNK_DATA); - for (; *thunk; thunk++) { - void *import = *thunk; + if (import->OriginalFirstThunk == 0) { + // Bound imports can omit OriginalFirstThunk. In that case only an + // untouched slot can be identified safely by its current value. + if (!target_function) + return FALSE; + for (size_t j = 0; j < first_thunk_count; j++) { + ULONG_PTR current = first_thunks[j].u1.Function; + if (current == 0) + break; + if ((void *)current == target_function) + return iat_replace_thunk(&first_thunks[j], + detour_function, + previous_function); + } + return FALSE; + } - if (import != target_function) + if (!iat_rva_valid(image_size, import->OriginalFirstThunk, + sizeof(IMAGE_THUNK_DATA))) + return FALSE; + + IMAGE_THUNK_DATA *original_thunks = RVA2PTR( + IMAGE_THUNK_DATA *, base, import->OriginalFirstThunk); + size_t original_thunk_count = + (image_size - import->OriginalFirstThunk) / + sizeof(IMAGE_THUNK_DATA); + size_t thunk_count = first_thunk_count < original_thunk_count + ? first_thunk_count + : original_thunk_count; + + for (size_t j = 0; j < thunk_count; j++) { + ULONG_PTR lookup = original_thunks[j].u1.AddressOfData; + if (lookup == 0) + break; + if (IMAGE_SNAP_BY_ORDINAL(lookup)) continue; - DWORD old_state; - if (!VirtualProtect(thunk, sizeof(void *), PAGE_READWRITE, - &old_state)) + size_t name_offset = offsetof(IMAGE_IMPORT_BY_NAME, Name); + if (!iat_rva_valid(image_size, lookup, name_offset + 1)) return FALSE; - *thunk = (void *)detour_function; - - VirtualProtect(thunk, sizeof(void *), old_state, &old_state); + char *import_name = + RVA2PTR(char *, base, lookup + name_offset); + if (!iat_string_equals(import_name, + image_size - (size_t)lookup - name_offset, + target_import, FALSE)) + continue; - return TRUE; + if (first_thunks[j].u1.Function == 0) + return FALSE; + if ((void *)first_thunks[j].u1.Function != target_function && + !previous_function) { + // The slot is already hooked, but the caller is not prepared + // to retain and call through the previous implementation. + return FALSE; + } + return iat_replace_thunk(&first_thunks[j], detour_function, + previous_function); } + + return FALSE; } return FALSE; } -#endif \ No newline at end of file +#endif diff --git a/src/windows/proxy/proxy.h b/src/windows/proxy/proxy.h index 277ed3c..e1ee64c 100644 --- a/src/windows/proxy/proxy.h +++ b/src/windows/proxy/proxy.h @@ -21,10 +21,15 @@ static inline void load_proxy(char_t *module_name) { char_t *alt_full_path = get_full_path(alt_name); free(alt_name); - LOG("Looking for original DLL from %s", alt_full_path); - - void *handle = dlopen(alt_full_path, RTLD_LAZY); - free(alt_full_path); + void *handle = NULL; + if (alt_full_path) { + LOG("Looking for original DLL from %s", alt_full_path); + + handle = dlopen(alt_full_path, RTLD_LAZY); + free(alt_full_path); + } else { + LOG("Failed to resolve alternate DLL path"); + } if (handle == NULL) { UINT sys_len = GetSystemDirectory(NULL, 0); @@ -47,4 +52,4 @@ static inline void load_proxy(char_t *module_name) { load_functions(handle); } -#endif \ No newline at end of file +#endif diff --git a/src/windows/util.c b/src/windows/util.c index aa4db28..aa645ba 100644 --- a/src/windows/util.c +++ b/src/windows/util.c @@ -50,19 +50,35 @@ size_t get_module_path(void *module, char_t **result, size_t *size, } char_t *get_full_path(char_t *path) { + if (!path || !path[0]) + return NULL; + const DWORD needed = GetFullPathName(path, 0, NULL, NULL); + if (needed == 0) + return NULL; + char_t *res = malloc(sizeof(char_t) * needed); - GetFullPathName(path, needed, res, NULL); + const DWORD written = GetFullPathName(path, needed, res, NULL); + if (written == 0 || written >= needed) { + free(res); + return NULL; + } return res; } bool_t file_exists(char_t *file) { + if (!file || !file[0]) + return FALSE; + DWORD ab = GetFileAttributes(file); return ab != INVALID_FILE_ATTRIBUTES && (ab & FILE_ATTRIBUTE_DIRECTORY) == 0; } bool_t folder_exists(char_t *folder) { + if (!folder || !folder[0]) + return FALSE; + DWORD ab = GetFileAttributes(folder); return ab != INVALID_FILE_ATTRIBUTES && (ab & FILE_ATTRIBUTE_DIRECTORY) != 0; @@ -78,36 +94,60 @@ typedef struct { size_t ext; size_t parent; size_t len; + bool_t has_parent; } PathParts; PathParts split_path(char_t *path) { + if (!path || !path[0]) + return (PathParts){0}; + size_t len = strlen(path); size_t ext = len; - size_t i; - for (i = len - 1; i > 0; i--) { - char_t c = path[i]; + size_t parent = 0; + bool_t has_parent = FALSE; + for (size_t i = len; i > 0; i--) { + const size_t pos = i - 1; + char_t c = path[pos]; if (c == TEXT('.') && ext == len) - ext = i; - else if (c == TEXT('\\') || c == TEXT('/')) + ext = pos; + else if (c == TEXT('\\') || c == TEXT('/')) { + parent = pos; + has_parent = TRUE; break; + } } - return (PathParts){.ext = ext, .parent = i, .len = len}; + return (PathParts){.ext = ext, + .parent = parent, + .len = len, + .has_parent = has_parent}; } char_t *get_folder_name(char_t *path) { + if (!path || !path[0]) + return NULL; + PathParts parts = split_path(path); - char_t *result = malloc((parts.parent + 1) * sizeof(char_t)); - strncpy(result, path, parts.parent); - result[parts.parent] = TEXT('\0'); + const size_t result_len = parts.has_parent ? parts.parent : 0; + char_t *result = malloc((result_len + 1) * sizeof(char_t)); + strncpy(result, path, result_len); + result[result_len] = TEXT('\0'); return result; } char_t *get_file_name(char_t *path, bool_t with_ext) { + if (!path || !path[0]) + return NULL; + PathParts parts = split_path(path); - size_t result_len = (with_ext ? parts.len : parts.ext) - parts.parent; - char_t *result = malloc(result_len * sizeof(char_t)); - strncpy(result, path + parts.parent + 1, result_len - 1); - result[result_len - 1] = TEXT('\0'); + const size_t start = parts.has_parent ? parts.parent + 1 : 0; + size_t end = with_ext ? parts.len : parts.ext; + if (end < start) + end = parts.len; + + const size_t result_len = end - start; + char_t *result = malloc((result_len + 1) * sizeof(char_t)); + strncpy(result, path + start, result_len); + result[result_len] = TEXT('\0'); return result; } diff --git a/tests/nix/fixtures/fake-arch.sh b/tests/nix/fixtures/fake-arch.sh new file mode 100644 index 0000000..09c1f7e --- /dev/null +++ b/tests/nix/fixtures/fake-arch.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +if [ "${DYLD_INSERT_LIBRARIES+x}" = x ]; then + echo "DYLD_INSERT_LIBRARIES leaked into arch" 1>&2 + exit 1 +fi + +[ "$1" = "-e" ] || exit 2 +case "$2" in + DYLD_INSERT_LIBRARIES=*) inserted=${2#DYLD_INSERT_LIBRARIES=} ;; + *) exit 2 ;; +esac + +[ "${inserted}" = "${EXPECTED_DYLD_INSERT_LIBRARIES}" ] || { + echo "arch received unexpected inserted libraries: ${inserted}" 1>&2 + exit 1 +} + +printf '%s\n' 'arch-ok' diff --git a/tests/nix/fixtures/fake-defaults.sh b/tests/nix/fixtures/fake-defaults.sh new file mode 100644 index 0000000..c80803b --- /dev/null +++ b/tests/nix/fixtures/fake-defaults.sh @@ -0,0 +1,2 @@ +#!/bin/sh +printf '%s\n' 'GameBin' diff --git a/tests/nix/fixtures/fake-file.sh b/tests/nix/fixtures/fake-file.sh new file mode 100644 index 0000000..420d713 --- /dev/null +++ b/tests/nix/fixtures/fake-file.sh @@ -0,0 +1,2 @@ +#!/bin/sh +printf '%s\n' 'Mach-O 64-bit executable' diff --git a/tests/nix/fixtures/fake-sysctl.sh b/tests/nix/fixtures/fake-sysctl.sh new file mode 100644 index 0000000..d196728 --- /dev/null +++ b/tests/nix/fixtures/fake-sysctl.sh @@ -0,0 +1,2 @@ +#!/bin/sh +printf '%s\n' 'Apple M4' diff --git a/tests/nix/fixtures/fake-uname.sh b/tests/nix/fixtures/fake-uname.sh new file mode 100644 index 0000000..6c14aee --- /dev/null +++ b/tests/nix/fixtures/fake-uname.sh @@ -0,0 +1,2 @@ +#!/bin/sh +printf '%s\n' 'Darwin' diff --git a/tests/nix/fixtures/game.sh b/tests/nix/fixtures/game.sh new file mode 100644 index 0000000..51069c9 --- /dev/null +++ b/tests/nix/fixtures/game.sh @@ -0,0 +1,12 @@ +#!/bin/sh +case "${1-}" in + --print-search-path) + printf '%s\n' "${DOORSTOP_MONO_DLL_SEARCH_PATH_OVERRIDE-}" + ;; + --print-target-assembly) + printf '[%s]\n' "${DOORSTOP_TARGET_ASSEMBLY-}" + ;; + *) + printf '%s\n' 'game-ok' + ;; +esac diff --git a/tests/nix/fixtures/macos-dlsym-smoke.c b/tests/nix/fixtures/macos-dlsym-smoke.c new file mode 100644 index 0000000..801fe35 --- /dev/null +++ b/tests/nix/fixtures/macos-dlsym-smoke.c @@ -0,0 +1,93 @@ +#include +#include +#include +#include +#include +#include + +static int original_marker; + +__attribute__((visibility("default"))) void * +mono_jit_init_version(const char *root_domain_name, + const char *runtime_version) { + (void)root_domain_name; + (void)runtime_version; + return &original_marker; +} + +typedef void *(*mono_init_fn)(const char *, const char *); + +extern int unityplayer_read_first_byte(const char *path); +extern int unityplayer_redirect_stdout(int fd); +extern int unityplayer_close_stdout(void); + +int main(int argc, char **argv) { + if (argc != 2 || + (strcmp(argv[1], "enabled") != 0 && + strcmp(argv[1], "disabled") != 0)) { + fprintf(stderr, "usage: %s enabled|disabled\n", argv[0]); + return 2; + } + + dlerror(); + void *resolved = dlsym(RTLD_DEFAULT, "mono_jit_init_version"); + const char *error = dlerror(); + if (!resolved || error) { + fprintf(stderr, "target lookup failed: %s\n", + error ? error : "unknown error"); + return 1; + } + + if (strcmp(argv[1], "disabled") == 0) { + mono_init_fn original = (mono_init_fn)resolved; + if (original("smoke", "v4.0") != &original_marker) { + fputs("disabled interposer did not return the original symbol\n", + stderr); + return 1; + } + return 0; + } + + dlerror(); + void *doorstop_init = dlsym(RTLD_DEFAULT, "init_mono"); + error = dlerror(); + if (!doorstop_init || error) { + fprintf(stderr, "Doorstop init lookup failed: %s\n", + error ? error : "unknown error"); + return 1; + } + if (resolved != doorstop_init) { + fprintf(stderr, "dlsym was not redirected: got %p, expected %p\n", + resolved, doorstop_init); + return 1; + } + + const char *default_boot_config = getenv("EXPECTED_BOOT_CONFIG_PATH"); + const char *redirect_path = getenv("REDIRECT_OUTPUT_PATH"); + if (!default_boot_config || !redirect_path) { + fputs("missing interposition smoke-test paths\n", stderr); + return 1; + } + if (unityplayer_read_first_byte(default_boot_config) != 'o') { + fputs("UnityPlayer fopen was not redirected\n", stderr); + return 1; + } + + int redirect_fd = open(redirect_path, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (redirect_fd < 0 || + unityplayer_redirect_stdout(redirect_fd) != STDOUT_FILENO) { + fputs("UnityPlayer dup2 interception failed\n", stderr); + return 1; + } + close(redirect_fd); + if (unityplayer_close_stdout() != 0) { + fputs("UnityPlayer fclose interception failed\n", stderr); + return 1; + } + if (write(STDOUT_FILENO, "unity-interpose-ok\n", 19) != 19) { + fputs("UnityPlayer closed or redirected stdout\n", stderr); + return 1; + } + + return 0; +} diff --git a/tests/nix/fixtures/macos-unityplayer-interpose.c b/tests/nix/fixtures/macos-unityplayer-interpose.c new file mode 100644 index 0000000..6161180 --- /dev/null +++ b/tests/nix/fixtures/macos-unityplayer-interpose.c @@ -0,0 +1,22 @@ +#include +#include + +__attribute__((visibility("default"))) int +unityplayer_read_first_byte(const char *path) { + FILE *file = fopen(path, "r"); + if (!file) + return -1; + int value = fgetc(file); + fclose(file); + return value; +} + +__attribute__((visibility("default"))) int +unityplayer_redirect_stdout(int fd) { + return dup2(fd, STDOUT_FILENO); +} + +__attribute__((visibility("default"))) int +unityplayer_close_stdout(void) { + return fclose(stdout); +} diff --git a/tests/nix/fixtures/unityplayer-dup2-smoke.c b/tests/nix/fixtures/unityplayer-dup2-smoke.c new file mode 100644 index 0000000..e15613e --- /dev/null +++ b/tests/nix/fixtures/unityplayer-dup2-smoke.c @@ -0,0 +1,38 @@ +#include +#include + +extern int unityplayer_redirect_output(int destination); + +int main(void) { + int output_pipe[2]; + if (pipe(output_pipe) != 0) + return 2; + + int saved_stdout = dup(STDOUT_FILENO); + if (saved_stdout < 0) + return 2; + + int result = unityplayer_redirect_output(output_pipe[1]); + static const char visible[] = "visible"; + if (write(STDOUT_FILENO, visible, sizeof(visible) - 1) != + (ssize_t)(sizeof(visible) - 1)) + return 2; + if (dup2(saved_stdout, STDOUT_FILENO) != STDOUT_FILENO) + return 2; + close(saved_stdout); + close(output_pipe[1]); + + char unexpected; + ssize_t captured = read(output_pipe[0], &unexpected, sizeof(unexpected)); + close(output_pipe[0]); + + if (result != STDOUT_FILENO || captured != 0) { + fprintf(stderr, + "UnityPlayer dup2 protection failed: result=%d captured=%ld\n", + result, (long)captured); + return 1; + } + + puts("unityplayer-dup2-ok"); + return 0; +} diff --git a/tests/nix/fixtures/unityplayer-dup2.c b/tests/nix/fixtures/unityplayer-dup2.c new file mode 100644 index 0000000..f8d3f19 --- /dev/null +++ b/tests/nix/fixtures/unityplayer-dup2.c @@ -0,0 +1,5 @@ +#include + +int unityplayer_redirect_output(int destination) { + return dup2(destination, STDOUT_FILENO); +} diff --git a/tests/nix/run-macos-interpose-smoke.sh b/tests/nix/run-macos-interpose-smoke.sh new file mode 100644 index 0000000..ccd47d1 --- /dev/null +++ b/tests/nix/run-macos-interpose-smoke.sh @@ -0,0 +1,53 @@ +#!/bin/sh +set -eu + +if [ "$(uname -s)" != "Darwin" ] || [ "$#" -ne 1 ] || [ ! -f "$1" ]; then + echo "Usage on macOS: $0 /path/to/libdoorstop.dylib" 1>&2 + exit 2 +fi + +a="/$0"; a=${a%/*}; a=${a#/}; a=${a:-.}; TEST_DIR=$(cd "$a" || exit; pwd -P) +LIBDOORSTOP=$(cd "$(dirname "$1")" || exit; pwd -P)/$(basename "$1") +SMOKE_TMP=$(mktemp -d "${TMPDIR:-/tmp}/unity-doorstop-interpose.XXXXXX") + +cleanup() { + rm -rf "${SMOKE_TMP}" +} +trap cleanup EXIT HUP INT TERM + +cc -Wall -Wextra -Werror -dynamiclib \ + -Wl,-install_name,@rpath/UnityPlayer.dylib \ + "${TEST_DIR}/fixtures/macos-unityplayer-interpose.c" \ + -o "${SMOKE_TMP}/UnityPlayer.dylib" + +cc -Wall -Wextra -Werror -Wl,-export_dynamic \ + "${TEST_DIR}/fixtures/macos-dlsym-smoke.c" \ + -L"${SMOKE_TMP}" -lUnityPlayer -Wl,-rpath,@loader_path \ + -o "${SMOKE_TMP}/macos-dlsym-smoke" + +unset DOORSTOP_DISABLE DOORSTOP_INITIALIZED DOORSTOP_TARGET_ASSEMBLY + +DOORSTOP_ENABLED=0 \ +DYLD_INSERT_LIBRARIES="${LIBDOORSTOP}" \ + "${SMOKE_TMP}/macos-dlsym-smoke" disabled + +printf '%s\n' override > "${SMOKE_TMP}/override.config" + +enabled_output=$( + DOORSTOP_ENABLED=1 \ + DOORSTOP_BOOT_CONFIG_OVERRIDE="${SMOKE_TMP}/override.config" \ + EXPECTED_BOOT_CONFIG_PATH="$(pwd -P)/macos-dlsym-smoke_Data/boot.config" \ + REDIRECT_OUTPUT_PATH="${SMOKE_TMP}/redirected-output" \ + DYLD_INSERT_LIBRARIES="${LIBDOORSTOP}" \ + "${SMOKE_TMP}/macos-dlsym-smoke" enabled +) +[ "${enabled_output}" = "unity-interpose-ok" ] || { + echo "macOS UnityPlayer interposition failed: ${enabled_output}" 1>&2 + exit 1 +} +[ ! -s "${SMOKE_TMP}/redirected-output" ] || { + echo "macOS UnityPlayer dup2 leaked output to the redirected file" 1>&2 + exit 1 +} + +echo "macOS dlsym interposition smoke test passed" diff --git a/tests/nix/run-regressions.sh b/tests/nix/run-regressions.sh new file mode 100644 index 0000000..c03acfe --- /dev/null +++ b/tests/nix/run-regressions.sh @@ -0,0 +1,179 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then + echo "Usage: $0 /path/to/libdoorstop.so" 1>&2 + exit 2 +fi + +a="/$0"; a=${a%/*}; a=${a#/}; a=${a:-.}; TEST_DIR=$(cd "$a" || exit; pwd -P) +REPO_DIR=$(cd "${TEST_DIR}/../.." || exit; pwd -P) +LIBDOORSTOP=$(cd "$(dirname "$1")" || exit; pwd -P)/$(basename "$1") +TMP_ROOT=${TMPDIR:-/tmp} +TEST_TMP="${TMP_ROOT%/}/unity-doorstop-tests-$$" + +cleanup() { + rm -rf "${TEST_TMP}" +} +trap cleanup EXIT HUP INT TERM + +mkdir -p "${TEST_TMP}" + +fail() { + echo "FAIL: $*" 1>&2 + exit 1 +} + +# Issue #108 is macOS-only. Linux CI keeps lightweight source invariants while +# the macOS job runs the real process-level dlsym interposition smoke test. +entrypoint_source="${REPO_DIR}/src/nix/entrypoint.c" +plthook_osx_source="${REPO_DIR}/src/nix/plthook/plthook_osx.c" +grep -q 'DYLD_INTERPOSE(dlsym_hook, dlsym)' "${entrypoint_source}" || + fail "macOS dlsym interposition is missing" +grep -q 'DYLD_INTERPOSE(fopen_hook, fopen)' "${entrypoint_source}" || + fail "macOS fopen interposition is missing" +grep -q 'DYLD_INTERPOSE(fclose_hook, fclose)' "${entrypoint_source}" || + fail "macOS fclose interposition is missing" +grep -q 'DYLD_INTERPOSE(dup2_hook, dup2)' "${entrypoint_source}" || + fail "macOS dup2 interposition is missing" +grep -q '!doorstop_ready || !config.enabled' "${entrypoint_source}" || + fail "macOS dlsym interposition guard is missing" +grep -q 'APPLE_CALLER_IS_UNITY_PLAYER' "${entrypoint_source}" || + fail "macOS stdio interposition caller guard is missing" +grep -q 'linkedit->vmaddr - linkedit->fileoff + d->slide' \ + "${plthook_osx_source}" || + fail "chained-fixups header is not relative to __LINKEDIT" +grep -q 'chained-fixup PLT enumeration is unsupported' \ + "${plthook_osx_source}" || + fail "unsafe chained-fixups enumeration is not fail-closed" + +# Issue #88: a preloaded Doorstop must not replace sh's own dup2 PLT entry. +shell_output=$( + DOORSTOP_ENABLED=1 \ + DOORSTOP_TARGET_ASSEMBLY=/dev/null \ + LD_PRELOAD="${LIBDOORSTOP}" \ + /bin/sh -c 'value="$(printf captured)"; printf "[%s]" "$value"' +) +[ "${shell_output}" = "[captured]" ] || + fail "command substitution was not captured: ${shell_output}" + +# Issue #47 is the same leak through ordinary file redirection: child tools +# must still be able to write generated content to a file without it escaping +# to the launcher's stdout. +redirected_file="${TEST_TMP}/redirected-output" +redirected_stdout=$( + DOORSTOP_ENABLED=1 \ + DOORSTOP_TARGET_ASSEMBLY=/dev/null \ + LD_PRELOAD="${LIBDOORSTOP}" \ + /bin/sh -c 'printf redirected > "$1"' sh "${redirected_file}" +) +[ -z "${redirected_stdout}" ] || + fail "file redirection leaked to stdout: ${redirected_stdout}" +[ "$(cat "${redirected_file}")" = "redirected" ] || + fail "file redirection did not reach its destination" + +# Issues #34/#73: an opted-in replacement process must not inherit the two +# markers that belong to its launcher process. +marker_output=$( + DOORSTOP_ENABLED=1 \ + DOORSTOP_IGNORE_DISABLED_ENV=1 \ + DOORSTOP_INITIALIZED=TRUE \ + DOORSTOP_DISABLE=TRUE \ + LD_PRELOAD="${LIBDOORSTOP}" \ + /bin/sh -c \ + 'printf "[%s][%s]" "${DOORSTOP_INITIALIZED-}" "${DOORSTOP_DISABLE-}"' +) +[ "${marker_output}" = "[][]" ] || + fail "inherited Doorstop markers were not cleared: ${marker_output}" + +# Guard the original reason for the dup2 hook as well: calls originating in a +# real UnityPlayer module still cannot redirect stdout away from the console. +unityplayer_case="${TEST_TMP}/unityplayer-case" +mkdir -p "${unityplayer_case}" +cc -Wall -Wextra -Werror -fPIC -shared \ + -Wl,-soname,UnityPlayer.so \ + "${TEST_DIR}/fixtures/unityplayer-dup2.c" \ + -o "${unityplayer_case}/UnityPlayer.so" +cc -Wall -Wextra -Werror \ + "${TEST_DIR}/fixtures/unityplayer-dup2-smoke.c" \ + -L"${unityplayer_case}" -Wl,-rpath,"${unityplayer_case}" \ + -Wl,--no-as-needed -l:UnityPlayer.so \ + -o "${unityplayer_case}/unityplayer-dup2-smoke" + +unityplayer_output=$( + DOORSTOP_ENABLED=1 \ + DOORSTOP_TARGET_ASSEMBLY=/dev/null \ + LD_PRELOAD="${LIBDOORSTOP}" \ + "${unityplayer_case}/unityplayer-dup2-smoke" +) +[ "${unityplayer_output}" = "visibleunityplayer-dup2-ok" ] || + fail "UnityPlayer stdout protection regressed: ${unityplayer_output}" + +# Issue #84: relative executable names are relative to run.sh even when the +# caller is in another directory. +linux_case="${TEST_TMP}/linux-case" +mkdir -p "${linux_case}/caller" +cp "${REPO_DIR}/assets/nix/run.sh" "${linux_case}/run.sh" +cp "${LIBDOORSTOP}" "${linux_case}/libdoorstop.so" +cp "${TEST_DIR}/fixtures/game.sh" "${linux_case}/fixture-game" +chmod +x "${linux_case}/fixture-game" + +linux_output=$(cd "${linux_case}/caller" && /bin/sh ../run.sh fixture-game) +[ "${linux_output}" = "game-ok" ] || + fail "relative executable was not resolved from run.sh: ${linux_output}" + +# Debug-only mode represents the absence of a managed entrypoint with an empty +# target value. Do not turn it into BASEDIR, which is an existing directory and +# would be mistaken for an assembly by access(F_OK). +empty_target_output=$( + cd "${linux_case}/caller" && + /bin/sh ../run.sh fixture-game \ + --doorstop-target-assembly "" \ + --print-target-assembly +) +[ "${empty_target_output}" = "[]" ] || + fail "empty target assembly was rewritten: ${empty_target_output}" + +# Issue #67: each non-empty Mono search path is relative to run.sh, not the +# caller's working directory. Multiple entries remain colon-separated. +mkdir -p "${linux_case}/search-one" "${linux_case}/search two" +search_output=$( + cd "${linux_case}/caller" && + /bin/sh ../run.sh fixture-game \ + --doorstop-mono-dll-search-path-override \ + "search-one::search two:" \ + --print-search-path +) +expected_search_path="${linux_case}/search-one:${linux_case}/search two" +[ "${search_output}" = "${expected_search_path}" ] || + fail "Mono search paths were not resolved from run.sh: ${search_output}" + +# Issue #107: simulate Apple Silicon and assert that the arch helper starts +# without DYLD_INSERT_LIBRARIES, while arch -e receives the complete value for +# the game. +mac_case="${TEST_TMP}/mac-case" +fake_bin="${mac_case}/fake-bin" +game_app="${mac_case}/TestGame.app/Contents/MacOS" +mkdir -p "${fake_bin}" "${game_app}" "${mac_case}/caller" +cp "${REPO_DIR}/assets/nix/run.sh" "${mac_case}/run.sh" +cp "${LIBDOORSTOP}" "${mac_case}/libdoorstop.dylib" +cp "${TEST_DIR}/fixtures/game.sh" "${game_app}/GameBin" +cp "${TEST_DIR}/fixtures/fake-uname.sh" "${fake_bin}/uname" +cp "${TEST_DIR}/fixtures/fake-sysctl.sh" "${fake_bin}/sysctl" +cp "${TEST_DIR}/fixtures/fake-defaults.sh" "${fake_bin}/defaults" +cp "${TEST_DIR}/fixtures/fake-file.sh" "${fake_bin}/file" +cp "${TEST_DIR}/fixtures/fake-arch.sh" "${fake_bin}/arch" +chmod +x "${game_app}/GameBin" "${fake_bin}/uname" "${fake_bin}/sysctl" \ + "${fake_bin}/defaults" "${fake_bin}/file" "${fake_bin}/arch" + +mac_output=$( + cd "${mac_case}/caller" && + PATH="${fake_bin}:${PATH}" \ + DYLD_INSERT_LIBRARIES=existing.dylib \ + EXPECTED_DYLD_INSERT_LIBRARIES=libdoorstop.dylib:existing.dylib \ + /bin/sh ../run.sh TestGame.app +) +[ "${mac_output}" = "arch-ok" ] || + fail "Apple Silicon arch environment was not isolated: ${mac_output}" + +echo "nix regression tests passed" diff --git a/tests/windows/config-util-regressions.c b/tests/windows/config-util-regressions.c new file mode 100644 index 0000000..94a04a5 --- /dev/null +++ b/tests/windows/config-util-regressions.c @@ -0,0 +1,168 @@ +#include "../../src/bootstrap.h" +#include "../../src/bootstrap.h" +#include "../../src/config/config.h" +#include "../../src/crt.h" +#include "../../src/util/util.h" + +extern bool_t load_bool_argv(char_t **argv, int *i, int argc, + const char_t *arg_name, bool_t *value); +extern bool_t load_str_argv(char_t **argv, int *i, int argc, + const char_t *arg_name, char_t **value); +extern bool_t load_path_argv(char_t **argv, int *i, int argc, + const char_t *arg_name, char_t **value); + +#define CHECK(condition, code) \ + if (!(condition)) \ + return code + +static char_t *owned_string(const char_t *value) { + const size_t len = strlen(value) + 1; + char_t *result = malloc(len * sizeof(char_t)); + strncpy(result, value, len); + return result; +} + +static bool_t debug_options_canonicalized; +static const char *expected_debug_options; +static bool_t mono_thread_api_called; + +static void *capture_debug_options(int argc, char **argv) { + debug_options_canonicalized = + argc == 1 && argv && argv[0] && expected_debug_options && + lstrcmpA(argv[0], expected_debug_options) == 0; + return NULL; +} + +static void *capture_thread_current(void) { + mono_thread_api_called = TRUE; + return NULL; +} + +static void capture_thread_set_main(void *thread) { + (void)thread; + mono_thread_api_called = TRUE; +} + +int main(void) { + init_crt(); + + char_t *missing_bool[] = {TEXT("--flag")}; + int index = 0; + bool_t bool_value = TRUE; + CHECK(load_bool_argv(missing_bool, &index, 1, TEXT("--flag"), + &bool_value), + 1); + CHECK(index == 0 && bool_value == TRUE, 2); + + char_t *bool_before_option[] = {TEXT("--flag"), + TEXT("--doorstop-enabled")}; + index = 0; + CHECK(load_bool_argv(bool_before_option, &index, 2, TEXT("--flag"), + &bool_value), + 21); + CHECK(index == 0 && bool_value == TRUE, 22); + + char_t *invalid_bool[] = {TEXT("--flag"), TEXT("not-a-bool")}; + index = 0; + CHECK(load_bool_argv(invalid_bool, &index, 2, TEXT("--flag"), &bool_value), + 3); + CHECK(index == 1 && bool_value == TRUE, 4); + + char_t *valid_bool[] = {TEXT("--flag"), TEXT("false")}; + index = 0; + CHECK(load_bool_argv(valid_bool, &index, 2, TEXT("--flag"), &bool_value), + 5); + CHECK(index == 1 && bool_value == FALSE, 6); + + char_t *missing_string[] = {TEXT("--string")}; + char_t *string_value = owned_string(TEXT("default")); + char_t *original_string = string_value; + index = 0; + CHECK(load_str_argv(missing_string, &index, 1, TEXT("--string"), + &string_value), + 7); + CHECK(index == 0 && string_value == original_string, 8); + + char_t *string_before_option[] = {TEXT("--string"), + TEXT("--doorstop-enabled")}; + index = 0; + CHECK(load_str_argv(string_before_option, &index, 2, TEXT("--string"), + &string_value), + 23); + CHECK(index == 0 && string_value == original_string, 24); + + char_t *empty_path[] = {TEXT("--path"), TEXT("")}; + char_t *path_value = owned_string(TEXT("default-path")); + char_t *original_path = path_value; + index = 0; + CHECK(load_path_argv(empty_path, &index, 2, TEXT("--path"), &path_value), + 9); + CHECK(index == 1 && path_value == original_path, 10); + + char_t *missing_path[] = {TEXT("--path")}; + index = 0; + CHECK(load_path_argv(missing_path, &index, 1, TEXT("--path"), &path_value), + 11); + CHECK(index == 0 && path_value == original_path, 12); + + char_t *path_before_option[] = {TEXT("--path"), + TEXT("--doorstop-enabled")}; + index = 0; + CHECK(load_path_argv(path_before_option, &index, 2, TEXT("--path"), + &path_value), + 25); + CHECK(index == 0 && path_value == original_path, 26); + + CHECK(get_full_path(NULL) == NULL, 13); + CHECK(get_full_path(TEXT("")) == NULL, 14); + CHECK(get_folder_name(NULL) == NULL, 15); + CHECK(get_file_name(TEXT(""), TRUE) == NULL, 16); + + char_t *file_name = get_file_name(TEXT("game.exe"), FALSE); + CHECK(file_name && lstrcmp(file_name, TEXT("game")) == 0, 17); + + char_t *folder_name = get_folder_name(TEXT("game.exe")); + CHECK(folder_name && folder_name[0] == TEXT('\0'), 18); + + load_config(); + CHECK(config.mono_debug_address != NULL, 19); + CHECK(lstrcmp(config.mono_debug_address, TEXT("127.0.0.1:10000")) == 0, + 20); + + free(config.mono_debug_address); + config.mono_debug_address = owned_string(TEXT("LOCALHOST:23456")); + config.mono_debug_enabled = TRUE; + config.mono_debug_suspend = FALSE; + mono.jit_parse_options = capture_debug_options; + expected_debug_options = + "--debugger-agent=transport=dt_socket,server=y," + "address=127.0.0.1:23456,suspend=n"; + hook_mono_jit_parse_options(0, NULL); + CHECK(debug_options_canonicalized, 27); + + free(config.mono_debug_address); + config.mono_debug_address = NULL; + debug_options_canonicalized = FALSE; + expected_debug_options = + "--debugger-agent=transport=dt_socket,server=y," + "address=127.0.0.1:10000,suspend=n"; + hook_mono_jit_parse_options(0, NULL); + CHECK(debug_options_canonicalized, 28); + + // Debug-only mode must return before touching managed-bootstrap-only Mono + // APIs. Some stripped runtimes do not export these functions at all. + free(config.target_assembly); + config.target_assembly = NULL; + mono.thread_current = capture_thread_current; + mono.thread_set_main = capture_thread_set_main; + mono_doorstop_bootstrap(NULL); + CHECK(!mono_thread_api_called, 29); + + cleanup_config(); + + free(file_name); + free(folder_name); + free(string_value); + free(path_value); + return 0; +} diff --git a/tests/windows/iat-hook-regressions.c b/tests/windows/iat-hook-regressions.c new file mode 100644 index 0000000..222adb7 --- /dev/null +++ b/tests/windows/iat-hook-regressions.c @@ -0,0 +1,184 @@ +#include "../../src/windows/hook.h" + +#define IMAGE_SIZE 0x1000 +#define NT_OFFSET 0x80 +#define IMPORT_OFFSET 0x200 +#define DLL_NAME_OFFSET 0x300 +#define ORIGINAL_THUNK_OFFSET 0x400 +#define FIRST_THUNK_OFFSET 0x500 +#define IMPORT_NAME_OFFSET 0x600 + +#define CHECK(condition, code) \ + if (!(condition)) \ + return code + +typedef struct { + BYTE *base; + IMAGE_IMPORT_DESCRIPTOR *import; + IMAGE_THUNK_DATA *original_thunks; + IMAGE_THUNK_DATA *first_thunks; +} SyntheticImage; + +static void WINAPI original_function(void) {} +static void WINAPI previous_detour(void) {} +static void WINAPI doorstop_detour(void) {} + +#define DETOUR_PROCESS_ID ((DWORD)0xD00570F) +static DWORD WINAPI get_process_id_detour(void) { return DETOUR_PROCESS_ID; } + +static void copy_ascii(char *destination, char const *source) { + while ((*destination++ = *source++) != '\0') { + } +} + +static SyntheticImage create_image(void) { + SyntheticImage image = {0}; + image.base = VirtualAlloc(NULL, IMAGE_SIZE, MEM_COMMIT | MEM_RESERVE, + PAGE_READWRITE); + if (!image.base) + return image; + + IMAGE_DOS_HEADER *dos = (IMAGE_DOS_HEADER *)image.base; + dos->e_magic = IMAGE_DOS_SIGNATURE; + dos->e_lfanew = NT_OFFSET; + + IMAGE_NT_HEADERS *nt = + (IMAGE_NT_HEADERS *)(image.base + NT_OFFSET); + nt->Signature = IMAGE_NT_SIGNATURE; + nt->FileHeader.SizeOfOptionalHeader = sizeof(IMAGE_OPTIONAL_HEADER); + nt->OptionalHeader.Magic = IMAGE_NT_OPTIONAL_HDR_MAGIC; + nt->OptionalHeader.SizeOfImage = IMAGE_SIZE; + nt->OptionalHeader.NumberOfRvaAndSizes = IMAGE_NUMBEROF_DIRECTORY_ENTRIES; + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT] + .VirtualAddress = IMPORT_OFFSET; + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size = + 2 * sizeof(IMAGE_IMPORT_DESCRIPTOR); + + image.import = + (IMAGE_IMPORT_DESCRIPTOR *)(image.base + IMPORT_OFFSET); + image.import->Name = DLL_NAME_OFFSET; + image.import->OriginalFirstThunk = ORIGINAL_THUNK_OFFSET; + image.import->FirstThunk = FIRST_THUNK_OFFSET; + copy_ascii((char *)(image.base + DLL_NAME_OFFSET), "KERNEL32.dll"); + + image.original_thunks = + (IMAGE_THUNK_DATA *)(image.base + ORIGINAL_THUNK_OFFSET); + image.original_thunks[0].u1.AddressOfData = IMPORT_NAME_OFFSET; + image.original_thunks[1].u1.AddressOfData = 0; + + IMAGE_IMPORT_BY_NAME *import_name = + (IMAGE_IMPORT_BY_NAME *)(image.base + IMPORT_NAME_OFFSET); + import_name->Hint = 0; + copy_ascii((char *)import_name->Name, "GetProcAddress"); + + image.first_thunks = + (IMAGE_THUNK_DATA *)(image.base + FIRST_THUNK_OFFSET); + image.first_thunks[0].u1.Function = (ULONG_PTR)&previous_detour; + image.first_thunks[1].u1.Function = 0; + return image; +} + +static void destroy_image(SyntheticImage *image) { + if (image->base) + VirtualFree(image->base, 0, MEM_RELEASE); +} + +int main(void) { + // Exercise a real loaded PE image before the malformed-image cases. The + // final PE page may be discardable/reserved, but the import table itself + // remains a valid hook target. + HMODULE current_module = GetModuleHandleW(NULL); + void *real_get_process_id = NULL; + CHECK(iat_hook(current_module, "kernel32.dll", "GetCurrentProcessId", + (void *)&GetCurrentProcessId, + (void *)&get_process_id_detour, &real_get_process_id), + 19); + CHECK(GetCurrentProcessId() == DETOUR_PROCESS_ID, 20); + void *replaced_detour = NULL; + CHECK(iat_hook(current_module, "kernel32.dll", "GetCurrentProcessId", + (void *)&get_process_id_detour, real_get_process_id, + &replaced_detour), + 21); + CHECK(replaced_detour == (void *)&get_process_id_detour, 22); + + // OriginalFirstThunk identifies the import even though another tool has + // already replaced the initialized IAT value. + SyntheticImage chained = create_image(); + CHECK(chained.base != NULL, 1); + void *previous = NULL; + CHECK(iat_hook(chained.base, "kernel32.dll", "GetProcAddress", + (void *)&original_function, (void *)&doorstop_detour, + &previous), + 2); + CHECK(previous == (void *)&previous_detour, 3); + CHECK(chained.first_thunks[0].u1.Function == + (ULONG_PTR)&doorstop_detour, + 4); + destroy_image(&chained); + + // A caller that does not retain the previous function must not overwrite + // an existing hook even when OriginalFirstThunk identifies the slot. + SyntheticImage unchained = create_image(); + CHECK(unchained.base != NULL, 16); + CHECK(!iat_hook(unchained.base, "kernel32.dll", "GetProcAddress", + (void *)&original_function, (void *)&doorstop_detour, + NULL), + 17); + CHECK(unchained.first_thunks[0].u1.Function == + (ULONG_PTR)&previous_detour, + 18); + destroy_image(&unchained); + + // An image without OriginalFirstThunk can still use the conservative + // address fallback while the IAT is untouched. + SyntheticImage fallback = create_image(); + CHECK(fallback.base != NULL, 5); + fallback.import->OriginalFirstThunk = 0; + fallback.first_thunks[0].u1.Function = (ULONG_PTR)&original_function; + previous = NULL; + CHECK(iat_hook(fallback.base, "kernel32.dll", "GetProcAddress", + (void *)&original_function, (void *)&doorstop_detour, + &previous), + 6); + CHECK(previous == (void *)&original_function, 7); + destroy_image(&fallback); + + // Without lookup metadata an already-modified slot is ambiguous and must + // fail closed instead of overwriting an unrelated/previous hook. + SyntheticImage ambiguous = create_image(); + CHECK(ambiguous.base != NULL, 8); + ambiguous.import->OriginalFirstThunk = 0; + previous = (void *)&original_function; + CHECK(!iat_hook(ambiguous.base, "kernel32.dll", "GetProcAddress", + (void *)&original_function, (void *)&doorstop_detour, + &previous), + 9); + CHECK(previous == NULL, 10); + CHECK(ambiguous.first_thunks[0].u1.Function == + (ULONG_PTR)&previous_detour, + 11); + destroy_image(&ambiguous); + + // Ordinal imports are skipped without treating the ordinal as a string. + SyntheticImage ordinal = create_image(); + CHECK(ordinal.base != NULL, 12); + ordinal.original_thunks[0].u1.Ordinal = + IMAGE_ORDINAL_FLAG | (ULONG_PTR)7; + CHECK(!iat_hook(ordinal.base, "kernel32.dll", "GetProcAddress", + (void *)&original_function, (void *)&doorstop_detour, + NULL), + 13); + destroy_image(&ordinal); + + // Malformed name RVAs are rejected before any pointer is dereferenced. + SyntheticImage malformed = create_image(); + CHECK(malformed.base != NULL, 14); + malformed.original_thunks[0].u1.AddressOfData = IMAGE_SIZE + 1; + CHECK(!iat_hook(malformed.base, "kernel32.dll", "GetProcAddress", + (void *)&original_function, (void *)&doorstop_detour, + NULL), + 15); + destroy_image(&malformed); + + return 0; +} diff --git a/tests/windows/run-regressions.ps1 b/tests/windows/run-regressions.ps1 new file mode 100644 index 0000000..4b4cda1 --- /dev/null +++ b/tests/windows/run-regressions.ps1 @@ -0,0 +1,58 @@ +$ErrorActionPreference = "Stop" + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$configTestExe = Join-Path $PSScriptRoot "config-util-regressions.exe" +$iatTestExe = Join-Path $PSScriptRoot "iat-hook-regressions.exe" +$compiler = (Get-Command gcc.exe -ErrorAction Stop).Source + +try { + & $compiler ` + -std=gnu11 ` + -DUNICODE ` + -D_UNICODE ` + -ffunction-sections ` + -fdata-sections ` + "-Wl,--gc-sections" ` + -I (Join-Path $repoRoot "src") ` + (Join-Path $repoRoot "tests\windows\config-util-regressions.c") ` + (Join-Path $repoRoot "tests\windows\test-crt.c") ` + (Join-Path $repoRoot "src\bootstrap.c") ` + (Join-Path $repoRoot "src\windows\config.c") ` + (Join-Path $repoRoot "src\windows\util.c") ` + (Join-Path $repoRoot "src\config\common.c") ` + (Join-Path $repoRoot "src\runtimes\globals.c") ` + -lshell32 ` + -lkernel32 ` + -o $configTestExe + if ($LASTEXITCODE -ne 0) { + throw "Failed to compile Windows config regressions (exit $LASTEXITCODE)" + } + + & $configTestExe + if ($LASTEXITCODE -ne 0) { + throw "Windows config regressions failed (case $LASTEXITCODE)" + } + + & $compiler ` + -std=gnu11 ` + -DUNICODE ` + -D_UNICODE ` + -I (Join-Path $repoRoot "src") ` + (Join-Path $repoRoot "tests\windows\iat-hook-regressions.c") ` + -lkernel32 ` + -o $iatTestExe + if ($LASTEXITCODE -ne 0) { + throw "Failed to compile Windows IAT regressions (exit $LASTEXITCODE)" + } + + & $iatTestExe + if ($LASTEXITCODE -ne 0) { + throw "Windows IAT regressions failed (case $LASTEXITCODE)" + } +} finally { + foreach ($testExe in @($configTestExe, $iatTestExe)) { + if (Test-Path -LiteralPath $testExe) { + Remove-Item -LiteralPath $testExe -Force + } + } +} diff --git a/tests/windows/test-crt.c b/tests/windows/test-crt.c new file mode 100644 index 0000000..0c7c0c0 --- /dev/null +++ b/tests/windows/test-crt.c @@ -0,0 +1,71 @@ +#include "../../src/windows/wincrt.h" + +void init_crt(void) {} + +size_t strlen_wide(const char_t *str) { + size_t result = 0; + while (*str++) + result++; + return result; +} + +void *malloc(size_t size) { + return HeapAlloc(GetProcessHeap(), HEAP_GENERATE_EXCEPTIONS, size); +} + +void *calloc(size_t num, size_t size) { + return HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, num * size); +} + +void free(void *mem) { + if (mem) + HeapFree(GetProcessHeap(), 0, mem); +} + +char_t *strncpy_wide(char_t *dst, const char_t *src, size_t len) { + char_t *result = dst; + while (len--) + *dst++ = *src++; + return result; +} + +char_t *strcpy_wide(char_t *dst, const char_t *src) { + char_t *result = dst; + while ((*dst++ = *src++) != TEXT('\0')) { + } + return result; +} + +char_t *strcat_wide(char_t *dst, const char_t *src) { + char_t *result = dst; + while (*dst) + dst++; + strcpy_wide(dst, src); + return result; +} + +void *dlsym(void *handle, const char *name) { + (void)handle; + (void)name; + return NULL; +} + +void *dlopen(const char_t *filename, int flag) { + (void)filename; + (void)flag; + return NULL; +} + +int setenv(const char_t *name, const char_t *value, int overwrite) { + (void)name; + (void)value; + (void)overwrite; + return 0; +} + +char_t *getenv_wide(const char_t *name) { + (void)name; + return NULL; +} + +void shutenv(char_t *value) { free(value); } From 55b753a2cc6018c9a0aa4bac919bd0ae17f9ec27 Mon Sep 17 00:00:00 2001 From: mohui666 Date: Mon, 17 Aug 2026 18:29:16 +0800 Subject: [PATCH 2/4] Fix macOS build: define DYLD_INTERPOSE locally is part of the dyld source tree but is not shipped in the Xcode SDK, so the include failed on CI runners. Vendor the stable macro definition instead; the generated __DATA,__interpose tuples are identical. --- src/nix/entrypoint.c | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/nix/entrypoint.c b/src/nix/entrypoint.c index 58ad884..98b7c2b 100644 --- a/src/nix/entrypoint.c +++ b/src/nix/entrypoint.c @@ -7,7 +7,16 @@ #include "./plthook/plthook.h" #if defined(__APPLE__) -#include +// ships with the dyld source tree, not the Xcode +// SDK, so define its stable DYLD_INTERPOSE macro locally. +#define DYLD_INTERPOSE(_replacement, _replacee) \ + __attribute__((used)) static struct { \ + const void *replacement; \ + const void *replacee; \ + } _interpose_##_replacee \ + __attribute__((section("__DATA,__interpose"))) = { \ + (const void *)(unsigned long)&_replacement, \ + (const void *)(unsigned long)&_replacee}; #define PLTHOOK_OPEN_BY_HANDLE_OR_ADDRESS plthook_open_by_handle #else #define PLTHOOK_OPEN_BY_HANDLE_OR_ADDRESS plthook_open_by_address From 9e93c562a67362f419e27d287024f586492ad9ec Mon Sep 17 00:00:00 2001 From: mohui666 Date: Mon, 17 Aug 2026 18:33:34 +0800 Subject: [PATCH 3/4] Fix macOS smoke test link: reference UnityPlayer.dylib by path -lUnityPlayer only resolves lib-prefixed library names, while the caller check requires the image basename to start with UnityPlayer. Link the dylib by explicit path so its @rpath install_name is used instead. --- tests/nix/run-macos-interpose-smoke.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/nix/run-macos-interpose-smoke.sh b/tests/nix/run-macos-interpose-smoke.sh index ccd47d1..a816313 100644 --- a/tests/nix/run-macos-interpose-smoke.sh +++ b/tests/nix/run-macos-interpose-smoke.sh @@ -20,9 +20,11 @@ cc -Wall -Wextra -Werror -dynamiclib \ "${TEST_DIR}/fixtures/macos-unityplayer-interpose.c" \ -o "${SMOKE_TMP}/UnityPlayer.dylib" +# Link against the dylib by path: -l would require a lib-prefixed name, but +# the caller check expects the image basename to start with "UnityPlayer". cc -Wall -Wextra -Werror -Wl,-export_dynamic \ "${TEST_DIR}/fixtures/macos-dlsym-smoke.c" \ - -L"${SMOKE_TMP}" -lUnityPlayer -Wl,-rpath,@loader_path \ + "${SMOKE_TMP}/UnityPlayer.dylib" -Wl,-rpath,@loader_path \ -o "${SMOKE_TMP}/macos-dlsym-smoke" unset DOORSTOP_DISABLE DOORSTOP_INITIALIZED DOORSTOP_TARGET_ASSEMBLY From 3c397a40acaa878ab14ef81a66c48f3bfbf74758 Mon Sep 17 00:00:00 2001 From: mohui666 Date: Mon, 17 Aug 2026 18:41:51 +0800 Subject: [PATCH 4/4] Preserve dlerror state across dlsym hook init probes load_mono_funcs/ load_il2cpp_funcs probe many optional symbols, leaving the last failed probe in dlerror. Callers using the textbook dlerror()-after-dlsym() pattern then see a phantom error even though their own lookup succeeded. Drain the error state when the caller's lookup succeeded; surfaced by the macOS interposition smoke test. --- src/nix/entrypoint.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/nix/entrypoint.c b/src/nix/entrypoint.c index 98b7c2b..30a3383 100644 --- a/src/nix/entrypoint.c +++ b/src/nix/entrypoint.c @@ -59,6 +59,11 @@ void *dlsym_hook(void *handle, const char *name) { initialized = TRUE; \ init_func(handle); \ extra_init; \ + /* init_func probes many optional symbols, which clobbers the \ + caller-visible dlerror state; drain it when the caller's own \ + lookup succeeded. */ \ + if (res) \ + dlerror(); \ } \ return (void *)target; \ }