diff --git a/CMakeLists.txt b/CMakeLists.txt index 9eb4ff8..aa82870 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -172,6 +172,9 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) add_test(NAME multiplayer_settings_lock COMMAND dingosdk_settings_lock_tests) add_executable(dingosdk_held_input_tests Extension/UI/Overlay/Test/held_input_tests.cpp) add_test(NAME overlay_held_input COMMAND dingosdk_held_input_tests) + add_executable(dingosdk_ea_service_block_tests Extension/Boot/Test/ea_service_block_tests.cpp) + target_include_directories(dingosdk_ea_service_block_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME ea_service_block COMMAND dingosdk_ea_service_block_tests) endif() # The route and logging tests live in the local, untracked tests/ folder. option(DINGOSDK_BUILD_ROUTE_TESTS "Build native route cycle regression tests" OFF) diff --git a/Extension/Boot/Test/ea_service_block_tests.cpp b/Extension/Boot/Test/ea_service_block_tests.cpp new file mode 100644 index 0000000..99361e5 --- /dev/null +++ b/Extension/Boot/Test/ea_service_block_tests.cpp @@ -0,0 +1,34 @@ +#include "Extension/Boot/ea_service_block.h" +#include +#include +#include +#include +using namespace dingosdk; +void check(bool value, const std::string& message) { if (!value) throw std::runtime_error(message); } +bool blocked(std::string_view host) { return ea_service_host(host); } +bool blocked(std::wstring_view host) { return ea_service_host(host); } +static_assert(ea_service_host(std::string_view("collector.errors.ea.com")), "The match is usable at compile time"); +int main() { + try { + // Every EA service the game contacted in a recorded session, and a sample of the others it names. + for (const auto* host : {"collector.errors.ea.com", "a-collector.errors.ea.com", "freeform-river.data.ea.com", + "gcs.ea.com", "pin-river-grpc.data.ea.com", "pin-em.data.ea.com", "spring25.client.blazeredirector.ea.com", + "spring18.gosredirector.ea.com", "accounts.ea.com", "gateway.grpc.ea.com", "experimentation-grpc.data.ea.com", + "adtech-runtime-rest-internal.data.ea.com", "river-grpc-lt.eadpdata.ddns.ea.com", "pn.tnt-ea.com", + "rtm.tnt-ea.com", "ea.com", "tnt-ea.com"}) + check(blocked(host), std::string("EA service must be blocked: ") + host); + check(blocked("COLLECTOR.Errors.EA.com"), "Host names compare without case"); + check(blocked("gcs.ea.com."), "A trailing root dot is the same name"); + check(blocked(L"freeform-river.data.ea.com"), "Wide lookups are blocked too"); + check(blocked(L"GCS.EA.COM."), "Wide lookups compare without case and root dot"); + // What ReSkate and the game still need, and names that only look like EA's. + for (const auto* host : {"dingo-dev-assets.akamaized.net", "api.reskate.dev", "api.github.com", + "raw.githubusercontent.com", "thunderstore.io", "api.steampowered.com", "idea.com", "notea.com", + "xtnt-ea.com", "ea.com.example.net", "ea.co", "eacom", "com", ".", "", "159.153.1.1"}) + check(!blocked(host), std::string("Host must stay reachable: ") + host); + check(!blocked(L"dingo-dev-assets.akamaized.net"), "The fast-travel artwork CDN stays reachable"); + check(!blocked(L"gcs.ea.cöm"), "Characters outside ASCII never match a domain letter"); + check(!blocked("gcs.ea.com.."), "Only one root dot is stripped"); + std::cout << "EA service block checks passed.\n"; + } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } +} diff --git a/Extension/Boot/ea_service_block.cpp b/Extension/Boot/ea_service_block.cpp new file mode 100644 index 0000000..8947c55 --- /dev/null +++ b/Extension/Boot/ea_service_block.cpp @@ -0,0 +1,154 @@ +#include +#include +#include +#include "ea_service_block.h" + +#include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Log/logging.h" + +#include +#include +#include +#include +#include +#include + +namespace dingosdk { +namespace { + +using GetAddrInfoAFn = INT (WSAAPI*)(PCSTR, PCSTR, const ADDRINFOA*, PADDRINFOA*); +using GetAddrInfoWFn = INT (WSAAPI*)(PCWSTR, PCWSTR, const ADDRINFOW*, PADDRINFOW*); +using GetAddrInfoExAFn = INT (WSAAPI*)(PCSTR, PCSTR, DWORD, LPGUID, const ADDRINFOEXA*, PADDRINFOEXA*, timeval*, + LPOVERLAPPED, LPLOOKUPSERVICE_COMPLETION_ROUTINE, LPHANDLE); +using GetAddrInfoExWFn = INT (WSAAPI*)(PCWSTR, PCWSTR, DWORD, LPGUID, const ADDRINFOEXW*, PADDRINFOEXW*, timeval*, + LPOVERLAPPED, LPLOOKUPSERVICE_COMPLETION_ROUTINE, LPHANDLE); +using GetHostByNameFn = hostent* (WSAAPI*)(const char*); + +std::atomic original_get_addr_info_a{}; +std::atomic original_get_addr_info_w{}; +std::atomic original_get_addr_info_ex_a{}; +std::atomic original_get_addr_info_ex_w{}; +std::atomic original_get_host_by_name{}; + +// A refused name is logged once at info level; a service that cannot connect keeps retrying, so +// repeats are logged at debug level only. Never destroyed, because lookups can still arrive while +// the process exits. +struct RefusedNames { + std::mutex mutex; + std::set names; +}; +RefusedNames& refused_names() { + static auto* value = new RefusedNames; + return *value; +} + +template +bool refuse(const Char* name) noexcept { + if (!name) return false; + const std::basic_string_view host(name); + if (!ea_service_host(host)) return false; + try { + std::wstring wide; + wide.reserve(host.size()); + for (const auto ch : host) wide.push_back(static_cast(static_cast>(ch))); + bool first{}; + { + auto& refused = refused_names(); + const std::lock_guard lock(refused.mutex); + first = refused.names.insert(wide).second; + } + if (first) + logging::write(logging::Level::info, logging::Channel::runtime, + L"Blocked the game's lookup of " + wide + L": ReSkate keeps it off EA's online services"); + else if (logging::enabled(logging::Level::debug)) + logging::write(logging::Level::debug, logging::Channel::runtime, L"Blocked the game's lookup of " + wide + L" again"); + } catch (...) {} + return true; +} + +// What the resolver itself answers for a name that does not exist. +template +INT not_found(Result* result) noexcept { + if (result) *result = nullptr; + WSASetLastError(WSAHOST_NOT_FOUND); + return WSAHOST_NOT_FOUND; +} + +INT WSAAPI get_addr_info_a(PCSTR name, PCSTR service, const ADDRINFOA* hints, PADDRINFOA* result) { + if (refuse(name)) return not_found(result); + return original_get_addr_info_a.load()(name, service, hints, result); +} + +INT WSAAPI get_addr_info_w(PCWSTR name, PCWSTR service, const ADDRINFOW* hints, PADDRINFOW* result) { + if (refuse(name)) return not_found(result); + return original_get_addr_info_w.load()(name, service, hints, result); +} + +// Failing before the lookup starts is a valid answer for an overlapped call too: the completion +// routine and event are only used once the function has returned WSA_IO_PENDING. +INT WSAAPI get_addr_info_ex_a(PCSTR name, PCSTR service, DWORD name_space, LPGUID provider, const ADDRINFOEXA* hints, + PADDRINFOEXA* result, timeval* timeout, LPOVERLAPPED overlapped, + LPLOOKUPSERVICE_COMPLETION_ROUTINE completion, LPHANDLE handle) { + if (refuse(name)) { + if (handle) *handle = nullptr; + return not_found(result); + } + return original_get_addr_info_ex_a.load()(name, service, name_space, provider, hints, result, timeout, overlapped, + completion, handle); +} + +INT WSAAPI get_addr_info_ex_w(PCWSTR name, PCWSTR service, DWORD name_space, LPGUID provider, const ADDRINFOEXW* hints, + PADDRINFOEXW* result, timeval* timeout, LPOVERLAPPED overlapped, + LPLOOKUPSERVICE_COMPLETION_ROUTINE completion, LPHANDLE handle) { + if (refuse(name)) { + if (handle) *handle = nullptr; + return not_found(result); + } + return original_get_addr_info_ex_w.load()(name, service, name_space, provider, hints, result, timeout, overlapped, + completion, handle); +} + +hostent* WSAAPI get_host_by_name(const char* name) { + if (refuse(name)) { + WSASetLastError(WSAHOST_NOT_FOUND); + return nullptr; + } + return original_get_host_by_name.load()(name); +} + +template +void hook(const char* name, void* replacement, std::atomic& original) { + const auto module = LoadLibraryExW(L"ws2_32.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32); + const auto target = module ? reinterpret_cast(GetProcAddress(module, name)) : nullptr; + if (!target) throw std::runtime_error(std::string("Missing ") + name); + void* relay{}; + const auto create = hook_prepare(target, replacement, &relay); + if (create != HookOk) throw std::runtime_error(std::string("Cannot hook ") + name + ": " + hook_status_string(create)); + original.store(reinterpret_cast(relay)); + const auto enable = hook_enable(target); + if (enable != HookOk) throw std::runtime_error(std::string("Cannot enable ") + name + ": " + hook_status_string(enable)); +} + +} // namespace + +bool start_ea_service_block(std::string& error) noexcept { + try { + // Skate.exe resolves names through getaddrinfo alone; the other entry points close the + // same door for anything else loaded into the process. +#pragma warning(push) +#pragma warning(disable: 4191) + hook("getaddrinfo", reinterpret_cast(&get_addr_info_a), original_get_addr_info_a); + hook("GetAddrInfoW", reinterpret_cast(&get_addr_info_w), original_get_addr_info_w); + hook("GetAddrInfoExA", reinterpret_cast(&get_addr_info_ex_a), original_get_addr_info_ex_a); + hook("GetAddrInfoExW", reinterpret_cast(&get_addr_info_ex_w), original_get_addr_info_ex_w); + hook("gethostbyname", reinterpret_cast(&get_host_by_name), original_get_host_by_name); +#pragma warning(pop) + error.clear(); + return true; + } catch (const std::exception& exception) { + error = exception.what(); + return false; + } +} + +} // namespace dingosdk diff --git a/Extension/Boot/ea_service_block.h b/Extension/Boot/ea_service_block.h new file mode 100644 index 0000000..7285be9 --- /dev/null +++ b/Extension/Boot/ea_service_block.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace dingosdk { + +// EA's online services, as Skate.exe names them. ea.com holds the Blaze redirectors, accounts and +// gateway, telemetry (pin-*, freeform-river), error reports (collector.errors), remote +// configuration (gcs), experimentation, leaderboards, social and ad tech; tnt-ea.com holds push +// notifications and real-time messaging. Not listed on purpose: dingo-dev-assets.akamaized.net, +// the CDN the fast-travel artwork comes from (Engine/Game/World/location_travel.h). It only serves +// images, and the travel menu would lose its icons without it. +inline constexpr std::array ea_service_domains{"ea.com", "tnt-ea.com"}; + +// Whether a host name is one of ea_service_domains or a name under one, ignoring ASCII case and a +// trailing root dot. Address literals never match: Skate.exe reaches EA by name only. +template +constexpr bool ea_service_host(std::basic_string_view host) noexcept { + using Unit = std::make_unsigned_t; + if (!host.empty() && host.back() == Char('.')) host.remove_suffix(1); + for (const auto domain : ea_service_domains) { + if (host.size() < domain.size()) continue; + const auto start = host.size() - domain.size(); + if (start != 0 && host[start - 1] != Char('.')) continue; + bool same = true; + for (std::size_t i = 0; same && i < domain.size(); ++i) { + auto unit = static_cast(host[start + i]); + if (unit >= Unit('A') && unit <= Unit('Z')) unit = static_cast(unit + Unit('a' - 'A')); + same = unit == static_cast(domain[i]); + } + if (same) return true; + } + return false; +} + +// Makes every name lookup inside Skate.exe for an EA service fail as if the name did not exist, so +// the game cannot reach EA's servers: no error reports, telemetry or remote configuration. Always on. +bool start_ea_service_block(std::string& error) noexcept; + +} // namespace dingosdk diff --git a/README.md b/README.md index ad3c5e0..22304f3 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,11 @@ login name. To turn it off, untick **Send crash reports** in the launcher's Settings (ADVANCED), or set the environment variable `RESKATE_CRASH_REPORTING=0`. +ReSkate also keeps the game off EA's online services: the game's own crash reports, telemetry and +remote configuration requests are turned off and blocked. Fast-travel artwork still comes from EA's +image CDN (`dingo-dev-assets.akamaized.net`), which only downloads images. +[contrib/ea-watch.ps1](contrib/ea-watch.ps1) shows what the game connects to, if you want to check. + ## Third-party code ReSkate uses Dear ImGui, Microsoft Detours, RapidJSON, spdlog, SQLite, LZ4, Zstandard, miniz, bcdec and diff --git a/Runtime/bootstrap.cpp b/Runtime/bootstrap.cpp index d04e538..ef29d19 100644 --- a/Runtime/bootstrap.cpp +++ b/Runtime/bootstrap.cpp @@ -6,6 +6,7 @@ #include "Extension/Boot/steam_restart_guard.h" #include "Extension/Boot/user_data_redirect.h" #include "Extension/Boot/ea_app_block.h" +#include "Extension/Boot/ea_service_block.h" #include "Extension/Assets/native_patch_support.h" #include "Extension/Assets/mod_layers.h" #include "Extension/Assets/loose_files.h" @@ -61,6 +62,8 @@ bool initialize_bootstrap(std::uintptr_t base) { if (!stage(Channel::runtime, "Separate game user data", ready, error)) return false; error.clear(); ready = start_ea_app_block(error); if (!stage(Channel::runtime, "EA app launch block", ready, error)) return false; + error.clear(); ready = start_ea_service_block(error); + if (!stage(Channel::runtime, "EA online services block", ready, error)) return false; error.clear(); ready = start_steam_restart_guard(error); return stage(Channel::runtime, launcher::offline_mode() ? "Offline Steam" : "Steam restart guard", ready, error); } diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 7d3f17a..2a5b085 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -93,6 +93,9 @@ void refresh_interactive_model(std::uintptr_t client, DWORD state, DWORD game_ty // Engine settings that cost simulation time for nothing here, set once through the named // setter (retried until the settings registry answers): // - telemetry and the performance tracker only feed EA's backend, absent offline; +// - EA's error reporting (EadpErrorsData) only builds reports for EA's servers, which +// ea_service_block keeps unreachable. Set here because the same values given on the game's +// command line do not take; // - the world-transform update, which grows with every remote player's entities, is split // over more jobs than Game.cfg's 2. void apply_performance_settings() { @@ -104,11 +107,14 @@ void apply_performance_settings() { next_attempt = now + 500; try { const auto jobs = std::to_string(std::clamp(std::thread::hardware_concurrency() / 2, 2u, 8u)); - const std::array, 7> settings{{ + const std::array, 10> settings{{ {"DingoTelemetry.Enable", "0"}, {"DingoTelemetry.EnablePlayerTickEvents", "0"}, {"DingoTelemetry.EnablePerformanceEvents", "0"}, {"DingoTelemetry.EnableCPUBenchmark", "0"}, + {"EadpErrorsData.DisableAllEventsReporting", "1"}, + {"EadpErrorsData.EnableReportCrashes", "0"}, + {"EadpErrorsData.AutoSendEventFromPersistence", "0"}, {"PerformanceTracker.Enabled", "0"}, {"PerformanceTracker.JuiceLogPerformance", "0"}, {"EcsWorldTransform.ParallelWorldTransformUpdateJobCount", jobs}, diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index d8458a6..fdbcd74 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -173,7 +173,8 @@ add_library(dingosdk_runtime SHARED Extension/Progression/fixed_stop_entitlement_observation.cpp Extension/Progression/neighborhood_unlock_override.cpp Extension/Boot/user_data_redirect.cpp - Extension/Boot/ea_app_block.cpp) + Extension/Boot/ea_app_block.cpp + Extension/Boot/ea_service_block.cpp) target_link_libraries(dingosdk_runtime PRIVATE dingosdk_logging dingosdk_profiler dingosdk_supported_build dingosdk_overlay dingosdk_console_core dingosdk_steam_restart_guard dingosdk_startup_interventions dingosdk_fast_travel_unlock dingosdk_local_profile dingosdk_initfs dingosdk_mods dingosdk_startup_window dingosdk_custom_scripts dingosdk_world_layer_scan bcrypt diff --git a/contrib/ea-watch.ps1 b/contrib/ea-watch.ps1 new file mode 100644 index 0000000..0357f8e --- /dev/null +++ b/contrib/ea-watch.ps1 @@ -0,0 +1,569 @@ +<# +.SYNOPSIS + Records which programs on this PC look up and talk to EA's servers, so you can check what + skate. and ReSkate send to EA. + +.DESCRIPTION + Uses two event-tracing providers built into Windows; nothing is installed and the game is not + touched: + Microsoft-Windows-DNS-Client every DNS lookup, with the program that made it + Microsoft-Windows-Kernel-Network every TCP/UDP connect, send and receive, with the program, + the remote address and the size in bytes + The traffic itself is encrypted; the report shows which EA hosts were looked up, by which + program, and how many bytes went to and came from them. + + The game keeps the fast-travel artwork it downloads in its own cache and only fetches it from + EA's image CDN when it is missing. To see those downloads, close the game and rename + %LOCALAPPDATA%\ReSkate\Game\Skate\data\cache\http before recording; the game makes a new one. + The report also warns about hosts-file entries and Windows Firewall rules that would hide or + change what the game sends. + + Each recording gets its own folder, %LOCALAPPDATA%\ReSkate\ea-watch\-