diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f9f3e..821bb45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ GitHub are taken from the matching section here. - Solving a login challenge now closes its window. Before, Roblox's "challenge completed" signal was ignored and the window stayed open. Thanks to @glook9001. +- Login no longer stalls after you pass a verification check. Before, Roblox + only saw the page's "challenge completed" signal after a one-minute + timeout, so it gave up and the login failed. On a Roblox build Nightcap + can't handle this way, the log says "WebViewProtocol getter not + recognized" and web pages behave as before. - Login check windows and other web pages get 5 seconds to start instead of 3, so a slow start no longer closes them. The log says how long each one took to open. diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ab92bb..8171d75 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -386,7 +386,8 @@ add_library(mocktail_webview_helper_policy STATIC src/webview/webview_helper_policy.cc ) add_library(Mocktail::WebViewHelperPolicy ALIAS mocktail_webview_helper_policy) -target_include_directories(mocktail_webview_helper_policy PUBLIC src) +target_include_directories(mocktail_webview_helper_policy PUBLIC src + PRIVATE include) target_link_libraries(mocktail_webview_helper_policy PUBLIC PkgConfig::WEBKITGTK6 nlohmann_json::nlohmann_json diff --git a/include/compat/web_view_protocol_contract.h b/include/compat/web_view_protocol_contract.h new file mode 100644 index 0000000..4bbdbd7 --- /dev/null +++ b/include/compat/web_view_protocol_contract.h @@ -0,0 +1,190 @@ +#ifndef MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_ +#define MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_ + +#include +#include +#include +#include +#include + +// Roblox's Subsystem getter is not exported. The exported +// WebViewProtocol.initializeAndroidWebViewProtocol calls it first, into a +// stack slot, then reads the protocol from the returned holder: +// +// x86-64: lea slot(%rbp),%rbx; mov %rbx,%rdi; call getter; +// mov (%rbx),%rbx; mov 0x18(%rbx),%rax +// AArch64: add x8, sp, #slot; bl getter; ldr xN, [sp, #slot]; +// ldr x8, [xN, #0x18] +// +// These functions find that call site in the initializer's first bytes and +// check the getter's own code. Only relocated or build-varying fields (stack +// slots, frame sizes, PC-relative offsets, the call target) are masked. +// Verified on x86_64 2.734.917, 2.736.1408 and 2.738.1397, and on arm64-v8a +// 2.738.1397 and 2.740.931. + +namespace mocktail::compat { + +// Covers the call site in every verified build (x86-64 +0x27, AArch64 +0x34). +inline constexpr std::size_t kWebViewProtocolInitializerScanBytes = 128; +inline constexpr std::size_t kWebViewProtocolGetterBytesX86_64 = 44; +inline constexpr std::size_t kWebViewProtocolGetterBytesArm64 = 60; + +namespace web_view_protocol_contract_detail { + +inline bool MatchesMasked(std::string_view code, std::size_t offset, + std::string_view pattern, + std::string_view wildcard) { + if (offset > code.size() || code.size() - offset < pattern.size()) { + return false; + } + for (std::size_t index = 0; index < pattern.size(); ++index) { + if (wildcard[index] == 'x') continue; + if (code[offset + index] != pattern[index]) return false; + } + return true; +} + +// AArch64 instructions are little-endian words. +inline std::uint32_t Word(std::string_view code, std::size_t index) { + std::uint32_t word = 0; + for (std::size_t byte = 0; byte < 4; ++byte) { + word |= static_cast( + static_cast(code[index * 4 + byte])) + << (8 * byte); + } + return word; +} + +// One AArch64 instruction word: bits outside `mask` may vary. +struct MaskedWord { + std::uint32_t mask; + std::uint32_t value; +}; + +inline constexpr std::uint32_t kExact = 0xffffffffU; +// adrp Xd, page: immlo and immhi vary. +inline constexpr std::uint32_t kAdrpMask = 0x9f00001fU; +// add Xd, Xn, #imm12: imm12 varies. +inline constexpr std::uint32_t kAddImmediateMask = 0xffc003ffU; + +} // namespace web_view_protocol_contract_detail + +// Returns the getter address called from the x86-64 initializer at +// `address`, or nullopt unless the frame setup and exactly one call site +// match. +inline std::optional FindWebViewProtocolGetterCallX86_64( + std::string_view code, std::uintptr_t address) { + using web_view_protocol_contract_detail::MatchesMasked; + // push %rbp; mov %rsp,%rbp + if (!MatchesMasked(code, 0, "\x55\x48\x89\xe5", "....")) { + return std::nullopt; + } + static constexpr std::string_view kCallSite( + "\x48\x8d\x5d\x00\x48\x89\xdf\xe8\x00\x00\x00\x00\x48\x8b\x1b\x48\x8b\x43" + "\x18", + 19); + static constexpr std::string_view kCallSiteWildcard("...x....xxxx......."); + static constexpr std::size_t kCallDisplacement = 8; + const std::string_view window = + code.substr(0, kWebViewProtocolInitializerScanBytes); + std::optional call_site; + for (std::size_t offset = 0; offset < window.size(); ++offset) { + if (MatchesMasked(window, offset, kCallSite, kCallSiteWildcard)) { + if (call_site.has_value()) return std::nullopt; + call_site = offset; + } + } + if (!call_site.has_value()) return std::nullopt; + std::int32_t displacement = 0; + std::memcpy(&displacement, window.data() + *call_site + kCallDisplacement, + sizeof(displacement)); + const std::uintptr_t next_instruction = + address + *call_site + kCallDisplacement + sizeof(displacement); + return next_instruction + + static_cast(static_cast(displacement)); +} + +// The x86-64 getter stores the holder returned by the subsystem's acquire +// into its result slot (%rdi) and returns that slot. +inline bool HasWebViewProtocolGetterContractX86_64(std::string_view code) { + return web_view_protocol_contract_detail::MatchesMasked( + code, 0, + std::string_view( + "\x55\x48\x89\xe5\x53\x50\x48\x89\xfb\x8a\x05\x00\x00\x00\x00\x84\xc0" + "\x74\x19\x48\x8d\x3d\x00\x00\x00\x00\xe8\x59\x00\x00\x00\x48\x89\x03" + "\x48\x89\xd8\x48\x83\xc4\x08\x5b\x5d\xc3", + kWebViewProtocolGetterBytesX86_64), + "...........xxxx.......xxxx.................."); +} + +// Returns the getter address called from the AArch64 initializer at +// `address`, or nullopt unless exactly one call site matches. +inline std::optional FindWebViewProtocolGetterCallArm64( + std::string_view code, std::uintptr_t address) { + using web_view_protocol_contract_detail::Word; + const std::size_t words = + code.substr(0, kWebViewProtocolInitializerScanBytes).size() / 4; + std::optional getter; + for (std::size_t index = 0; index + 4 <= words; ++index) { + const std::uint32_t slot_address = Word(code, index); + const std::uint32_t call = Word(code, index + 1); + const std::uint32_t handle_load = Word(code, index + 2); + const std::uint32_t protocol_load = Word(code, index + 3); + // add x8, sp, #slot + if ((slot_address & 0xffc003ffU) != 0x910003e8U) continue; + // bl getter + if ((call & 0xfc000000U) != 0x94000000U) continue; + // ldr xN, [sp, #slot] + if ((handle_load & 0xffc003e0U) != 0xf94003e0U) continue; + const std::uint32_t slot = (slot_address >> 10) & 0xfffU; + if (((handle_load >> 10) & 0xfffU) * 8 != slot) continue; + // ldr x8, [xN, #0x18] + const std::uint32_t handle_register = handle_load & 0x1fU; + if (protocol_load != (0xf9400c08U | (handle_register << 5))) continue; + if (getter.has_value()) return std::nullopt; + // imm26 is a signed word offset from the bl instruction. + const std::int64_t imm26 = call & 0x03ffffffU; + const std::int64_t words_from_call = + (imm26 & 0x02000000) != 0 ? imm26 - 0x04000000 : imm26; + getter = address + (index + 1) * 4 + + static_cast(words_from_call * 4); + } + return getter; +} + +// The AArch64 getter saves its result slot (x8) and stores the holder +// returned by the subsystem's acquire there. +inline bool HasWebViewProtocolGetterContractArm64(std::string_view code) { + using namespace web_view_protocol_contract_detail; + static constexpr MaskedWord kGetter[] = { + {kExact, 0xa9be7bfdU}, // stp x29, x30, [sp, #-0x20]! + {kExact, 0xf9000bf3U}, // str x19, [sp, #0x10] + {kExact, 0x910003fdU}, // mov x29, sp + {kAdrpMask, 0x90000009U}, // adrp x9, guard + {kAddImmediateMask, 0x91000129U}, // add x9, x9, :lo12:guard + {kExact, 0xaa0803f3U}, // mov x19, x8 + {kExact, 0x08dffd29U}, // ldarb w9, [x9] + {kExact, 0x36000109U}, // tbz w9, #0, initialize + {kAdrpMask, 0x90000000U}, // adrp x0, holder + {kAddImmediateMask, 0x91000000U}, // add x0, x0, :lo12:holder + {kExact, 0x94000017U}, // bl acquire + {kExact, 0xf9000260U}, // str x0, [x19] + {kExact, 0xf9400bf3U}, // ldr x19, [sp, #0x10] + {kExact, 0xa8c27bfdU}, // ldp x29, x30, [sp], #0x20 + {kExact, 0xd65f03c0U}, // ret + }; + static_assert(sizeof(kGetter) / sizeof(kGetter[0]) * 4 == + kWebViewProtocolGetterBytesArm64); + if (code.size() < kWebViewProtocolGetterBytesArm64) return false; + for (std::size_t index = 0; index < sizeof(kGetter) / sizeof(kGetter[0]); + ++index) { + if ((Word(code, index) & kGetter[index].mask) != kGetter[index].value) { + return false; + } + } + return true; +} + +} // namespace mocktail::compat + +#endif // MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_ diff --git a/include/runtime/roblox_experience_composition.h b/include/runtime/roblox_experience_composition.h index 3ba7242..73eadc1 100644 --- a/include/runtime/roblox_experience_composition.h +++ b/include/runtime/roblox_experience_composition.h @@ -3,6 +3,7 @@ #include +#include #include #include #include @@ -164,6 +165,7 @@ class RobloxExperienceComposition final { bool visible = true; bool back_navigation_disabled = false; bool show_domain_as_title = false; + bool close_when_challenge_solved = false; }; static Status DispatchLaunch(void* context, @@ -203,8 +205,11 @@ class RobloxExperienceComposition final { const WebSurfacePresentation& presentation); // WebViewProtocol and BrowserService are two logical transports over the // APK's single physical WebView container. Either protocol may close or - // mutate the surface that the other one opened. - Status CloseWebSurface(); + // mutate the surface that the other one opened. A nonzero + // expected_logical_generation closes only that surface. + Status CloseWebSurface(uint64_t expected_logical_generation = 0); + // Closes a solved challenge that Roblox left open past its grace period. + Status CloseUnclaimedSolvedChallenge(); void HandleWebSurfaceExit(uint64_t process_generation); Status Dispatch(const RobloxExperienceLaunchRequest& request); Status PromoteAuthenticatedSession(); @@ -262,6 +267,13 @@ class RobloxExperienceComposition final { uint64_t web_surface_logical_generation_ = 0; uint64_t next_web_surface_process_generation_ = 1; uint64_t next_web_surface_logical_generation_ = 1; + // The current surface closes as soon as its solved challenge is forwarded. + bool web_surface_close_when_challenge_solved_ = false; + // Set when a solved challenge is forwarded; applies only to that logical + // surface generation. + std::optional + solved_challenge_close_deadline_; + uint64_t solved_challenge_logical_generation_ = 0; SecureWebViewRobloxCookie web_view_cookie_; std::string web_view_cookie_initialization_error_; bool web_view_cookie_synchronized_ = false; diff --git a/include/runtime/roblox_web_view_bridge.h b/include/runtime/roblox_web_view_bridge.h index d93d7da..609ac9b 100644 --- a/include/runtime/roblox_web_view_bridge.h +++ b/include/runtime/roblox_web_view_bridge.h @@ -13,6 +13,8 @@ #include #include #include +#include +#include #include "mocktail/status.h" #include "runtime/roblox_game_session_native_adapter.h" @@ -42,6 +44,10 @@ struct RobloxWebViewOpenRequest { std::optional show_domain_as_title; std::optional back_button_visible; std::optional hide_header; + // Set by the bridge, never parsed. The host closes an OPEN_CAPTCHA_VIEW + // window once its solved captcha has been forwarded, as Android's captcha + // activity finishes itself on success. + bool close_when_challenge_solved = false; }; // WebViewProtocol.mutateWindow is a partial update. Field absence must remain @@ -61,6 +67,9 @@ Status ParseRobloxWebViewMutationJson(const std::string& json, RobloxWebViewMutationRequest* request); Status ParseRobloxCaptchaOpenJson(const std::string& json, RobloxWebViewOpenRequest* request); +// True for a page's Navigation.navigateToFeature command reporting a solved +// verification (see webview::IsRobloxChallengeSolvedFeature). +bool IsRobloxWebViewChallengeSuccess(std::string_view command); Status ParseRobloxProfileViewUrl(const std::string& url, RobloxWebViewOpenRequest* request); Status ParseRobloxPurchaseRobuxNotification(const std::string& json, @@ -108,6 +117,33 @@ using BroadcastWebViewDataModelFocusFn = void (*)(JNIEnv *, jclass, jstring, using SignalWebViewJavascriptCallbackFn = void (*)(JNIEnv*, jclass, jstring); using UpdateRobloxCookieSetHandlerFn = void (*)(JNIEnv*, jobject, jobject); +// Roblox's counted handle to its Subsystem. The protocol, +// and its handleJavascriptCallback subscriber, exist only while a handle is +// held. Roblox's handle type is not trivially destructible, so its getter +// returns it through the caller-supplied result slot (rdi on x86-64, x8 on +// AArch64). The user-provided destructor gives this type the same calling +// convention. It does not release the handle. +class RobloxWebViewProtocolHandle final { + public: + RobloxWebViewProtocolHandle() = default; + explicit RobloxWebViewProtocolHandle(void* holder) : holder_(holder) {} + RobloxWebViewProtocolHandle(const RobloxWebViewProtocolHandle&) = default; + RobloxWebViewProtocolHandle& operator=(const RobloxWebViewProtocolHandle&) = + default; + ~RobloxWebViewProtocolHandle() {} + + void* holder() const { return holder_; } + + private: + void* holder_ = nullptr; +}; +static_assert(sizeof(RobloxWebViewProtocolHandle) == sizeof(void*)); +static_assert(!std::is_trivially_destructible_v); + +// Roblox's Subsystem getter. It is not exported; the +// resolver finds it through the first call in +// WebViewProtocol.initializeAndroidWebViewProtocol. +using AcquireWebViewProtocolFn = RobloxWebViewProtocolHandle (*)(); struct RobloxWebViewMessageBusSymbols { GetWebViewOpenWindowIdFn get_open_window_id = nullptr; @@ -129,6 +165,8 @@ struct RobloxWebViewMessageBusSymbols { GetWebViewCloseWindowIdFn get_close_window_id = nullptr; SignalWebViewJavascriptCallbackFn signal_javascript_callback = nullptr; UpdateRobloxCookieSetHandlerFn update_cookie_set_handler = nullptr; + // Optional: found by code contract, not by symbol name. + AcquireWebViewProtocolFn acquire_web_view_protocol = nullptr; bool complete() const { return get_open_window_id != nullptr && @@ -287,6 +325,10 @@ private: std::array, kSubscriptionCount> raw_callback_targets_{}; std::shared_ptr host_window_close_target_; + // The reference this counts is kept for the process lifetime, as Roblox's + // Android app start keeps its own. Neither Shutdown nor destruction + // releases it. + RobloxWebViewProtocolHandle web_view_protocol_handle_; std::size_t in_flight_dispatches_ = 0; bool initializing_ = false; bool shutting_down_ = false; diff --git a/include/webview/roblox_challenge_features.h b/include/webview/roblox_challenge_features.h new file mode 100644 index 0000000..8b20d4a --- /dev/null +++ b/include/webview/roblox_challenge_features.h @@ -0,0 +1,19 @@ +#ifndef MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_ +#define MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_ + +#include + +namespace mocktail::webview { + +// Navigation.navigateToFeature features a Roblox verification page sends when +// the user has solved it. The captcha page sends CaptchaSuccess; the generic +// challenge page (challenge/cdn/hybrid) sends challengeCompleted. The WebView +// helper and the runtime both classify page events with this list. +inline bool IsRobloxChallengeSolvedFeature(std::string_view feature) { + return feature == "CaptchaSuccess" || feature == "challengeCompleted" || + feature == "ChallengeCompleted"; +} + +} // namespace mocktail::webview + +#endif // MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_ diff --git a/src/runtime/roblox_experience_composition.cc b/src/runtime/roblox_experience_composition.cc index 9bab0dd..a70b285 100644 --- a/src/runtime/roblox_experience_composition.cc +++ b/src/runtime/roblox_experience_composition.cc @@ -24,6 +24,11 @@ constexpr size_t kMaxPendingLaunchRequests = 8; constexpr size_t kLaunchWorkerStackSize = 64ULL * 1024 * 1024; // Covers helper exec through window creation, including cold Flatpak starts. constexpr std::chrono::milliseconds kWebSurfaceReadyTimeout{5000}; +// Roblox closes a solved WebViewProtocol challenge itself with closeWindow, +// as on Android. The host closes it only if Roblox has not done so within +// this period. Captcha windows close at once instead (see +// RobloxWebViewOpenRequest::close_when_challenge_solved). +constexpr std::chrono::milliseconds kSolvedChallengeCloseGrace{3000}; constexpr char kRobloxBaseUrl[] = "https://www.roblox.com/"; Status Invalid(std::string message) { @@ -637,6 +642,8 @@ Status RobloxExperienceComposition::OpenWebSurface( web_surface_process_generation_ = process_generation; web_surface_route_ = route; web_surface_logical_generation_ = logical_generation; + web_surface_close_when_challenge_solved_ = + presentation.close_when_challenge_solved; web_surface_logical_exit_observer_ = std::move(exit_observer); } if (superseded_observer.valid()) { @@ -649,13 +656,16 @@ Status RobloxExperienceComposition::OpenWebSurface( return Status::Ok(); } -Status RobloxExperienceComposition::CloseWebSurface() { +Status RobloxExperienceComposition::CloseWebSurface( + uint64_t expected_logical_generation) { std::lock_guard operation_lock(web_surface_operation_mutex_); std::shared_ptr process; WebViewHelperExitObserver exit_observer; { std::lock_guard lock(mutex_); - if (web_surface_route_ == WebSurfaceRoute::kNone) { + if (web_surface_route_ == WebSurfaceRoute::kNone || + (expected_logical_generation != 0 && + expected_logical_generation != web_surface_logical_generation_)) { return Status::Ok(); } process = std::move(web_surface_process_); @@ -663,6 +673,7 @@ Status RobloxExperienceComposition::CloseWebSurface() { exit_observer = std::move(web_surface_logical_exit_observer_); web_surface_route_ = WebSurfaceRoute::kNone; web_surface_logical_generation_ = 0; + web_surface_close_when_challenge_solved_ = false; } // A closed challenge must stop executing. Reusing its hidden document lets // late callbacks from the old attempt reach the next login attempt. The APK @@ -678,6 +689,33 @@ Status RobloxExperienceComposition::CloseWebSurface() { return Status::Ok(); } +Status RobloxExperienceComposition::CloseUnclaimedSolvedChallenge() { + uint64_t generation = 0; + { + std::lock_guard lock(mutex_); + if (!solved_challenge_close_deadline_.has_value()) { + return Status::Ok(); + } + const bool still_open = + web_surface_route_ != WebSurfaceRoute::kNone && + solved_challenge_logical_generation_ == web_surface_logical_generation_; + if (still_open && + std::chrono::steady_clock::now() < *solved_challenge_close_deadline_) { + return Status::Ok(); + } + solved_challenge_close_deadline_.reset(); + if (!still_open) { + return Status::Ok(); + } + generation = solved_challenge_logical_generation_; + } + std::fprintf(stderr, + " [webview] closing solved challenge window; Roblox did not " + "close it within %lld ms\n", + static_cast(kSolvedChallengeCloseGrace.count())); + return CloseWebSurface(generation); +} + void RobloxExperienceComposition::WebSurfaceExited(void* context) { auto* exit_context = static_cast(context); if (exit_context == nullptr || exit_context->target == nullptr || @@ -703,6 +741,7 @@ void RobloxExperienceComposition::HandleWebSurfaceExit( web_surface_process_generation_ = 0; web_surface_route_ = WebSurfaceRoute::kNone; web_surface_logical_generation_ = 0; + web_surface_close_when_challenge_solved_ = false; exit_observer = std::move(web_surface_logical_exit_observer_); } if (exit_observer.valid()) { @@ -728,6 +767,8 @@ Status RobloxExperienceComposition::DispatchWebViewOpen( // host back navigation too. presentation.back_navigation_disabled = request.back_button_visible.has_value() && !*request.back_button_visible; + presentation.close_when_challenge_solved = + request.close_when_challenge_solved; return composition->OpenWebSurface(request.url, "webview", WebSurfaceRoute::kWebView, std::move(exit_observer), presentation); @@ -990,9 +1031,9 @@ Status RobloxExperienceComposition::RouteCurrentWebSurfaceEvent( return AcceptWebViewRobloxCookie(event.payload); } if (event.type == WebViewHelperEventType::kExecuteRoblox) { - // Roblox defers a signalled launchGame until the Lua app next navigates. - // A parsed launchGame is launched here and never forwarded, so a deferred - // copy cannot start a second join later. + // A web Play button's launchGame is launched through the experience + // pipeline and closes the surface. It is never forwarded, so Roblox + // cannot start a second join from the same command. RobloxExperienceLaunchRequest request; if (ParseRobloxWebViewLaunchGame(event.payload, &request).ok() && Dispatch(request).ok()) { @@ -1002,7 +1043,29 @@ Status RobloxExperienceComposition::RouteCurrentWebSurfaceEvent( return CloseWebSurface(); } } - return RouteWebSurfaceEvent(route, event, web_view_bridge); + const Status status = RouteWebSurfaceEvent(route, event, web_view_bridge); + if (!status.ok() || !IsRobloxWebViewChallengeSuccess(event.payload)) { + return status; + } + std::fprintf(stderr, " [webview] solved challenge forwarded to Roblox\n"); + bool close_now = false; + { + std::lock_guard lock(mutex_); + if (logical_generation != web_surface_logical_generation_) { + return status; + } + close_now = web_surface_close_when_challenge_solved_; + if (!close_now) { + solved_challenge_close_deadline_ = + std::chrono::steady_clock::now() + kSolvedChallengeCloseGrace; + solved_challenge_logical_generation_ = logical_generation; + } + } + if (!close_now) { + return status; + } + std::fprintf(stderr, " [webview] closing solved captcha window\n"); + return CloseWebSurface(logical_generation); } Status RobloxExperienceComposition::DrainPlatformEvents() { @@ -1051,6 +1114,9 @@ Status RobloxExperienceComposition::DrainPlatformEvents() { status = drain_helper(web_surface_process, process_generation, logical_generation); } + if (status.ok()) { + status = CloseUnclaimedSolvedChallenge(); + } if (status.ok() && browser_service_bridge != nullptr) { status = browser_service_bridge->DrainOutgoingEvents(); } diff --git a/src/runtime/roblox_platform_web_symbols.cc b/src/runtime/roblox_platform_web_symbols.cc index d0d2a33..0b800de 100644 --- a/src/runtime/roblox_platform_web_symbols.cc +++ b/src/runtime/roblox_platform_web_symbols.cc @@ -1,7 +1,11 @@ #include "runtime/roblox_platform_web_symbols.h" +#include +#include #include +#include +#include "compat/web_view_protocol_contract.h" #include "linker/linker.h" namespace mocktail { @@ -14,6 +18,41 @@ Function Resolve(void* library, const char* name) { linker::ResolveSymbol(library, std::string(name))); } +// Reads the mapped initializer and follows its getter call only when both +// match the code contract for this host's architecture. Both reads stay +// inside libroblox.so's text segment: the initializer is longer than the scan +// window, and the getter address comes from a verified call site. +AcquireWebViewProtocolFn FindWebViewProtocolGetter(const void* initializer) { + if (initializer == nullptr) return nullptr; + const auto address = reinterpret_cast(initializer); + const std::string_view code(static_cast(initializer), + compat::kWebViewProtocolInitializerScanBytes); +#if defined(__x86_64__) + const std::optional getter = + compat::FindWebViewProtocolGetterCallX86_64(code, address); + const bool getter_matches = + getter.has_value() && + compat::HasWebViewProtocolGetterContractX86_64(std::string_view( + reinterpret_cast(*getter), + compat::kWebViewProtocolGetterBytesX86_64)); +#elif defined(__aarch64__) + const std::optional getter = + compat::FindWebViewProtocolGetterCallArm64(code, address); + const bool getter_matches = + getter.has_value() && + compat::HasWebViewProtocolGetterContractArm64(std::string_view( + reinterpret_cast(*getter), + compat::kWebViewProtocolGetterBytesArm64)); +#else + const std::optional getter; + const bool getter_matches = false; + (void)code; + (void)address; +#endif + return getter_matches ? reinterpret_cast(*getter) + : nullptr; +} + } // namespace RobloxPlatformWebSymbols ResolveRobloxPlatformWebSymbols( @@ -46,6 +85,9 @@ RobloxPlatformWebSymbols ResolveRobloxPlatformWebSymbols( roblox_library, "Java_com_roblox_protocols_webview_WebViewProtocol_" "initializeAndroidWebViewProtocol"); + symbols.web_view.acquire_web_view_protocol = FindWebViewProtocolGetter( + reinterpret_cast( + symbols.web_view.initialize_android_web_view_protocol)); symbols.web_view.subscribe_raw = subscribe_raw; symbols.web_view.delete_connection = delete_connection; symbols.web_view.set_request_handler_raw = diff --git a/src/runtime/roblox_web_view_bridge.cc b/src/runtime/roblox_web_view_bridge.cc index 2de6421..4b7bf62 100644 --- a/src/runtime/roblox_web_view_bridge.cc +++ b/src/runtime/roblox_web_view_bridge.cc @@ -9,6 +9,7 @@ #include #include "runtime/environment.h" +#include "webview/roblox_challenge_features.h" namespace mocktail { namespace runtime { @@ -401,6 +402,39 @@ Status ParseRobloxCaptchaOpenJson(const std::string& json, return Status::Ok(); } +bool IsRobloxWebViewChallengeSuccess(std::string_view command) { + if (command.empty() || command.size() > kMaximumRobloxWebViewJsonBytes) { + return false; + } + const nlohmann::json document = + nlohmann::json::parse(command.begin(), command.end(), nullptr, false); + const auto string_field = [](const nlohmann::json& object, + const char* key) -> const std::string* { + const auto field = object.find(key); + return field != object.end() && field->is_string() + ? &field->get_ref() + : nullptr; + }; + const auto object_field = [](const nlohmann::json& object, + const char* key) -> const nlohmann::json* { + const auto field = object.find(key); + return field != object.end() && field->is_object() ? &*field : nullptr; + }; + if (!document.is_object()) { + return false; + } + const std::string* module = string_field(document, "moduleID"); + const std::string* function = string_field(document, "functionName"); + const nlohmann::json* outer = object_field(document, "params"); + const nlohmann::json* navigation = + outer != nullptr ? object_field(*outer, "params") : nullptr; + const std::string* feature = + navigation != nullptr ? string_field(*navigation, "feature") : nullptr; + return module != nullptr && *module == "Navigation" && function != nullptr && + *function == "navigateToFeature" && feature != nullptr && + webview::IsRobloxChallengeSolvedFeature(*feature); +} + Status ParseRobloxProfileViewUrl(const std::string& url, RobloxWebViewOpenRequest* request) { if (request == nullptr) { @@ -911,6 +945,20 @@ Status RobloxWebViewBridge::Initialize() { // handler and window subscriptions are installed before the native Android // protocol is initialized. Calling the native initializer earlier loses // messages that it can publish synchronously during startup. + // Roblox's Android app start holds a WebViewProtocol handle for the process + // lifetime. Without one, the protocol is deleted as soon as each caller + // releases it, and page callbacks wait for the next Lua WebView call to + // recreate it. Taking it before the native initializer keeps the protocol + // that initializer configures. + if (symbols_.acquire_web_view_protocol != nullptr) { + if (web_view_protocol_handle_.holder() == nullptr) { + web_view_protocol_handle_ = symbols_.acquire_web_view_protocol(); + } + } else { + std::fprintf(stderr, + " [webview] WebViewProtocol getter not recognized; page " + "callbacks may wait for the next Roblox WebView call\n"); + } jclass initialization_class = env->FindClass(kWebViewProtocolClass); if (initialization_class != nullptr) { symbols_.initialize_android_web_view_protocol(env, initialization_class); @@ -1266,6 +1314,7 @@ Status RobloxWebViewBridge::DispatchOpenRequest( exit_context->target->browser_login_fallback = browser_login_fallback; } } + request.close_when_challenge_solved = source == OpenSource::kCaptcha; if (status.ok()) { status = sink_.dispatch_open( sink_.context, request, diff --git a/src/webview/mocktail_webview_helper.cc b/src/webview/mocktail_webview_helper.cc index e2414cc..76c3daf 100644 --- a/src/webview/mocktail_webview_helper.cc +++ b/src/webview/mocktail_webview_helper.cc @@ -108,15 +108,9 @@ struct SurfaceState { unsigned int resource_retries = 0; }; +// A solved challenge stays open: the host forwards the event to Roblox, and +// Roblox or the host closes the window after Roblox has received it. struct CallbackConfirmation { - CallbackConfirmation(GtkWindow* surface_window, CaptchaEventType event_type) - : type(event_type) { - g_weak_ref_init(&window, G_OBJECT(surface_window)); - } - - ~CallbackConfirmation() { g_weak_ref_clear(&window); } - - GWeakRef window; CaptchaEventType type = CaptchaEventType::kShown; }; @@ -612,16 +606,6 @@ void FinishCallbackConfirmation(GObject* source_object, GAsyncResult* result, if (value != nullptr) { g_object_unref(value); } - - auto* window_object = - static_cast(g_weak_ref_get(&confirmation->window)); - if (confirmed && confirmation->type == CaptchaEventType::kSuccess && - window_object != nullptr) { - gtk_window_close(GTK_WINDOW(window_object)); - } - if (window_object != nullptr) { - g_object_unref(window_object); - } delete confirmation; } @@ -644,9 +628,8 @@ void HandleHybridCommand(WebKitUserContentManager* manager, if (window_object == nullptr) { return; } - auto* confirmation = - new CallbackConfirmation(GTK_WINDOW(window_object), event.type); g_object_unref(window_object); + auto* confirmation = new CallbackConfirmation{event.type}; const std::string script = BuildCallbackScript(event.callback_id); WebKitWebView* web_view = WEBKIT_WEB_VIEW( g_object_get_data(G_OBJECT(manager), "mocktail-web-view")); diff --git a/src/webview/webview_helper_policy.cc b/src/webview/webview_helper_policy.cc index 11dce0f..d9e1730 100644 --- a/src/webview/webview_helper_policy.cc +++ b/src/webview/webview_helper_policy.cc @@ -9,6 +9,8 @@ #include #include +#include "webview/roblox_challenge_features.h" + namespace mocktail { namespace webview { namespace { @@ -306,8 +308,7 @@ bool ParseCaptchaEvent(std::string_view command, CaptchaEvent* event) { event->type = CaptchaEventType::kShown; return true; } - if (feature == "CaptchaSuccess" || feature == "challengeCompleted" || - feature == "ChallengeCompleted") { + if (IsRobloxChallengeSolvedFeature(feature)) { event->type = CaptchaEventType::kSuccess; return true; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ebe6d63..e1caa78 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -722,6 +722,15 @@ target_link_libraries(elf_build_id_test PRIVATE GTest::gtest_main ) +add_executable(web_view_protocol_contract_test + web_view_protocol_contract_test.cc +) +target_link_libraries(web_view_protocol_contract_test PRIVATE + Mocktail::Compat + GTest::gtest_main +) +mocktail_apply_compile_options(web_view_protocol_contract_test) + add_executable(build_profile_test build_profile_test.cc) target_link_libraries(build_profile_test PRIVATE Mocktail::Compat @@ -1049,6 +1058,7 @@ gtest_discover_tests(auth_service_test) gtest_discover_tests(auth_runtime_composition_test) gtest_discover_tests(client_settings_service_test) gtest_discover_tests(elf_build_id_test) +gtest_discover_tests(web_view_protocol_contract_test) gtest_discover_tests(build_profile_test) gtest_discover_tests(host_abi_profile_test) gtest_discover_tests(payload_compatibility_host_abi_gate_test) diff --git a/tests/roblox_experience_composition_test.cc b/tests/roblox_experience_composition_test.cc index a5dfd3d..9f88ae1 100644 --- a/tests/roblox_experience_composition_test.cc +++ b/tests/roblox_experience_composition_test.cc @@ -1225,6 +1225,61 @@ while True: uint64_t process_generation) { composition->HandleWebSurfaceExit(process_generation); } + + static void MarkClosesWhenChallengeSolved( + RobloxExperienceComposition* composition) { + std::lock_guard lock(composition->mutex_); + composition->web_surface_close_when_challenge_solved_ = true; + } + + static bool SolvedChallengeClosePending( + RobloxExperienceComposition* composition) { + std::lock_guard lock(composition->mutex_); + return composition->solved_challenge_close_deadline_.has_value(); + } + + static void ExpireSolvedChallengeGrace( + RobloxExperienceComposition* composition) { + std::lock_guard lock(composition->mutex_); + if (composition->solved_challenge_close_deadline_.has_value()) { + composition->solved_challenge_close_deadline_ = + std::chrono::steady_clock::now(); + } + } + + static std::string ChallengeSuccess() { + return R"({"moduleID":"Navigation","functionName":"navigateToFeature",)" + R"("params":{"params":{"data":{"captchaData":{}},)" + R"("feature":"CaptchaSuccess"}},"callbackID":"challenge-1"})"; + } + + static std::unique_ptr MakeWebViewComposition( + jnivm::VM* vm, Probe* probe) { + for (const char* class_name : { + "com/roblox/protocols/webview/WebViewProtocol", + "com/roblox/universalapp/messagebus/MessageBus", + "com/roblox/universalapp/messagebus/Connection", + "com/roblox/engine/jni/memstorage/MemStorage", + "com/roblox/engine/jni/memstorage/Connection", + "com/roblox/engine/jni/memstorage/Callback", + }) { + vm->RegisterClass(class_name); + } + return std::make_unique( + JniEnvironmentProvider{vm->GetJavaVM(), vm, Prepare}, + RobloxExperienceMessageBusSymbols{}, + RobloxWebViewMessageBusSymbols{ + GetWebViewOpenId, GetWebViewHandleWindowCloseId, + GetWebViewProtocolName, GetWebViewAvailabilityId, + GetWebViewMessageId, InitializeWebViewProtocol, Subscribe, + Disconnect, SetRequestHandler, ClearNativeRequestHandler, + PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId, + GetWebViewCloseId, SignalWebViewJavascriptCallback, + UpdateCookieSetHandler}, + BrowserServiceSymbols(), PermissionsSymbols(), + RobloxGameSessionSymbols{}, JniFactory(probe), + RobloxFreshLaunchPresentBoundary{}); + } }; TEST_F(RobloxExperienceCompositionWebSurfaceTest, @@ -1412,6 +1467,123 @@ TEST_F(RobloxExperienceCompositionWebSurfaceTest, << status.message(); } +TEST_F(RobloxExperienceCompositionWebSurfaceTest, + ForwardsSolvedChallengeAndLeavesClosingToRoblox) { + jnivm::VM vm; + Probe probe{&vm}; + g_probe = &probe; + auto composition = MakeWebViewComposition(&vm, &probe); + ASSERT_TRUE(composition->InitializePlatformProtocols().ok()); + auto exit_probe = std::make_shared(); + Activate(composition.get(), false, 17, 23, exit_probe); + + const std::string success = ChallengeSuccess(); + ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23, + {WebViewHelperEventType::kRobloxWkHybrid, + success}) + .ok()); + EXPECT_EQ(probe.web_view_javascript_callback, success); + EXPECT_TRUE(SolvedChallengeClosePending(composition.get())); + ASSERT_TRUE(composition->DrainPlatformEvents().ok()); + EXPECT_EQ(exit_probe->calls, 0); + + ASSERT_TRUE(Close(composition.get()).ok()); + EXPECT_EQ(exit_probe->calls, 1); + ExpireSolvedChallengeGrace(composition.get()); + ASSERT_TRUE(composition->DrainPlatformEvents().ok()); + EXPECT_EQ(exit_probe->calls, 1); + EXPECT_FALSE(SolvedChallengeClosePending(composition.get())); + + EXPECT_TRUE(composition->Shutdown().ok()); + g_probe = nullptr; +} + +TEST_F(RobloxExperienceCompositionWebSurfaceTest, + ClosesSolvedChallengeWhenRobloxLeavesItOpen) { + jnivm::VM vm; + Probe probe{&vm}; + g_probe = &probe; + auto composition = MakeWebViewComposition(&vm, &probe); + ASSERT_TRUE(composition->InitializePlatformProtocols().ok()); + auto exit_probe = std::make_shared(); + Activate(composition.get(), false, 17, 23, exit_probe); + + ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23, + {WebViewHelperEventType::kExecuteRoblox, + ChallengeSuccess()}) + .ok()); + ASSERT_TRUE(composition->DrainPlatformEvents().ok()); + EXPECT_EQ(exit_probe->calls, 0); + + ExpireSolvedChallengeGrace(composition.get()); + ::testing::internal::CaptureStderr(); + const Status status = composition->DrainPlatformEvents(); + const std::string log = ::testing::internal::GetCapturedStderr(); + ASSERT_TRUE(status.ok()) << status.message(); + EXPECT_EQ(exit_probe->calls, 1); + EXPECT_NE(log.find("closing solved challenge window"), std::string::npos) + << log; + EXPECT_FALSE(SolvedChallengeClosePending(composition.get())); + + EXPECT_TRUE(composition->Shutdown().ok()); + g_probe = nullptr; +} + +TEST_F(RobloxExperienceCompositionWebSurfaceTest, + ClosesSolvedCaptchaWindowAfterForwardingSuccess) { + jnivm::VM vm; + Probe probe{&vm}; + g_probe = &probe; + auto composition = MakeWebViewComposition(&vm, &probe); + ASSERT_TRUE(composition->InitializePlatformProtocols().ok()); + auto exit_probe = std::make_shared(); + Activate(composition.get(), false, 17, 23, exit_probe); + MarkClosesWhenChallengeSolved(composition.get()); + + const std::string success = ChallengeSuccess(); + ::testing::internal::CaptureStderr(); + const Status status = RouteCurrentEvent( + composition.get(), 17, 23, + {WebViewHelperEventType::kRobloxWkHybrid, success}); + const std::string log = ::testing::internal::GetCapturedStderr(); + ASSERT_TRUE(status.ok()) << status.message(); + EXPECT_EQ(probe.web_view_javascript_callback, success); + EXPECT_EQ(exit_probe->calls, 1); + EXPECT_FALSE(SolvedChallengeClosePending(composition.get())); + EXPECT_NE(log.find("closing solved captcha window"), std::string::npos) + << log; + + EXPECT_TRUE(composition->Shutdown().ok()); + g_probe = nullptr; +} + +TEST_F(RobloxExperienceCompositionWebSurfaceTest, + UnsolvedChallengeEventsKeepWindowOpen) { + jnivm::VM vm; + Probe probe{&vm}; + g_probe = &probe; + auto composition = MakeWebViewComposition(&vm, &probe); + ASSERT_TRUE(composition->InitializePlatformProtocols().ok()); + auto exit_probe = std::make_shared(); + Activate(composition.get(), false, 17, 23, exit_probe); + + const std::string shown = + R"({"moduleID":"Navigation","functionName":"navigateToFeature",)" + R"("params":{"params":{"feature":"CaptchaShown"}},"callbackID":"c"})"; + ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23, + {WebViewHelperEventType::kRobloxWkHybrid, + shown}) + .ok()); + EXPECT_EQ(probe.web_view_javascript_callback, shown); + EXPECT_FALSE(SolvedChallengeClosePending(composition.get())); + ASSERT_TRUE(composition->DrainPlatformEvents().ok()); + EXPECT_EQ(exit_probe->calls, 0); + + EXPECT_TRUE(Close(composition.get()).ok()); + EXPECT_TRUE(composition->Shutdown().ok()); + g_probe = nullptr; +} + namespace {} // namespace } // namespace runtime } // namespace mocktail diff --git a/tests/roblox_web_view_bridge_test.cc b/tests/roblox_web_view_bridge_test.cc index 8d96ba8..0ff9457 100644 --- a/tests/roblox_web_view_bridge_test.cc +++ b/tests/roblox_web_view_bridge_test.cc @@ -162,6 +162,13 @@ void InitializeAndroidWebViewProtocol(JNIEnv *, jclass) { g_web_view_probe->initialization_order.push_back("initialize"); } +int g_fake_protocol_holder = 0; + +RobloxWebViewProtocolHandle AcquireWebViewProtocol() { + g_web_view_probe->initialization_order.push_back("acquire_protocol"); + return RobloxWebViewProtocolHandle(&g_fake_protocol_holder); +} + void SignalJavascriptCallback(JNIEnv* env, jclass, jstring payload) { g_web_view_probe->hybrid_callback_payload = ReadJavaString(env, payload); } @@ -473,6 +480,34 @@ TEST(RobloxWebViewParserTest, BuildsExactCaptchaRoutesWithoutGuessingSuccess) { .ok()); } +TEST(RobloxWebViewParserTest, RecognizesOnlyChallengeSuccessNavigation) { + const auto navigation = [](const std::string& feature) { + return R"({"moduleID":"Navigation","functionName":"navigateToFeature",)" + R"("params":{"params":{"data":{"captchaData":{}},"feature":")" + + feature + R"("}},"callbackID":"challenge-1"})"; + }; + EXPECT_TRUE(IsRobloxWebViewChallengeSuccess(navigation("CaptchaSuccess"))); + EXPECT_TRUE( + IsRobloxWebViewChallengeSuccess(navigation("challengeCompleted"))); + EXPECT_TRUE( + IsRobloxWebViewChallengeSuccess(navigation("ChallengeCompleted"))); + + EXPECT_FALSE(IsRobloxWebViewChallengeSuccess(navigation("CaptchaShown"))); + EXPECT_FALSE( + IsRobloxWebViewChallengeSuccess(navigation("ChallengeDisplayed"))); + EXPECT_FALSE(IsRobloxWebViewChallengeSuccess( + R"({"moduleID":"Game","functionName":"navigateToFeature",)" + R"("params":{"params":{"feature":"CaptchaSuccess"}}})")); + EXPECT_FALSE(IsRobloxWebViewChallengeSuccess( + R"({"moduleID":"Navigation","functionName":"navigateToFeature",)" + R"("params":{"feature":"CaptchaSuccess"}})")); + EXPECT_FALSE(IsRobloxWebViewChallengeSuccess( + R"({"moduleID":7,"functionName":["navigateToFeature"],)" + R"("params":{"params":{"feature":{"CaptchaSuccess":true}}}})")); + EXPECT_FALSE(IsRobloxWebViewChallengeSuccess("CaptchaSuccess")); + EXPECT_FALSE(IsRobloxWebViewChallengeSuccess("")); +} + TEST(RobloxWebViewParserTest, AcceptsOnlyCanonicalProfileRoutes) { RobloxWebViewOpenRequest request; Status status = ParseRobloxProfileViewUrl( @@ -656,6 +691,7 @@ TEST(RobloxWebViewBridgeTest, ASSERT_EQ(probe.dispatches, 2); EXPECT_EQ(probe.request.url, "https://www.roblox.com/games/servers-section/10148749921"); + EXPECT_FALSE(probe.request.close_when_challenge_solved); ASSERT_TRUE(bridge.DrainHostWindowEvents().ok()); EXPECT_EQ(probe.data_model_focus_states, (std::vector{"Unfocused"})); @@ -677,6 +713,7 @@ TEST(RobloxWebViewBridgeTest, ASSERT_EQ(probe.dispatches, 3); EXPECT_EQ(probe.request.url, "https://www.roblox.com/login"); EXPECT_EQ(probe.request.title, "Roblox sign in"); + EXPECT_TRUE(probe.request.close_when_challenge_solved); EXPECT_TRUE(bridge.HandleCloseWindow().ok()); EXPECT_EQ(probe.close_dispatches, 1); ASSERT_TRUE(bridge.DrainHostWindowEvents().ok()); @@ -1218,6 +1255,45 @@ TEST(RobloxWebViewBridgeTest, g_web_view_probe = nullptr; } +TEST(RobloxWebViewBridgeTest, HoldsWebViewProtocolFromBeforeNativeInitialization) { + const ScopedNativeLogin native_login(false); + jnivm::VM vm; + JNIEnv* env = vm.GetJNIEnv(); + jclass bus_class = + env->FindClass("com/roblox/universalapp/messagebus/MessageBus"); + jobject bus = env->AllocObject(bus_class); + WebViewBridgeProbe probe; + g_web_view_probe = &probe; + { + RobloxWebViewBridge bridge( + {vm.GetJavaVM(), nullptr, nullptr}, + {&GetOpenWindowId, &GetHandleWindowCloseId, &GetProtocolName, + &GetIsAvailableId, &GetMessageId, &InitializeAndroidWebViewProtocol, + &SubscribeRaw, &DeleteConnection, &SetRequestHandler, + &ClearRequestHandler, &PublishRaw, &BroadcastDataModelFocus, + &GetMutateWindowId, &GetCloseWindowId, &SignalJavascriptCallback, + &UpdateCookieSetHandler, &AcquireWebViewProtocol}, + {bus, &vm, &CreateRawCallback, &ClearRawCallback, &CreateRequestHandler, + &ClearRequestHandlerObject, &SetPlatformWebCallbacks, + &ClearPlatformWebCallbacks}, + {&probe, &DispatchOpen, &DispatchMutate, &DispatchClose, + &DispatchCookie}); + + ASSERT_TRUE(bridge.Initialize().ok()); + ASSERT_TRUE(bridge.Shutdown().ok()); + } + + // Roblox's own app start keeps this handle for the process lifetime. + // Releasing it deletes the protocol and its handleJavascriptCallback + // subscriber. + EXPECT_EQ(probe.initialization_order, + (std::vector{ + "message_id:openWindow", "message_id:mutateWindow", + "message_id:closeWindow", "availability", "subscribe", + "subscribe", "subscribe", "acquire_protocol", "initialize"})); + g_web_view_probe = nullptr; +} + } // namespace } // namespace runtime } // namespace mocktail diff --git a/tests/web_view_protocol_contract_test.cc b/tests/web_view_protocol_contract_test.cc new file mode 100644 index 0000000..2554586 --- /dev/null +++ b/tests/web_view_protocol_contract_test.cc @@ -0,0 +1,198 @@ +#include "compat/web_view_protocol_contract.h" + +#include + +#include +#include +#include +#include +#include + +namespace mocktail::compat { +namespace { + +std::string FromHex(std::string_view hex) { + std::string bytes; + for (std::size_t index = 0; index + 1 < hex.size(); index += 2) { + bytes += static_cast( + std::stoi(std::string(hex.substr(index, 2)), nullptr, 16)); + } + return bytes; +} + +std::string Flipped(std::string code, std::size_t index) { + code[index] = static_cast(code[index] ^ 0x01); + return code; +} + +// Start of initializeAndroidWebViewProtocol and the getter it calls first, +// from the x86_64 and arm64-v8a libroblox.so of each named Roblox release. +struct KnownBuild { + const char* name; + std::uintptr_t initializer; + const char* initializer_code; + std::uintptr_t getter; + const char* getter_code; +}; + +constexpr KnownBuild kX86_64Builds[] = { + {"2.734.917", 0x2e7418f, + "554889e54157415641554154534883ec384c8b2599ce1004498b0424488945d0488d5da8" + "4889dfe8c9a355ff488b1b488b43184c", + 0x23ce584, + "554889e553504889fb8a05ede9c10484c07419488d3dbae9c104e8590000004889034889" + "d84883c4085b5dc3"}, + {"2.736.1408", 0x2e9d241, + "554889e54157415641554154534883ec484c8b25df291c04498b0424488945d0488d5d98" + "4889dfe8af2d56ff488b1b488b43184c", + 0x240001c, + "554889e553504889fb8a05a5b9cc0484c07419488d3d72b9cc04e8590000004889034889" + "d84883c4085b5dc3"}, + {"2.738.1397", 0x2e6c591, + "554889e54157415641554154534883ec184c8b2577d52704498b0424488945d0488d5dc0" + "4889dfe8fbf55cff488b1b488b43184c", + 0x243bbb8, + "554889e553504889fb8a05f99dd10484c07419488d3dc69dd104e8590000004889034889" + "d84883c4085b5dc3"}, +}; + +constexpr KnownBuild kArm64Builds[] = { + {"2.738.1397", 0x2bac4bc, + "ffc301d1fd7b02a9f91b00f9f85f04a9f65705a9f44f06a9fd83009137e101b0f77a43f9" + "e80240f9a8831ff8e8430091e82300915244de97f30740f9680e40f9", + 0x233d638, + "fd7bbea9f30b00f9fd030091e92702f029e13391f30308aa29fddf0809010036e02702f0" + "0040339117000094600200f9f30b40f9fd7bc2a8c0035fd6"}, + {"2.740.931", 0x2be87d8, + "ff0302d1fd7b03a9f92300f9f85f05a9f65706a9f44f07a9fdc3009157e801d0f7b244f9" + "e80240f9a8831ff8e84b0091e82300913a64de97f30740f9680e40f9", + 0x23818f4, + "fd7bbea9f30b00f9fd030091492f02d029212a91f30308aa29fddf0809010036402f02d0" + "0080299117000094600200f9f30b40f9fd7bc2a8c0035fd6"}, +}; + +TEST(WebViewProtocolContractTest, FindsX86_64GetterInKnownBuilds) { + for (const KnownBuild& build : kX86_64Builds) { + EXPECT_EQ(FindWebViewProtocolGetterCallX86_64( + FromHex(build.initializer_code), build.initializer), + std::optional(build.getter)) + << build.name; + const std::string getter = FromHex(build.getter_code); + ASSERT_EQ(getter.size(), kWebViewProtocolGetterBytesX86_64) << build.name; + EXPECT_TRUE(HasWebViewProtocolGetterContractX86_64(getter)) << build.name; + } +} + +TEST(WebViewProtocolContractTest, RejectsChangedX86_64CallSite) { + const KnownBuild& build = kX86_64Builds[2]; + const std::string code = FromHex(build.initializer_code); + // Frame setup, the handle slot's lea, the handle argument, the call, the + // handle load and the protocol field offset. + for (const std::size_t fixed : {0u, 3u, 32u, 34u, 36u, 39u, 44u, 50u}) { + EXPECT_FALSE(FindWebViewProtocolGetterCallX86_64(Flipped(code, fixed), + build.initializer) + .has_value()) + << fixed; + } + EXPECT_FALSE(FindWebViewProtocolGetterCallX86_64(code.substr(0, 50), + build.initializer) + .has_value()); +} + +TEST(WebViewProtocolContractTest, RejectsAmbiguousX86_64CallSite) { + const KnownBuild& build = kX86_64Builds[2]; + const std::string code = FromHex(build.initializer_code); + EXPECT_FALSE(FindWebViewProtocolGetterCallX86_64(code + code.substr(32, 19), + build.initializer) + .has_value()); +} + +TEST(WebViewProtocolContractTest, RejectsChangedX86_64Getter) { + const std::string getter = FromHex(kX86_64Builds[2].getter_code); + // Frame setup, the out-pointer save, the guard test, the holder lea, the + // local acquire call, the out-pointer store and the return. + for (const std::size_t fixed : {0u, 8u, 16u, 21u, 27u, 33u, 43u}) { + EXPECT_FALSE(HasWebViewProtocolGetterContractX86_64(Flipped(getter, fixed))) + << fixed; + } + EXPECT_FALSE(HasWebViewProtocolGetterContractX86_64( + getter.substr(0, getter.size() - 1))); +} + +TEST(WebViewProtocolContractTest, FindsArm64GetterInKnownBuilds) { + for (const KnownBuild& build : kArm64Builds) { + EXPECT_EQ(FindWebViewProtocolGetterCallArm64( + FromHex(build.initializer_code), build.initializer), + std::optional(build.getter)) + << build.name; + const std::string getter = FromHex(build.getter_code); + ASSERT_EQ(getter.size(), kWebViewProtocolGetterBytesArm64) << build.name; + EXPECT_TRUE(HasWebViewProtocolGetterContractArm64(getter)) << build.name; + } +} + +TEST(WebViewProtocolContractTest, RejectsChangedArm64CallSite) { + const KnownBuild& build = kArm64Builds[0]; + const std::string code = FromHex(build.initializer_code); + // Words 12..15: add x8, sp, #8; bl getter; ldr x19, [sp, #8]; + // ldr x8, [x19, #0x18]. Byte 0 of each word holds its low register field. + EXPECT_FALSE( + FindWebViewProtocolGetterCallArm64(Flipped(code, 48), build.initializer) + .has_value()); + std::string not_a_call = code; + not_a_call[55] = static_cast(not_a_call[55] ^ 0x80); // bl -> b + EXPECT_FALSE(FindWebViewProtocolGetterCallArm64(not_a_call, build.initializer) + .has_value()); + EXPECT_FALSE( + FindWebViewProtocolGetterCallArm64(Flipped(code, 56), build.initializer) + .has_value()); + EXPECT_FALSE( + FindWebViewProtocolGetterCallArm64(Flipped(code, 60), build.initializer) + .has_value()); + // The handle is read back from a different stack slot than the one passed. + std::string other_slot = code; + other_slot[57] = static_cast(other_slot[57] ^ 0x04); + EXPECT_FALSE(FindWebViewProtocolGetterCallArm64(other_slot, build.initializer) + .has_value()); + EXPECT_FALSE(FindWebViewProtocolGetterCallArm64(code.substr(0, 60), + build.initializer) + .has_value()); +} + +TEST(WebViewProtocolContractTest, RejectsAmbiguousArm64CallSite) { + const KnownBuild& build = kArm64Builds[0]; + const std::string code = FromHex(build.initializer_code); + EXPECT_FALSE(FindWebViewProtocolGetterCallArm64(code + code.substr(48, 16), + build.initializer) + .has_value()); +} + +TEST(WebViewProtocolContractTest, RejectsChangedArm64Getter) { + const std::string getter = FromHex(kArm64Builds[0].getter_code); + // Frame setup, mov x19, x8 (the indirect result register), the guard + // branch, the local acquire call, the result store and the return. + for (const std::size_t fixed : {0u, 20u, 28u, 40u, 44u, 56u}) { + EXPECT_FALSE(HasWebViewProtocolGetterContractArm64(Flipped(getter, fixed))) + << fixed; + } + EXPECT_FALSE(HasWebViewProtocolGetterContractArm64( + getter.substr(0, getter.size() - 1))); +} + +TEST(WebViewProtocolContractTest, RejectsOtherArchitectureCode) { + const KnownBuild& x86_64 = kX86_64Builds[2]; + const KnownBuild& arm64 = kArm64Builds[0]; + EXPECT_FALSE(FindWebViewProtocolGetterCallArm64( + FromHex(x86_64.initializer_code), x86_64.initializer) + .has_value()); + EXPECT_FALSE(FindWebViewProtocolGetterCallX86_64( + FromHex(arm64.initializer_code), arm64.initializer) + .has_value()); + EXPECT_FALSE(HasWebViewProtocolGetterContractArm64( + FromHex(x86_64.getter_code) + std::string(16, '\0'))); + EXPECT_FALSE(HasWebViewProtocolGetterContractX86_64( + FromHex(arm64.getter_code))); +} + +} // namespace +} // namespace mocktail::compat