Skip to content

Security: vinvicta/libqplay

Security

docs/SECURITY.md

Security and trust-boundary review

This page records the security review of the original Graal Online Classic 1.8 APK and its ARM64 libqplay.so. It is intentionally conservative. A string, an imported function, or an update capability is evidence of an attack surface; it is not by itself proof that a remote attacker can reach or exploit it.

The review was performed against the following private inputs:

  • APK SHA-256: 6d6c0428fe890d0f18fb1ce572798d7a8a95853b10078f693026164d6a5f56d7.
  • ARM64 libqplay.so SHA-256: 9348dd87a571050e05a9c9b76d71d37aa697de1836be5b86ea9982eb00e5b9c8.
  • Package: com.quattroplay.GraalClassic.
  • Version: code 6158, name 1.8.

The machine-readable evidence is in artifacts/original_apk_security_audit_20260830.json and artifacts/original_security_callsite_review_20260830.json. The first report is produced by tools/audit_original_apk.py. The second is exported directly from the original ARM64 IDA database by tools/ida_export_original_security_callsite_review.py; it now also includes the update-package parser and resource path helpers. The Java WebView boundary review is summarized in artifacts/original_dex_webview_review_20260830.json. The focused package path export is in artifacts/original_update_package_path_review_20260830.json and is generated by tools/ida_export_original_update_package_path_review.py. The request and response integrity export is in artifacts/original_update_integrity_review_20260830.json and is generated by tools/ida_export_original_update_integrity_review.py. The Android startup and pause boundary is recorded in artifacts/original_android_lifecycle_review_20260830.json and is generated by tools/ida_export_original_android_lifecycle_review.py. The method-level DEX callsite map is in artifacts/original_dex_method_surface_review_20260904.json and is generated by tools/audit_dex_method_surface.py.

Executive assessment

The most likely reason this old client no longer starts is compatibility, not a newly introduced exploit. The native connector embeds a historical trust bundle whose first recorded certificate expired on 2023-07-29. The connector also has legacy HTTP parsing and a native CyaSSL path that is independent of Android's Java trust store. A current service chain cannot be assumed to work with the old embedded material.

There are also several meaningful security boundaries:

  1. The APK has network permission and four native ABI variants. The native library opens sockets directly and contains its own TLS implementation.
  2. The main activity accepts graalclassic:// and graalclassicplus://. The manifest does not write an explicit android:exported value, but its intent filters make the activity effectively exported under the old Android component rules. Other applications can therefore request that activity.
  3. The Java DEX contains separate JavaScript-enabled WebView surfaces. The game activity loads native-supplied URLs, while a bundled Bolts app-link resolver owns the addJavascriptInterface bridge. The audit establishes these capabilities and their boundaries, but not attacker reachability.
  4. Update code can remove package files and can start a replacement executable. The generic deletion helper calls unlink, while the reviewed script and update callers provide the path policy around it. The executable handoff is a high-impact capability, but this pass did not prove an attacker-controlled update package or executable path.
  5. Device-identification code reads the eth0 hardware address, hashes it for a network identifier, and exposes other system-ID modes including Android ID. This is a privacy concern and an account-correlation surface, not evidence of code execution.
  6. The DEX contains onReceivedSslError, DISABLE_SSL_CHECK_FOR_TESTING, and fz_server_domain_ssl indicators. Local smali review resolves the first two to Facebook WebDialog code where the test flag is false and SSL errors are cancelled. This is not a Java SSL-bypass finding. The native connector is a separate CyaSSL implementation.
  7. The native TSocket property table exposes bind, connect, send, and sendudp. Its connection helper contains local bind, listen, and accept operations as well as TCP and UDP data paths. This is a conditional local-listener capability for activated script code. The stock connector is not shown to start a listener, and no runtime bind or external exposure was tested.
  8. The remaining Android JNI callbacks show that the render loop is also the native heartbeat. onAppPause can set the close flag before a client exists or while loading is still in its early states, after which QPlayLoop calls exit(0). This is a compatibility and observability lead for silent startup failures, not evidence of a server-side TLS rejection.

HTTPS error downgrade path

The connector has a confirmed static transport-policy concern in artifacts/connector_fallback_review_20260902.json, generated by tools/generate_connector_fallback_review.py. When the native CyaSSL read or write path records a nonzero error during connector HTTPS mode 1 or 2, THTTPRequest_saveDownloadedData_void skips the remaining HTTPS retry and enters mode 3, which uses http://con.quattroplay.com/conf.gs and its con2 retry. A plain socket failure follows the ordinary two-attempt progression.

This is not a claim that a network attacker can control the current service or that the fallback is reachable from every failure. A private two-port emulator control confirmed runtime reachability for an expired-certificate failure: the client made no HTTPS GET and then sent plain GET /conf.gs. The control returned the archived /con.png package at that path and did not reach a synthetic game listener, so current endpoint availability and path-specific response behavior remain unverified. The runtime record is artifacts/connector_fallback_runtime_control_20260902.json. A production repair should preserve peer and hostname verification, log the original TLS error, and reject the HTTPS-to-HTTP transition unless an operator explicitly accepts that policy for a controlled legacy endpoint.

The native libraries do have useful baseline mitigations. All four packaged libraries report a non-executable GNU_STACK, GNU RELRO, and BIND_NOW in the ELF metadata. Those properties do not compensate for unsafe input, stale trust material, or an overly broad update path.

The same offline ELF inventory found a loader compatibility lead. Every native ABI declares libstdc++.so in DT_NEEDED, but no libstdc++.so file is packaged in the APK. The ARM64 LOAD segments use 0x10000 alignment and the armeabi, x86, and x86_64 variants use 0x1000. If a device image does not provide a compatible legacy C++ runtime SONAME, the dynamic linker can reject the library before JNI startup. The successful x86_64 replay means this is not an established root cause. It should be checked with ARM64 logcat, alongside the native load result, before changing connector or TLS code. The machine readable record is APK-012 in artifacts/original_apk_security_audit_20260830.json.

The focused cross-ABI report at artifacts/cross_abi_compatibility_review_20260902.json confirms that all four variants share the same five native dependencies, baseline ELF hardening, connector and CyaSSL markers, and embedded connector trust text hash. This supports treating the expired trust material as a build-wide compatibility lead. It does not prove identical control flow, and the different segment alignment and code layouts still require an ARM64 loader test.

The separate init/fini export records the load-time constructor boundary. The ARM64 library has 20 fixed init callbacks and 10 fini callbacks. They set up or clear resource lists, cached texture dimensions, GUI defaults, client strings, and video state. The callbacks do not directly call the selected socket, resolver, file, or process boundary names. This narrows, but does not eliminate, the possibility of a constructor or allocation failure before QPlayMain. The detailed record is artifacts/original_native_init_review_20260830.json.

Evidence boundaries

The findings use these terms:

  • Confirmed static means the item was read from the APK, ELF metadata, DEX string table, or decompiled ARM64 code.
  • Local runtime means a behavior was reproduced with an emulator and a loopback responder. The runtime work in this repository reached a rendered world using diagnostic-only builds and did not contact a live game service.
  • Unproven reachability means the code or capability exists, but the review has not shown that an untrusted page, URI, package, or server response can supply its input.
  • Live would mean a current production service accepted the client. No live result is claimed here.

The report does not install a release APK, send an intent to another application, inject DEX, perform exhaustive native-parser fuzzing, contact a production endpoint, or attempt to bypass a signature on a live service. The bounded signed-package controls described below used only a private diagnostic APK and loopback responders.

Translation scope and residual names

The current ARM64 IDA copy contains 11,296 function heads. All 8,601 retained ELF symbols and 1,551 reviewed callback, role, and exact embedded-library aliases were checked at their expected addresses. The 124 residual functions without surviving source symbols now carry evidence-backed descriptive labels. A read-only active-IDB check found no default name or residual label in the Android bridge range, none among the 1,779 unique script callback addresses, and no direct call from a residual function into the selected socket, file, process, or update imports. The broader application-core range has 23 short static-state or cleanup wrappers, labeled without claiming recovered source names. See artifacts/ida_active_translation_scope_check_20260902.json.

The selected residual review inspected two of the remaining most referenced unnamed functions in the embedded FreeType region. They are diagnostic-string helpers reached through a FreeType internal object routine. A separate source comparison matched 141 FreeType and TrueType routines to the tagged 2.3.6 implementation, 153 routines to IJG libjpeg 6b, one to zlib 1.2.5, and one static GIF decoder role to giflib. Neither pass found a new Android, socket, file, process, or update boundary in the reviewed functions. These are semantic and source-matching passes, not proof that every static-library routine is safe. See docs/RESIDUAL_ANALYSIS.md and artifacts/original_residual_semantic_review_20260830.json plus artifacts/ida_freetype_source_matches_20260901.json, artifacts/ida_libjpeg_source_matches_20260902.json, artifacts/ida_zlib_source_matches_20260902.json, and artifacts/ida_giflib_source_matches_20260902.json.

Android package surface

The binary manifest reports:

Property Value Security meaning
Package com.quattroplay.GraalClassic Identifies the old application namespace
Version 6158, 1.8 The reviewed release
Minimum SDK 9 The package predates modern platform defaults
Target SDK 26 Old component and cleartext defaults matter on newer Android versions
Network permission android.permission.INTERNET Native connector and game sockets can reach remote hosts
Storage permission WRITE_EXTERNAL_STORAGE, capped at SDK 18 Legacy external-cache behavior
Custom schemes graalclassic://, graalclassicplus:// URI input reaches the launcher activity

The main activity has launcher and browsable intent filters but no explicit android:exported attribute. The audit records both the raw attribute and an exported_effective value inferred from the presence of intent filters. This is important when reading the manifest with modern tooling, which may present the omitted field differently from an old Android runtime.

The package does not declare android:usesCleartextTraffic=true in the binary manifest. Its absence is not the same as a modern explicit false. Since the target SDK is 26, platform behavior and the individual networking stack still need to be checked before treating the package as cleartext-safe. The native connector is separately reviewed as a CyaSSL client, so a manifest conclusion alone cannot explain its network behavior.

Startup gates before the connector

The Java and native lifecycle review found a compatibility boundary that is easy to misread as a network outage. GLThread waits for a live surface, window focus, a resumed activity, and a non-lost context before calling the renderer. QPlayRenderer then waits for Natives.downloaded, a field set by the permission-completion callback, before loading libqplay.so. Until both gates pass, QPlayMain, QPlayLoop, and the native connector have not run.

This is a confirmed static behavior, not a standalone remote vulnerability. It matters for triage because a compatibility warning or permission dialog can hold focus and leave the app on a static screen with no TLS attempt. The local Android replay loaded the ARM64 library after the compatibility warning was dismissed, so this boundary narrows the diagnosis without replacing the independent expired-certificate finding.

The same review found that the native pause bridge sets the close-application flag when the client is absent or still in an early loading state. QPlayLoop then calls exit(0). This is a medium-severity availability and observability issue for startup interruptions, not proof that a stock system dialog always causes process exit. The native initialization result is also ignored by the Java renderer, which can mark the library loaded after a partial initialization and make later failures harder to classify.

Custom-scheme launch input

The launcher activity is effectively exported under the old Android rules and accepts browsable graalclassic:// and graalclassicplus:// intents. The renderer forwards the full incoming data URI to native code, where it reaches onStartedWithURL and the script-visible serverstartconnect and serverstartparams fields. The native boundary shows no destination allowlist or canonical host validation.

There is a confirmed compatibility defect here: the Android schemes are not the same prefixes that TServerList_setProtocolString removes. A graalclassic://host/path URI therefore does not follow the same split as a graal://host/path URI. The result can be a deep-link startup failure, not a TLS failure.

The destination-control risk is conditional rather than proven. If the activated startup scripts use the event or fields to initiate an HTTP request or game connection, another application could supply an attacker-selected destination. The core script consumer and an external-app launch were not tested, so this is not claimed as a demonstrated SSRF, credential leak, or remote code execution path. A repair should use a strict allowlist or a validated server descriptor before any network action. The machine-readable review is artifacts/original_intent_launch_review_20260830.json.

Script-triggered external Android URLs

The native callbacks openurl and openurl2 reach the main window's openURL method. The focused native and DEX review is in artifacts/original_external_url_review_20260830.json, generated by tools/ida_export_original_external_url_review.py.

The original QPlayActivity.getIntentForURL recognizes a small set of exact legacy game strings and maps them to explicit ACTION_RUN intents. Every other string becomes an ACTION_VIEW intent built from Uri.parse. QPlayActivity.openURL asks PackageManager.queryIntentActivities whether a handler exists, then calls startActivity without a visible scheme or host allowlist or a confirmation step. The native canopenurl path performs the same mapping and exposes the Boolean handler result to the script caller.

This is a medium-severity, conditional capability finding. A script that has already crossed the connector package trust boundary can request external web or custom-scheme handling, which may open attacker-controlled content or trigger behavior in another installed application. It is not arbitrary native code execution, and no script or intent was run during this review.

opengraalurl is not identical to openurl. With an active player and game client, its native path sends a server-mediated setcookie message. When that state is absent, it falls back to the general external URL bridge. A repair should keep the server-mediated operation separate, allow only approved URL schemes and hosts for external navigation, and avoid exposing installed-app handler queries to untrusted scripts.

Facebook session and Graph bridge

The Android script table also exposes a Facebook session bridge. The focused native and DEX review is in artifacts/original_facebook_bridge_review_20260830.json, generated by tools/ida_export_original_facebook_bridge_review.py. The exporter records the relevant IDA functions and the private DEX observations without copying the smali dump or any session value.

getnewfacebooktoken calls JNI_getNewFacebookToken, which calls QPlayActivity.getNewFacebookToken(). When the active Facebook session is open, that method returns Session.getAccessToken() and the JNI wrapper copies it into a native string for the script callback. The matching getnewfacebookpermissions callback returns the open session's permission names as escaped comma text. These are direct credential and authorization boundaries, not status-only helpers.

The login and permission callbacks accept comma-separated permission names. logintonewfacebook builds an OpenRequest and calls openForRead, while requestnewfacebookrights selects requestNewReadPermissions or requestNewPublishPermissions from its Boolean argument. The activity checks the resulting permission list and reports the result back through onNewFaceBookRights. There is no local permission-name allowlist in the reviewed method, although Facebook and the user-facing prompt still mediate the final grant.

requestnewfacebookgraph and requestnewfacebookgraph2 require an open active session. The Java activity maps exact POST and DELETE strings to those methods and maps every other method string to GET. The bundled SDK defaults to https://graph.facebook.com, prepends Graph API version v2.1 unless the path already contains a version, adds the active access_token when the caller did not supply one, and uses https://graph-video.facebook.com for POST paths ending in /videos. The request response is returned to native script code as escaped comma text containing the requested path and, when available, the Graph JSON object.

The graph2 path adds a local-resource boundary. The native wrapper recognizes image: and file: values, resolves the suffix through the game-file resource loader, reads the resource, Base64-encodes it, and replaces the parameter. Java decodes image values through BitmapFactory and file values into byte arrays before creating the Facebook request. This is not proof of arbitrary filesystem access, but it does join game-readable data with an authenticated upload capability.

The strongest finding is high severity when an already activated script can reach the token getter: the script receives a bearer token whose authority is defined by the user's Facebook permissions. Authenticated Graph operations, resource uploads, and permission prompts are medium-severity conditional findings because they depend on an open session, user consent, Facebook policy, and the script package trust model. No Facebook login was performed, no token was captured, no script was executed, and no external request was sent during this review.

A repair should remove direct token and permission getters from general script scope, replace Graph access with narrowly named native operations, allowlist upload sources, and require explicit user intent for permission changes. The Facebook bridge is separate from the native Graal connector and its embedded CyaSSL trust bundle, so fixing one path does not repair the other.

Google Play billing bridge

The legacy billing review is in artifacts/original_billing_bridge_review_20260830.json, generated by tools/ida_export_original_billing_bridge_review.py. It joins the Android script table, the two billing JNI wrappers, the native result callbacks, and the private DEX smali for IabHelper, Security, and the activity listeners. No billing service was bound and no purchase flow was launched.

The Android table registers initgoogleplay and buygoogleplayitem. The first calls QPlayActivity.initGooglePlay(), which constructs the legacy IabHelper, enables debug logging, and begins an asynchronous bind to the explicit com.android.vending in-app billing v3 service. Its Java method returns true after the setup call, and also returns true when its surrounding try block catches an exception. The later onGooglePlayInitialized event is the meaningful readiness signal. On a device without the old Play billing service, a script can therefore see an optimistic true value while the later event reports failure or never arrives.

buygoogleplayitem passes two script-controlled strings to launchPurchaseFlow: the product identifier and developer payload. The reviewed activity method has no product allowlist. This lets an activated script request any product text it chooses and trigger a store UI. It does not provide a free-purchase path because the billing service still decides whether the product exists, whether the account is authorized, and which signed result is returned. The immediate true value only means that the asynchronous launch was accepted without a synchronous exception.

The helper does perform a meaningful signature check. It parses the purchase JSON, verifies it with the embedded RSA public key through SHA1withRSA, and uses the verifier for both purchase results and inventory entries. The security boundary is incomplete, however. On verification failure, IabHelper still passes its parsed Purchase object to the activity listener with a failure result. QPlayActivity$21 maps that result to failed, then onGooglePlayPurchase forwards the status, SKU, original JSON, and signature to Java_com_quattroplay_GraalClassic_Natives_onGooglePlayPurchase. That native callback emits all four values as the script event onGooglePlayPurchase.

This is not evidence that the stock game grants an item after failed verification. It is a data-minimization and trust-boundary problem: scripts receive signed transaction material even when the client has rejected its authenticity, and a script that checks only the SKU or JSON could make a client-side entitlement mistake. The helper also uses legacy SHA-1 signing and the platform default charset for the signed text. Automatic consumption is selected by broad SKU substring checks for the names used by the original game, so a future product identifier containing one of those strings could be consumed unexpectedly.

The safe repair direction is to keep the product catalog in native code, treat only the asynchronous verified result as authoritative, pass scripts a small status and product identifier rather than raw purchase proof, and replace the legacy billing service when the target platform allows it. The public report intentionally contains no public-key literal, purchase JSON, order ID, token, or signature.

Legacy partner bridges

The partner review is in artifacts/original_partner_bridge_review_20260830.json, generated by tools/ida_export_original_partner_bridge_review.py. It covers the 22 TapJoy, Distimo, Fabzat, and TrialPay entries in the 44-function Android script table, as well as the native Java wrapper bodies and the private DEX stubs. No partner service was bound and no offerwall or store UI was opened.

The support probes are not dynamic capability checks. Natives.isTapJoyEnabled, isTrialpaySupported, and isFabzatSupported return false unconditionally. connectToTapJoyService also returns false, and the remaining partner methods in Natives return without doing work. On the native side, the TapJoy application-ID and secret setters assign their incoming strings to two global TString values. The connection wrapper reads those values, but its Java callee is the false-returning stub. The five Fabzat display and texture setters at 0x2401e0 through 0x2401f0 are four-byte native nullsubs.

The same conclusion applies to the old Distimo, TrialPay, Amazon, and Mobiroo names found in the DEX. Distimo, TrialPay, and Fabzat operations return immediately through the active Natives class. The active Android script table has no Amazon or Mobiroo entries, and the Amazon and Mobiroo helper methods in Natives also return immediately. Bundled OnePF/OpenIAB and vendor billing classes therefore should not be mistaken for an active alternate store path.

This materially narrows the repair scope. The expired Fabzat certificate resource already noted in the APK inventory is legacy material, not evidence that the current connector or a live Fabzat client consumes it. The only security concern in this pass is that script-controlled TapJoy configuration strings are retained without a visible input bound. They are not shown leaving the process in the stock package. A repair should remove dead provider entries or expose their unavailable state clearly, cap configuration text, and subject any reactivation to a new privacy and trust review.

Android device, display, input, and media bridges

The focused review is in artifacts/original_android_device_media_review_20260830.json, generated by tools/ida_export_original_android_device_media_review.py. It covers the Android script callbacks, the native video and keyboard wrappers, and the corresponding DEX methods. No script was executed, no media was opened, and no device-collected value was published.

The getandroiddevicemodel and getandroidosversion callbacks expose Build.MANUFACTURER, Build.MODEL, and Build.VERSION.RELEASE to activated scripts. The native copies accept at most 1,024 bytes, so this is not an unbounded native copy in the reviewed body. The values are still useful for environment fingerprinting, especially alongside the separate network-ID, Android-ID, URL-handler, HTTP, and Facebook surfaces. These two callbacks do not themselves send the values anywhere.

The androidgetdisplayattributes callback returns a comma-separated sequence of densityDpi, xdpi, ydpi, and scaledDensity, captured when QPlayMain starts. This is ordinary layout information, but it can distinguish device classes when scripts are allowed to collect it. The video rectangle callback stores four caller-supplied integers and attempts to call a Java setVideoPlayerRectangle(IIII)V method that is not declared in the reviewed Natives class. QPlayLoop draws the cached rectangle only when the native video helper reports an open player and the dimensions are positive.

The Java activity does not establish that state: openVideoPlayer and stopVideoPlayer are no-ops. The native update callback is also absent from the reviewed DEX declarations. This makes the video path a compatibility defect and an area of stale state, not a demonstrated network or code-execution issue.

The keyboard close path is different. It is guarded by a registered native text control, snapshots visible text, conditionally posts a final onTextEntered event to the GL thread, and hides the IME and EditText on the UI thread. The final event requires both the caller's Boolean and Natives.loaded. A repair should minimize the environment data exposed to scripts, treat it as telemetry only when explicitly needed, make final text delivery lifecycle-safe, and remove or clearly gate the incomplete video bridge. These findings do not explain the native connector's current-clock TLS failure.

Remaining Android JNI callbacks

The remaining native callback export is in artifacts/original_android_callback_review_20260830.json, generated by tools/ida_export_original_android_callback_review.py. The DEX-side method and callsite inventory is in artifacts/original_dex_native_surface_review_20260830.json, generated by tools/audit_dex_native_surface.py.

The original com.quattroplay.GraalClassic.Natives class contains 18 public static native methods. Fourteen have 21 direct invoke-* callsites in the DEX. The four methods with no direct DEX caller are onAddScriptFunction, onRegisterEvent, onVideoFinished, and onVideoLoaded. They remain valid native exports, but their active reachability is not established by direct bytecode calls. Reflection, native-to-Java callbacks, and method references are outside this small scan.

The native render and lifecycle callbacks clarify a startup failure mode. The render method is the native heartbeat: it throttles work, consumes queued touch events, runs timers, and draws either the loading screen or the game. onAppPause sets closeapplication when there is no client or when loadingstate is at most two. The next QPlayLoop observes that flag and calls exit(0). A compatibility dialog, permission transition, or early activity pause can therefore stop the client before connector work has advanced. This is a compatibility and observability issue, not evidence that the server rejected a TLS handshake.

onInvokeEvent is a general Java-to-script dispatch boundary. It converts a Java event name and a comma-delimited argument value into a native event. The special onDeviceBackButton case sets the close flag when the active script universe has no catcher for that event. The other lifecycle callbacks forward background and foreground events to the script universe. These wrappers do not directly open sockets or read credentials, but a repair should keep their callers internal and preserve an explicit event allowlist.

onRegisterEvent lowercases the Java name, strips a leading on, and adds it to the global event-name table. onAddScriptFunction creates an Fs script property backed by the fixed native callback JNI_onScriptFunctionCall, using the Java-supplied function name. No length cap is visible in these wrappers, although neither method has a direct DEX caller in this APK. They should stay behind the signed script and native registration boundary rather than being treated as general application IPC.

The touch callback limits the pointer index to zero through four and records coordinates and click time in fixed native arrays. The key callback requires a native window and enabled hardware-keyboard state before converting Unicode input and dispatching it. Text, purchase, legacy-provider, and video callbacks marshal Java values into script events; their provider and video reachability is already narrowed by the billing, partner, and device/media reviews. No new native copy overflow was established in this callback pass.

WebView and Java bridge

The DEX string inventory finds WebView, setJavaScriptEnabled, addJavascriptInterface, JavascriptInterface, getSharedPreferences, and android_id. A local baksmali review separates these strings into two paths. The compact evidence is in artifacts/original_dex_webview_review_20260830.json.

com.quattroplay.GraalClassic.QPlayActivity$27 creates the game WebView with the application context, enables JavaScript, turns on the legacy WebView plugin state, installs InsideWebViewClient and WebChromeClient, and stores the view in the activity's map. QPlayActivity.setWebViewURL later calls loadUrl with a native-supplied URL on the UI thread. In InsideWebViewClient.shouldOverrideUrlLoading, the reviewed code calls WebView.loadUrl for the supplied URL and returns true. No visible URL allowlist check appears in that method. This is a real navigation surface, but the report does not claim that an untrusted caller can choose the URL.

The addJavascriptInterface call belongs to the bundled bolts.WebViewAppLinkResolver, not to the game activity's WebView setup. That resolver disables network access, installs a bridge named boltsWebViewAppLinkResolverResult, and injects JavaScript that reads al: metadata. Its annotated setValue method parses a JSON array and reports a result or error. This is narrower than a general game message bridge, although it still deserves review when the resolver is reachable.

The DEX also contains onReceivedSslError, DISABLE_SSL_CHECK_FOR_TESTING, and fz_server_domain_ssl. Local smali review resolves these anchors to com.facebook.widget.WebDialog: the test flag is explicitly false, and DialogWebViewClient.onReceivedSslError calls SslErrorHandler.cancel() and dismisses the dialog. No Java SSL bypass was established. The native connector's CyaSSL path is separate and is documented below.

No DexClassLoader, load_dex, or repository-specific java_reflection indicator was found in the original DEX, so dynamic code loading was not established by this static pass.

The follow-up method-level DEX pass resolves the call instructions behind these string and smali findings. It found 10 WebView call instructions across seven callers, including the four application-owned paths: JavaScript is enabled in QPlayActivity$27, native HTML is loaded in QPlayActivity$31, a URL is loaded in QPlayActivity$32, and InsideWebViewClient.shouldOverrideUrlLoading reloads the supplied URL. The only resolved addJavascriptInterface caller is bolts.WebViewAppLinkResolver.2, so the game activity and the Bolts metadata bridge should not be conflated. The application-owned renderer also makes three intent reads while processing the incoming URI before calling Natives.QPlayMain.

The same parser found 18 declared native methods and 21 direct call instructions into 14 of them. This confirms the Java-to-native startup and lifecycle edges without treating the four uncited native exports as live callers. The complete DEX contains 1,889 class definitions and 13,426 defined methods; all 11,952 methods with code were parsed without a bounds error. The callsite counts include bundled Android support, Facebook, Bolts, and billing code, so they are an inventory of reachable code references, not a claim that an external page or URI can drive every path at runtime.

The safe maintenance direction is to remove the bridge from untrusted pages, use an explicit URL allowlist, validate every message as structured data, and avoid enabling JavaScript where it is not required. Those changes are for a new authorized build. They are not assumptions about the behavior of the original package.

Credentials, option storage, and script exposure

The focused native export is artifacts/original_credential_storage_review_20260830.json, generated by tools/ida_export_original_credential_storage_review.py. It follows the nickname, account-name history, password, and plugin-cookie boundaries without reading a runtime registry or recording a real credential.

The virtual registry path is built below the native base data folder as cache/registry. Nickname and account-name values pass through THashList_encodesimple and THashList_decodesimple. The reviewed transform is length-dependent byte arithmetic. It has no random salt, secret key, or integrity tag, so it is reversible obfuscation rather than credential protection. The registry is also a native flat file, not Android Keystore storage.

The client loads a nickname and up to five ordinary account names. Guest, guest-prefixed, and cookie account names are excluded from the normal history write. The reviewed password setter updates the process-held options object but does not itself call the registry writer. The getter decodes that value, and TClient_getGraalPassword exposes it to the embedded script runtime as getpassword. The $pref::graal::dontsavepasswords accessors only read or write a global preference byte in the reviewed native path; they do not erase the current in-memory value.

This makes the signed script-package boundary part of the credential boundary. A script that can reach getpassword can read the current password, while a local reader of the registry can recover remembered names. The static pass did not prove an untrusted package can pass the script signature check, and it did not inspect post-install filesystem permissions. A modern repair should use platform-backed secret storage, avoid exposing passwords to scripts, and make account-history retention explicit.

Embedded script capability boundary

The focused export is artifacts/original_script_capability_review_20260830.json, generated by tools/ida_export_original_script_capability_review.py. It maps selected callbacks from the static script tables to their native implementations. The pass covered file content, uploads, HTTP request factories, the client protocol bridge, and dynamic class loading. It did not execute a script or contact a server.

The file API is inconsistent. fileexists, filesize, and deletefile call TFileScripting_getScriptAccessFilename before using the generic helpers, and decompressfile has separate source and write-folder checks. The distinct adventure_getfilecontent callback at 0xfbda4 calls TStream_LoadFromFile with its supplied filename. The matching adventure_setfilecontent callback at 0xfbbc8 calls TStream_SaveToFile directly. The visible wrapper does not apply the same access-filename or allowed-folder helper. An empty overwrite stream also reaches the generic writer's delete behavior. This is a conditional local file read, write, and delete capability for an activated script, not proof of a remote exploit.

Uploads have the same split. uploadfile checks the one-shot allowed-upload list or the script path helper, while adventure_uploadfile at 0x157d20 calls TClient_uploadFile directly. The native queue rejects a reported file size above 20,000,000 bytes and sends accepted files in chunks, but that limit does not establish a local path root. The Android application sandbox still limits which files are readable.

The general script HTTP factories accept http:// and https:// forms and create requests from a supplied host, port, and path. The reviewed wrappers do not show a destination allowlist. requesturlasgamefile has additional connector and extension checks, but it is a separate narrower path. This is a device-side network egress and data-access capability that depends on script activation and Android networking; no external destination was contacted.

The client property table also exposes raw send, encryption setup, game connection, and password callbacks to the connector script object. Dynamic class loading has a 9,999-class count check, duplicate-request tracking, and a server-privilege comparison before a class stream is installed. These are useful controls, but the signed script-package verifier remains the main trust boundary. A repair should remove direct file callbacks, require canonical application-root paths, use an explicit HTTP destination policy, and keep credentials and protocol keys outside general script reach.

Native socket capability boundary

The complete direct-import inventory is in artifacts/original_aarch64_import_callsite_inventory_20260830.json, generated by tools/audit_aarch64_import_calls.py. It scans the original ARM64 ELF's .text section for direct BL calls and unconditional B tail transfers to PLT stubs, then names the containing functions from the checked-in inventory. It found 167 undefined symbols, 165 with direct transfers, 3,186 transfer sites, and 301 tail calls. The two imports without a direct transfer are the stdio global __sF and the C++ __cxa_pure_virtual fallback; the latter is referenced through virtual-table data rather than a normal callsite.

The import scan confirms the following native boundary:

  • TSocketProperties registers the script functions bind, connect, send, and sendudp. The bind callback is jump_TSocket_bind_int_bool at 0x205b94, and the UDP callback is jump_TSocket_sendUDP_TString_const_TString_const_int at 0x2052e4.
  • TSocketConnection_bindSocket_int_bool at 0x2068b4 creates and configures a socket, then calls bind at 0x206940 and listen at 0x2069f0. TSocketConnection_acceptSocket_void calls accept at 0x206e60.
  • The static cleanup helper TSocket_clearStaticStrings at 0xe0680 clears the allowed-port and allowed-outbound-socket string state. The script callbacks at 0x204678 and 0x204688 replace those values. The matcher accepts *, comma-separated entries, exact host names or native patterns, exact decimal ports, and slash-separated port ranges.
  • The range syntax has a confirmed implementation defect. The outbound matcher at 0x2079ac compares the range against its comma-separated entry index in W21, not the requested port in W26. The bind matcher at 0x2057a0 makes the same mistake with entry index W20. Exact port entries still use the requested port, so this defect is specific to ranges. A configured range can therefore deny the intended port or allow an unintended one when the entry index happens to fall inside the range. The full static record is artifacts/original_socket_policy_review_20260902.json.
  • The ordinary connector uses the separate nonblocking TCP path. The UDP branch reaches sendto at 0x2071f0 and recvfrom at 0x207730, with the receive path recording sender metadata. No live destination was identified or contacted.

This makes SOCKET-001 a medium-severity conditional capability finding. An activated script that can set permissive values and invoke the bind operation may be able to create a local listener. TSocketConnection_bindSocket_int_bool zeros the IPv4 address before writing the port, so the resulting TCP or UDP bind is a wildcard-address bind rather than a statically loopback-only bind. The static review does not show that the stock connector starts one, and no external connection was observed. SOCKET-002 is a lower-severity conditional UDP capability finding for the matching datagram send and receive path. The signed script package remains a key reachability gate for both. The precise range bug and its evidence are tracked separately as SOCKET-POLICY-003 in the new socket-policy artifact.

The scanner also corrected a completeness issue in the older libc review: TFiles_deleteFile_TString_const reaches unlink through two unconditional tail branches. A BL-only search would have reported that imported deletion function as unused. This does not change the existing conclusion that caller path policy, resource resolution, and symlink behavior determine the file deletion risk.

The scan is intentionally not a full call graph. It does not include conditional branches, BLR, function pointers, vtables, or data-table dispatch. It proves imported native capability and code ownership, not stock runtime reachability. A safe repair should keep listener binding disabled for untrusted scripts, require an explicit loopback or approved-address policy, bound ports, and a separate outbound destination policy.

Native HTTP redirects and destination changes

The focused review is in artifacts/original_http_redirect_review_20260830.json, generated by tools/ida_export_original_http_redirect_review.py. It is a static ARM64 review. No redirect was sent to a live service and no remote exploit was demonstrated.

THTTPRequest_runScript treats status 300 through 303, 305, and 307 as redirect-like responses. It takes the first token from the stored Location value, parses an absolute http:// or https:// URL, and replaces the request's host, port, path, and SSL flag before sending again. The counter limits this behavior to ten attempts, but that is only a loop bound. The reviewed parser has no host allowlist, port policy, canonical destination comparison, or redirect-specific trust decision.

This creates two medium-severity static findings. A trusted response can move a request to a different endpoint, and a cleartext response can be modified in transit to do the same. An HTTPS request can also be redirected to HTTP, which causes a cleartext follow-up and exposes later request metadata and response contents. Connector package RSA verification remains a separate gate, and the ordinary script HTTP API already accepts broad destinations, so the redirect finding should not be described as a standalone remote code execution path.

The safer design is to define redirect policy by request class. Connector and update traffic should either reject redirects or allow only an explicitly approved host and port, while HTTPS should be preserved unless a deliberate, validated downgrade policy exists. The policy must be applied before the next socket is opened, not inferred from the retry count.

HTTP framing and memory limits

The related static review is in artifacts/original_http_framing_review_20260830.json, generated by tools/ida_export_original_http_framing_review.py. The socket layer reads at most 8,192 bytes per call, but the request layer appends repeated reads to an ever-growing response stream. TStream_readLine scans until LF or the current end of the stream without a per-line limit, and the header parser has no header-count or aggregate-header budget.

The parser recognizes the legacy fields needed by this client, including Content-Length, but it does not inspect Transfer-Encoding or decode HTTP chunks. A chunked response can therefore fail compatibility or reach a body consumer with chunk markers intact. Positive Content-Length values are used as a completion boundary, while close-delimited responses use socket closure; the selected web-download size check does not replace a general response buffer policy and is applied after header parsing.

This produces a medium-severity availability and compatibility finding and a lower-severity framing-ambiguity finding. It is not a demonstrated crash or request-smuggling exploit. The local responder used bounded Content-Length or EOF cases, no malformed response was fuzzed, and no live endpoint was contacted. A repair should reject unsupported transfer codings, enforce header, line, body, and aggregate-memory limits, and reject conflicting or overflowed length values before appending or dispatching data.

The selected-transfer default is data_THTTPRequest_webdownloadsizelimit at 0x385ad4, initialized to 0x6400000, or 104857600 bytes (100 MiB). The request-pool default is data_THTTPRequest_maxconnections at 0x385ad8, initialized to 0xa, or ten concurrent HTTP requests. These values are useful operational limits, but the selected-transfer check still occurs after header parsing and does not cap every ordinary response or the accumulated stream. The values and their callsites are preserved in artifacts/original_socket_policy_review_20260902.json.

Downloaded image resources and native decoders

The focused export is artifacts/original_image_parser_review_20260830.json, generated by tools/ida_export_original_image_parser_review.py. It follows a packet-102 resource through the cache and resource loader into the extension-selected PNG, GIF, JPEG, BMP, and TGA readers. The local runtime trace confirms that a server-delivered pics1.png reaches this resource family. No image fuzzing was performed, and no live endpoint was contacted.

The shared bitmap allocator derives its size from width, height, and pixel format using 32-bit arithmetic before calling memalign. The PNG/MNG path copies IHDR dimensions into animation steps and accumulates IDAT data with realloc. The GIF path allocates source and destination buffers for each image descriptor and keeps decoded frames in the animation list. The reviewed paths do not show a small pixel, frame-count, compressed-byte, or cumulative decoded byte budget before those allocations. JPEG delegates malformed syntax to the bundled decoder, but its wrapper still reaches the same allocator without a separate application pixel cap.

The embedded decoder inventory is now more precise. The JPEG body matches IJG libjpeg 6b across 153 functions, including the marker readers and the jerror formatter. The GIF API symbols are retained in the ELF export, and the shared static LZW line decompressor at 0x2acb20 is now named giflib_DGifDecompressLine. One apparent function at 0x2ac400 was removed from the IDA function inventory because it occupied the floating-point literal pool between the preserved jpeg_fdct_float and jpeg_fdct_ifast symbols. These corrections improve attribution but do not remove the missing image resource budgets described above.

The GIF-specific comparison is recorded in artifacts/gif_decoder_security_review_20260902.json. The LZW helper has visible checks for a full pixel stack, 12-bit dictionary indices, bounded prefix walks, and dictionary growth beyond the RunningCode - 2 table limit. That makes the two 2018 giflib dictionary-index CVEs useful comparison points, not confirmed APK assignments. CVE-2018-11489 describes an unchecked CrntCode index, while CVE-2018-11490 describes an unchecked Private->RunningCode - 2 index. The exact giflib release in this APK remains unknown and no malformed-image fuzzing was done.

The high-level DGifSlurp function at 0x2ae6ec is a separate finding. At 0x2ae814 it multiplies width and height in 32-bit arithmetic, sign-extends that result for reallocarray at 0x2ae824, and uses another 32-bit width-times-row product at 0x2ae8b4. The path has no application pixel or cumulative decoded-byte budget. This is tracked as GIF-003, an availability and memory-pressure hardening item, rather than a demonstrated out-of-bounds write. The zero-height divide-by-zero described by CVE-2019-15133 was not assigned to this binary because that exact divider state was not observed.

The same DGifSlurp loop accumulates extension blocks before it reaches the next record. At 0x2ae77c, 0x2ae7a0, and 0x2ae914, each nonempty block is passed to GifAddExtensionBlock. The helper grows the extension array with reallocarray(count + 1, 24) at 0x2af03c, increments the count at 0x2af04c, and allocates the block payload at 0x2af074. The one-byte GIF sub-block length limits each payload to 255 bytes, but the reviewed path has no aggregate extension count or byte budget. This is GIF-004, an availability and memory-pressure risk from repeated accepted blocks, not a demonstrated memory-corruption primitive.

There is a parallel frame-count gap. DGifGetImageDesc at 0x2ad908 grows the SavedImages array with reallocarray(existing, ImageCount + 1, 56) at 0x2ada90, increments ImageCount at 0x2adafc, and the DGifSlurp record loop at 0x2ae72c continues until a trailer. No application frame-count limit is visible. Each accepted descriptor can later acquire a color map and decoded pixel buffer, so many small frames can consume metadata while larger frames compound the existing dimension and cumulative-byte risks. This is GIF-005, a static availability and memory-pressure concern rather than a demonstrated overwrite.

The direct bitmap wrapper adds a conditional arithmetic overflow candidate. At 0x150b88 and 0x150b90, TBitmap_readGIF_TStream calculates the source length as a 32-bit height-times-width product before malloc and DGifGetLine. At 0x150c9c and 0x150ca0, it calculates the destination size as 32-bit width-times-height-times-8, rounds it at 0x150cb0, and allocates it at 0x150cb8. The row loop uses the 32-bit row offset at 0x150d40, forms the destination pointer at 0x150d4c, and copies each row at 0x150d50. With width 16385 and height 32768, the source and copy length are 536903680 bytes, but the wrapped destination calculation requests only 32768 bytes. If the large source allocation succeeds and the destination allocator returns storage, the copy can run past the destination. This is GIF-006, a conditional static heap-buffer-overflow candidate. No malformed GIF was fuzzed and no runtime crash was observed.

The PNG/MNG path has a similarly concrete arithmetic candidate, recorded in artifacts/png_decoder_security_review_20260902.json and generated by tools/generate_png_decoder_security_review.py. After parsePicture accepts the IHDR dimensions, TImageAnimation_createFromStream_TStream at 0x11b4a8 through 0x11b4d4 calculates the decoded-output allocation with width, height, and pixel bits in 32-bit W registers before shifting and sign-extending the result for memalign. The parser calculates the filtered scanline allocation at 0x1203a0 through 0x1203d4 with the same 32-bit intermediate behavior before realloc.

TMNGAnimation_decode_TMNGAnimationStep repeats the filtered-size expression at 0x11b878 through 0x11b894 and compares the stored raw size with that wrapped value. The row loop advances the parsed height at 0x11c58c. For the 8-bit RGBA default path, the color-format dispatch at 0x11c900 reaches the generic row copy at 0x11d3d4 and memcpy at 0x11d410.

As a concrete static witness, width 65536, height 65537, bit depth 8, and color type 6 describe a legal RGBA IHDR. The mathematical decoded output needs 17180131328 bytes, but the output allocation expression wraps to 262144 bytes. The mathematical filtered scanline size is 17180196865 bytes, while the parser and decoder's wrapped value is 327681 bytes. A compressed stream that inflates to that wrapped value could therefore satisfy the visible raw-size check before the dimension-driven row copy runs. This is PNG-001, a conditional static heap-buffer-overflow candidate.

PNG-001 is an arithmetic and disassembly result, not a demonstrated crash. No malformed PNG was fuzzed, no ARM64 harness was run, and no live endpoint was contacted. The next validation should use a disposable, address-sanitized harness with hard process limits. The repair should use checked size_t arithmetic and enforce dimension, IDAT, decoded-byte, frame-count, and total texture budgets before allocation or decode.

The BMP and TGA follow-up is recorded in artifacts/bmp_tga_decoder_security_review_20260902.json, generated by tools/generate_bmp_tga_decoder_security_review.py. The BMP reader's 4 and 8-bit palette path reads biClrUsed * 4 bytes at 0x11612c into a fixed 1,024-byte stack array at the function's SP+0x100 frame offset. A value of 257 therefore requests 1,028 bytes. The later palette loop stops at the end of the array, but that does not constrain the earlier stream copy. This is BMP-001, a conditional stack-buffer-overflow candidate for a BI_RGB BMP with an oversized palette count and enough remaining stream data.

The same BMP reader passes the width and absolute height to the shared allocator at 0x115fd0. Format 2 maps to three destination bytes per pixel at 0x11409c, and the allocator stores the width-times-height product in a 32-bit field before memalign at 0x1141e4. The row path then calls TStream_read_void_int at 0x116070 with the destination row length. For width 1048576, height 1366, and a 24-bit source, the mathematical bitmap size is 4297064448 bytes, the wrapped allocation is 2097152 bytes, and the first row read requests 3145728 bytes. If those bytes are available in the stream, this is BMP-002, a conditional static heap-buffer-overflow candidate. The BMP image-size header is read but does not act as a safety limit in this path.

The TGA loader at 0x1515cc accepts 16-bit width and height fields, computes the pixel count and four-byte decoded size in W registers at 0x151810, and stores pixels through the resulting 32-bit index without checking the malloc result. A type-2, 32-bit TGA with width and height both 32768 has 1073741824 pixels and needs 4294967296 bytes, but the decoded allocation wraps to zero. The TGA wrapper at 0x152168 allocates the destination from the same product and copies rows at 0x1521bc. This is TGA-001, a conditional static memory-safety candidate. No malformed BMP or TGA was fuzzed, and no runtime crash was observed for these witnesses.

The JPEG wrapper has a matching shared-allocation boundary. The focused record is artifacts/jpeg_decoder_security_review_20260902.json, generated by tools/generate_jpeg_decoder_security_review.py. After jpeg_start_decompress, TBitmap_readJPEG_TStream passes the decoder's output width and height to the shared allocator at 0x1510c4. The RGB path uses format 2, which maps to three bytes per pixel, while the scanline length is calculated as output components times output width at 0x1510dc. The destination pointer is advanced by a 32-bit row offset at 0x151120 and 0x151134 before jpeg_read_scanlines writes the row.

The JPEG dimension fields are 16-bit. Width 65535, height 21846, and three RGB output components therefore remain within the format field range, but the mathematical bitmap needs 4295032830 bytes. The shared allocator stores 65534 bytes after its 32-bit wrap, while the first scanline needs 196605 bytes. If the decoder accepts the dimensions and produces a row, this is JPEG-001, a conditional static heap-buffer-overflow candidate. The wrapper has no separate decoded-pixel budget. No malformed JPEG was fuzzed, and the decoder's own internal memory behavior for this witness has not been reproduced in a runtime harness.

The shared compression buffer has a separate arithmetic and policy boundary. The focused record is artifacts/compression_buffer_security_review_20260902.json, generated by tools/generate_compression_buffer_security_review.py. TCompression_getCompressionBuffer_int at 0xe4dd4 stores its rounded capacity in a 32-bit global and doubles it with a signed loop condition. A positive request of 0x40000001 reaches 0x80000000, then zero, and remains in the loop. This is COMP-001, a conditional availability stall. The compression wrappers request input_length + 1024 before entering the helper, but the ordinary NewGraal frame length is only three bytes. The review does not claim that one normal incoming frame can reach the witness.

The same helper has no independent upper bound. Automatic zlib and bzip2 decompression starts at 64 KiB or the existing shared capacity, whichever is larger, and retries only through a 4 MiB next-capacity test. A compressor input of 4194305 bytes requests 4195329 bytes and can raise the shared capacity to 8 MiB because the helper rounds to a power of two. A later automatic decompression then starts at that 8 MiB high-water value. This is COMP-002, a static memory-policy inconsistency, not a direct overwrite finding. The shared state is written before realloc, and no clear or shrink routine was identified in the reviewed data references. Allocation failure was not reproduced.

These are static findings, not a demonstrated remote crash. GIF-006 needs priority validation in a disposable harness because its arithmetic can make the copy size exceed the allocated destination. A peer would first need to cross the connector, game protocol, cache, and resource gates. A safe repair should use checked multiplication and reject dimensions, decoded pixels, compressed chunks, animation frames, and cumulative texture bytes before allocation. A bounded malformed-image corpus should then be run only against a disposable local harness.

Bundled dependency provenance

The native library also carries its own old compression and font implementations. The focused export is artifacts/original_dependency_provenance_20260830.json, generated by tools/ida_export_original_dependency_provenance.py. The longer explanation and version table are in docs/DEPENDENCY_PROVENANCE.md.

zlibVersion at 0x289b50 returns 1.2.5, and BZ2_bzlibVersion at 0x2751ac returns 1.0.4, 20-Dec-2006. The FreeType initializer at 0x253f1c writes version fields 2, 3, and 6. These are code-level version observations. The embedded JPEG body matches IJG libjpeg 6b, released 27-Mar-1998, across 153 source-role entries. The GIF analysis establishes the giflib API and static decompressor role, but not a unique giflib release.

The code is statically embedded. The ARM64 DT_NEEDED list contains no libz.so, libbz2.so, or libfreetype.so, so updating the Android host libraries would not update these paths. The legacy and NewGraal protocol parsers select zlib or bzip2 decompression based on incoming frame fields. PNG, MNG, and ZIP resource paths also reach bundled zlib. The font loader uses FreeType for system paths and for resource-backed memory streams, with a conditional file-download path when a relative font is missing locally.

This creates three conservative review items. The old zlib, bzip2, IJG, and giflib code is dependency exposure, not proof of a particular exploitable CVE because vendor backports and exact source revisions were not diffed. The GIF comparison narrows two historical dictionary-index concerns but does not replace fuzzing. GIF-003 remains an independent dimension-budget gap, and GIF-004 remains an extension-accumulation budget gap. Resource-backed font parsing reaches FreeType 2.3.6 without a separately established font-byte budget. The automatic decompression output buffer starts at 64 KiB and grows through 4 MiB, but that per-call behavior does not cap compressed input, shared stream accumulation, or total native resource memory.

The upstream zlib 1.2.x ChangeLog records later inflate validation fixes, which gives the source-diff pass a useful baseline. The bzip2 historical source archive provides the matching old release for comparison. No decoder fuzzing or live server test was performed in this pass.

Native connector and TLS

The original ARM64 library imports socket, resolver, send, receive, and file operations directly. It contains a bundled CyaSSL implementation and a fixed trust buffer. The native path is visible in the translated symbols:

  • TSocketConnection_connectSocket at 0x206bd8 creates the TCP connection and resolves the host.
  • TSocketConnection_enableSSLOnSocket at 0x206450 selects a CyaSSL client method, loads the configured verify buffer, enables peer verification when a buffer is present, checks the configured hostname, sets nonblocking mode, and calls CyaSSL_connect.
  • TSocketConnection_read at 0x2074d4 reads through CyaSSL when a TLS object exists and closes the socket on terminal read errors.

The embedded trust text is 12,820 bytes and has SHA-256 c87ea7bc32005cca699fb724ab455926fd852a1bd40ce0985aadf31a994878a0. The certificate-date parser calls the current UTC clock. The earliest connector certificate recovered from the bundle expired on 2023-07-29. This is a strong compatibility explanation for a current-clock device, but it is not a reason to disable verification globally.

The binary also contains identifiers for TLS 1.1, TLS 1.2, RC4, and NULL cipher suites. Those names show that the bundled implementation knows about legacy options. They do not prove that a weak suite was selected or negotiated. The local loopback tests kept peer and hostname checks in the native path; the private diagnostic changes were used only to separate stale trust and parser problems from the rest of the game protocol.

The APK contains res/raw/fabzat_com.crt, whose subject is admin.fabzat.com and whose recorded validity ended in 2014. Its presence is legacy material, not proof that the startup connector consumes it. It should not be treated as a current trust anchor.

Signed connector script package

The connector package has a second trust boundary after HTTPS. The compact review is in artifacts/original_script_package_review_20260830.json, generated by tools/ida_export_original_script_package_review.py. The native TScriptUniverse_compileZippedScripts path verifies the outer package with an embedded RSA public key before RC4 decryption and ZIP dispatch. The public artifact redacts long crypto literals and records only their lengths and hashes.

The parser caps the ZIP global entry count at 10,000 and skips entries whose reported uncompressed size exceeds 1 GiB. It has no visible aggregate decompressed-size budget, and one unzReadCurrentFile result is not compared with the requested entry size. Recognized entries become connector, NPC, or class script objects. The reviewed dispatch does not directly write arbitrary ZIP entry names to disk, so ZIP path traversal is not established here. Large or numerous signed entries remain an availability concern.

The focused ZIP follow-up is in artifacts/zip_resource_security_review_20260902.json, generated by tools/generate_zip_resource_security_review.py. It confirms that the same per-entry limits are used by the ordinary ZIP-backed resource scanner at 0xe8bac, while TResourceObject_getStream_void sizes a stream from the archive metadata before calling unzReadCurrentFile. The script-facing decompressfile callback at 0xfca80 can select * and write every loaded child resource, so the entry and individual-size checks are not an aggregate decoded-byte or disk-output budget. This is ZIP-001, a conditional availability and resource-policy gap.

The stream loader only checks the sign bit of the unzReadCurrentFile return value. It does not compare a non-negative short count with the declared size. Because TStream_setSize_int clears newly extended storage, a truncated member that returns short without a negative status can leave a zero-padded stream at the declared logical length. This is ZIP-002, a format-integrity and parser robustness gap. In unzOpenCurrentFile3 at 0x24b6fc, an error from inflateInit2_ returns before the temporary state and 16 KiB work buffer are attached to the parent handle, leaving them outside the normal cleanup path. This is ZIP-003, a conditional resource leak. Neither item was reproduced with a malformed archive or allocation-failure harness.

After a successful compile, TScriptUniverse_addZippedScripts finds StartScript_Connector, exposes selected socket and TLS metadata as script variables, and starts the script runtime. A mismatched or stale con.png can therefore fail at package verification even when the HTTPS transport is otherwise repaired. This package-signing check is separate from the expired connector certificate and should remain enabled in any compatibility build.

The mode-3 role has now been tested separately. The supplied Moreno.kahn workbench assigns StartScript_Fail to the conf.gs role and StartScript_Connector to the con.png role. A private package containing only NPCS/StartScript_Fail passed the native signature check, unpacked, and ran its onCreated event in the emulator. This means the earlier absence of a game connection after serving a con.png body at /conf.gs was not enough to identify a parser or VM failure. The current service's role-specific package and signing key remain unverified. See artifacts/connector_mode3_fail_script_runtime_control_20260902.json.

Signed-package malformed-input controls

The targeted runtime record is artifacts/connector_package_negative_controls_20260902.json. It exercised the actual native package path with a valid failure-script package plus five small mutations. The diagnostic public key matched the test signer, so the malformed ZIP cases reached the native ZIP parser without weakening the outer signature gate.

The native path rejected a flipped RSA signature, a missing 22-byte ZIP end record, an out-of-range .rk local-header offset, and a script entry whose declared uncompressed size exceeded the one-gibibyte per-entry limit. None of those cases produced a filtered logcat crash indicator. A package shortened by only its final ZIP byte still reached StartScript_Fail.onCreated and emitted MODE3_FAIL_SCRIPT_REACHED. The result is a format-integrity and parser robustness concern, not a demonstrated memory-safety exploit. The next repair should validate the complete end record and compare every non-negative member read with the declared size before activating script objects.

Game-server TLS configuration

The game connection has its own TGraalConnection SSL fields. The script callback TClient_setSSLParameters_scriptCallback at 0x1eb964 accepts an enable flag, protocol, cipher list, and encrypted certificate. It decrypts the certificate with the recovered NakFpz15 DES key and stores the values. The later TGraalConnection_connectToServer call copies those fields into the new socket before it connects.

The recovered source-level audit in artifacts/game_server_tls.json resolves the apparent contradiction in this code. The Classic branch sets usessl=false, the NewGraal setSSLParameters call is guarded by that flag, and the final value remains false. The certificate passed by the dormant path matches the first connector trust-bundle entry and expired on 2023-07-29, but this stale game-server material is not an active TLS blocker for the stock Classic branch. It remains relevant to other legacy modes or a modified script. The active connector HTTPS request still consumes the expired GraalWeb trust bundle.

Game login and framing review

The NewGraal login boundary is separate from both connector HTTPS and the signed connector script package. The compact IDA export is artifacts/original_game_login_review_20260830.json, generated by tools/ida_export_original_game_login_review.py. Its decompiler text is redacted for fixed key-like literals, and the export does not contain an account credential or private key.

The native setup parser recognizes the EILLLT six-byte header. The three length bytes form a 24-bit frame length, and the final header byte identifies the packet. The first server sequence is accepted without a prior value, and later values must advance modulo the configured sequence width. A mismatch sets the connection error flag. This is useful ordering state, but it is not cryptographic authentication.

The type-0xfc path optionally DES-decrypts its input, then passes it to a helper that decodes a client-held RSA private key and performs private-key decryption. The resulting mode and key fields select RC4, AES, or no cipher. The client resets each directional cipher state at this boundary and keeps RC4 state across frames. AES decrypts only complete 16-byte blocks until more input arrives. The packet-54 handler then decodes a one-byte server signature value, stores it as serversignature, and invokes onServerLogin.

This leads to two important security conclusions. First, the stock Classic branch does not enable TLS on the game socket, so the presence of an RSA unwrap and encrypted packet stream should not be mistaken for authenticated server identity. Second, a private key shipped to a client is not a durable server trust anchor. The native setup path does not call the separate RSA signature verifier, and its sequence check only protects order after setup. The static review identifies a design weakness and a repair requirement, not a demonstrated remote exploit.

The framed input path also has an availability concern. Socket reads arrive in 8192-byte chunks and are appended to the protocol buffer. The parser waits for the declared 24-bit frame length, but the reviewed bridge has no visible application cap below that field. A modern implementation should impose a small negotiated maximum and discard incomplete oversized frames. Any test of this condition should stay inside a disposable loopback harness.

Update and file operations

Package manifest and path policy

TUpdatePackage_load_void at 0x209fa4 loads a cached stream or a local package file as a line list, accepts the GRPKG001 header, clears the previous package lists, and parses records including NAME, FLAG, VERSION, PLATFORM, ACCOUNTS, MODE, SUBPACKAGE, FILE, DESCRIPTION, ISMAINEXECUTABLE, USECHECKSUM, and PROTECTOVERWRITE. SUBPACKAGE records are restricted to .gupd entries and can start another download during the load. FILE records are filtered by platform, folder policy, and filename policy before the resulting path is stored.

Package filenames are rooted below the user updatepackages directory. For a nonprivileged server, getPackageFullFilename_TString_const adds an escaped server-name component. The escape helper preserves a narrow filename alphabet and percent-encodes other bytes. The parser's protected-extension checks reject .exe, .lexe, .bat, and .com unless the package is privileged and also rejects a file identified as the configured base executable. The separate script policy has a broader blocked-extension table.

Resource lookup is not a raw string concatenation in the reviewed path. TResourceFunctions_getGameFile_TString_const_bool resolves a registered resource and can request a download when its boolean argument allows it. TResourceFunctions_getLevelFileResource_TString_const checks path form and registered alternatives before returning a resource. TFiles_fileExists uses stat and returns true only for regular files. The reviewed subset contains no realpath or no-follow open operation, so symlink and canonicalization semantics still need a disposable-directory test. This is a review boundary, not a claim that arbitrary file access was proven.

Focused package-path review

The narrower export confirms that SUBPACKAGE and FILE records have different path behavior. SUBPACKAGE values pass through TFiles_lowerCaseFilename before the .gupd check and package lookup. That helper keeps only the text after the last slash or backslash, so a literal ../../other.gupd does not reach getPackageFullFilename as a traversal path. This is basename normalization, not a general sanitizer.

FILE records retain a directory plus filename. The directory is checked by TFileScripting_AllowedFoldername with its wildcard flag disabled. The helper rejects ordinary ../ and ..\ spellings and permits only a narrow character set plus approved dotted directory forms. It does not perform canonical path containment, and it does not explicitly reject a slash-prefixed string. The reviewed package-aware cache mapper then prefixes the stored path with the native base user folder, so an accepted absolute-looking directory is not by itself proof of a write outside that root. The final path still lacks a visible realpath or no-follow operation, leaving symlink behavior as the important local test.

The manifest parser has no single visible limit for records, descriptions, or subpackage expansion. The cache path also has no general total-size budget. These are availability risks when package metadata or file responses are accepted, not proof that a remote peer can reach them in the stock trust configuration. The local-version file and ordinary cache writes are non-atomic, and their fwrite results are not checked. Package uninstall does call unlink, but it reduces each manifest entry to a basename and resolves a resource before deletion, so arbitrary file deletion is not established by this review.

The focused cache record is artifacts/cache_filename_policy_review_20260902.json, generated by tools/generate_cache_filename_policy_review.py. It confirms that URL-derived names are escaped into webfiles, while ordinary cache paths use extension routing, string prefixes, stat, and fopen without a visible canonical-root or no-follow operation. More importantly, TStream_SaveToFile_TString_const_uint ignores the fwrite count and TCachedStream_saveAndUpdate_TCachedStream_TString_const refreshes the resource object afterward. A partial cache can therefore be mistaken for a complete one and cause a later parser or loading failure. This is a conditional local cache-integrity finding, not proof that a remote peer controls a short write.

URLCACHE.txt is also line-oriented and has no visible byte or entry limit or integrity tag. The loader consumes every line that splits into two fields and the saver serializes the full in-memory list. The path skips .code entries, which narrows its direct effect on level-code origin, but malformed local state can still add startup work or alter cached URL provenance.

Request checksums and response integrity

The original client computes CRC32 values for existing resources and places them in five-character fields in packet requests. USECHECKSUM changes which local value is advertised, and a local .gupd package is also CRC32-checked when its value is sent to the peer. These values support conditional download or update decisions. They are not signatures and do not authenticate the peer's later bytes.

The focused integrity export shows that packet-102 data is appended to the cached stream, then ordinary and large-file completion paths call the cache writer. No matching CRC, RSA signature, keyed MAC, declared-size comparison, or offset-order check is visible before the ordinary save path. This makes response integrity and resource exhaustion important trust-boundary gaps after the connection has been accepted. It does not prove an unauthenticated remote exploit in the stock client, because connector trust, game protocol state, and the package state flags still gate reachability.

The large-file completion path has a separate local availability lead. The focused record is artifacts/update_package_transfer_review_20260902.json, with the narrative in docs/UPDATE_PACKAGES.md. Static ARM64 analysis maps the sequence to packet 68, 84, 102, and 69. In a loopback-only x86_64 diagnostic replay, omitting packet 69 kept the process alive, while delivering it after a valid local package reached TScriptSpace::receiveEvent and faulted at a null address. A probe.bin filename produced the same trace, so this local result does not require a .gupd parser branch. It is not evidence that a production server can crash an unmodified client, and it has not been reproduced on ARM64.

An isolated mismatch control kept basepackage.gupd as the active transfer name but sent mismatch.bin in packet 69. The private outgoing capture decodes to the expected 68, 84, 102, 69 sequence and the client again entered TScriptSpace::receiveEvent. This run differed in an important way: it faulted with SEGV_ACCERR at a nonzero address and instruction offset +43, not at the null address seen in the matching-name controls. That makes the mismatch branch a stronger state-instability lead, while leaving the cause of the invalid callback unresolved.

The same handler review found two input-validation gaps that are independent of the reproduced null dereference. Packet 84 combines five input bytes into a 32-bit bigfilesize value without a visible character-range or overflow check. Packet 69 uses its filename equality test to clear the active large-file marker, but both match and mismatch paths converge on cached-file lookup before completion callbacks. These are static state and arithmetic leads. The review does not show a direct allocation overflow, arbitrary file path, or production reachability. The mismatch replay does not by itself establish memory corruption or exploitability because it was run only against the x86_64 diagnostic build and the fault address may reflect its private test state.

Executable replacement

TClient_handleUpdatePackageDownloaded at 0x1ec044 calls the package lookup, marks the package as downloaded, updates its local version, invokes the onUpdatePackageDownloaded and onPackagesDownloadComplete script events, and then checks the replacement flag. If all package downloads are complete and the flag is set, it calls TSetup_startExeReplacer_TString_const.

The reviewed replacement function at 0x196fe0 does the following:

  1. Copies the configured full executable path.
  2. Calls chmod(path, 0775).
  3. Prepares shutdown and forks.
  4. In the child, calls execvp(path, argv) with the path as argv[0] and no additional arguments.
  5. In the parent, sets the close-application flag.

The reviewed function does not itself verify a package signature. That does not prove that earlier update code fails to verify one. It means the signature and path provenance must be traced through the package parser and download completion code before enabling an update repair. A safe current implementation should verify an authorized package signature, constrain the executable path to the application-owned directory, avoid making it group or world executable, and use an atomic replacement strategy.

File deletion

TFiles_deleteFile_TString_const at 0xe6dfc is a thin wrapper around unlink. It does not perform a policy check itself. The caller determines whether the path is safe.

The reviewed package uninstall function at 0x20a9cc skips packages named optional, iterates the package file list, lowercases each name, resolves it through TResourceFunctions_getGameFile, and then calls the generic deletion helper. It saves the local package version after the loop. This is a capability to remove cached game files, not proof of arbitrary file deletion.

The script-facing path is more constrained. TFileScripting_initStaticVars at 0xfd054 initializes blocked extensions including .exe, .dll, .so, .sh, .bat, .cmd, .msi, .scr, and .sys. It also initializes allowed filename characters and approved directory prefixes such as levels/, translations/, offline/, tileobjects/, updatepackages/, profiles/, and maps/. TFileScripting_AllowedFoldername checks folder characters and approved segments. TFileScripting_script_deleteFile resolves the script access path, checks that it exists, calls the deletion helper, and updates the resource object.

These checks reduce the script API's reach, but they are not a substitute for a complete path-traversal review. The next native pass should test separator, percent-escape, symlink, case-folding, and archive-entry behavior entirely on loopback and inside a disposable application directory.

Device identifiers and stored state

The identification path is more privacy-sensitive than it first appears.

  • TIdentification_getMacAddressBuffer_void asks the kernel for eth0 using SIOCGIFHWADDR.
  • TIdentification_retrieveMACAddress_bool stores the six-byte result in the native identification state.
  • TIdentification_getNetworkID_void computes an MD5 digest over the stored MAC address when retrieval succeeds.
  • TIdentification_getSystemID_int selects among a fixed dc:id2 value, hard-disk ID, network ID, OS ID, and Android ID.
  • TIdentification_getCookieFilename_void chooses basedatafolder/cache/creationtime.dat when present, otherwise basedatafolder/files/creationtime.dat.
  • TIdentification_getCookie_void loads that file into a native string.

The MD5 output appears to be used as a stable identifier rather than as a password hash. Hashing does not make a hardware identifier anonymous when the input space is small or the same construction is reused across services. A modern replacement should use a documented, resettable, per-installation identifier and disclose its purpose.

ELF hardening inventory

The APK packages libqplay.so for ARM64, ARM, x86, and x86_64. The offline audit extracted each library into a temporary directory and ran readelf only on those temporary copies. All four report:

  • non-executable GNU_STACK program headers;
  • a GNU RELRO segment; and
  • BIND_NOW dynamic-linker metadata.

The ARM64 library has 6,674 dynamic symbol-table entries and retains the exported names used by the IDA translation pass. Its interesting imports include chmod, connect, execvp, fork, gethostbyname, ioctl, open, recv, recvfrom, send, sendto, socket, and unlink. Imports are capabilities. The call-site review is what establishes the specific behavior described above.

Focused libc call-site review

The direct import inventory is recorded in artifacts/original_libc_callsite_review_20260830.json and is generated by tools/ida_export_original_libc_callsite_review.py. It is deliberately smaller than a raw import dump. The exporter records every direct caller of selected ARM64 PLT entries, then adds manual notes where a buffer or path boundary is visible in the decompiler.

The former open item at libjpeg_jerror_format_message (0x292b34) is the bundled libjpeg format_message callback installed by jpeg_std_error. It selects one of 124 fixed message strings and calls sprintf into the 200-byte local buffer used by TBitmap_JPEG_outputMessage. The call has no destination-length argument, so it remains an unsafe source-level pattern.

The reachability trace narrows the APK-specific risk. The only three %s messages in the table describe temporary-file operations, but this build's jpeg_open_backing_store implementation raises the fixed no-backing-store error instead of creating a temporary file. The bitmap reader supplies a stream source, and the JPEG code writes numeric diagnostics to the error parameter union. No write of a caller-controlled string into msg_parm.s was found in the bundled JPEG path. Numeric formats in the fixed table remain within the 200-byte diagnostic buffer for their 32-bit arguments. This is therefore not classified as a reachable string-format overflow in this APK, although reusing the formatter with another backing-store implementation would reopen the hazard.

The other reviewed sites have clearer boundaries. YAJL integer and double formatters render into 32-byte locals with fixed numeric formats. The certificate-directory enumerator keeps its constructed path within a 256-byte local buffer, but its stat calls follow links. That helper is only reached through the exported CyaSSL certificate-directory API, not the stock connector path, so symlink behavior is a dormant-API concern rather than a demonstrated startup or connector issue. The TString_snprintf wrappers copy at most size minus one and terminate the result. The apparent strcpy around the fixed eth0 interface name is compiler-inlined storage, not an imported call. The random-seed routine uses fixed /dev/urandom or /dev/random paths, while the GIF loader receives a caller-supplied path whose safety depends on resource provenance.

This pass does not treat memcpy, memmove, memset, socket I/O, or an imported function name as a finding by itself. It also does not replace parser fuzzing or a disposable-directory test for path and symlink behavior.

Encrypted level parser

The focused level-parser export is recorded in artifacts/original_level_parser_review_20260830.json and generated by tools/ida_export_original_level_parser_review.py. It covers the coded-file reader, stream helpers, encrypted level loader, board decoder, tile-layer allocation, and line-oriented link, sign, NPC, chest, and baddie readers.

The strongest availability finding is in TEncryption_loadCodedFile_TString_const at 0xe62ec. The four-byte stored length is checked with signed 32-bit arithmetic after adding three, then eight bytes are subtracted and the result is passed to the dynamic string allocator. There is no lower-bound check and no allocation-failure check. A stored value of 0x80000000 reaches a wrapped 0x7ffffff8 decoded size, and the following size calculation can become an enormous allocation request before the level header is checked. This proves a malformed local cache can cause a denial of service. Remote reachability still depends on the complete download and cache path, which has not been claimed from this static finding alone.

The stream helper caps ordinary reads to the remaining in-memory bytes and returns a short count. The level loader often ignores that count for fixed headers and fields, so truncated input can be interpreted using stale stack bytes or inconsistent parser state. The loader also accepts only an additive eight-byte column checksum, not cryptographic authenticity. The small length fields used by normal encoded strings are bounded by the 256-byte temporary buffer, but the shared line reader has no per-line limit. Large lines therefore remain an allocation and CPU exhaustion surface even when a fixed destination does not overflow.

The ordinary board path has clearer bounds. The normal callsite passes a fixed 13-bit value, the decoder stops at 4096 cells, and each tile layer owns an 8192-byte board. The layer-count setter clamps the count to 1 through 100, although linked levels can multiply the total allocation and should receive a separate budget review. No board-buffer overwrite is visible in this normal call path.

The link reader has no record or line budget and allocates 0xA8 bytes for each accepted record. Signs are processed on the normal path until end of stream, allocate 0xF8 bytes per record, and pass text through key-code expansion. The replacement helper repeatedly rebuilds the string, so many #K(...) sequences can amplify CPU and allocation cost. NPC and chest object allocation is gated off by the boolean used by the stock encrypted-level loader, but their alternative branches also lack total record budgets. These are availability and parser-robustness findings, not proof of code execution. A disposable malformed-container harness is still needed to establish crash behavior and to trace whether an attacker-controlled server can supply each section.

The cache-flow review now closes much of that reachability gap. Wire packet 102 maps to handler index 24, which splits the response into a filename and data and calls TClient_processFileChunk. That function appends data to a dynamic cached stream and later calls TCachedStream_saveAndUpdate; a .code response can therefore become the input consumed by TServerLevel_LoadEncrypted. The large-file 68, 84, 102, 69 sequence reaches the same save path. The reviewed code records the declared offset and size but does not visibly enforce offset ordering, total accumulated length, or a general file-size maximum. This makes resource exhaustion and cache-state confusion plausible for a peer that has already crossed the socket trust boundary. It still does not prove a production remote exploit while the original trust and authentication checks remain intact.

The mapper sends recognized files to application directories and escapes final components, but save and existing-file checks use string prefixes and stat. No canonical-root or no-follow creation step is visible. Symlink replacement, case-folding, and path-separator behavior should be tested only in a disposable private directory before assigning a traversal severity.

Priorities for a safe repair

The practical order is:

  1. Keep the original APK and library hashes recorded. Use a disposable private diagnostic build for every patch.
  2. Replace the stale connector trust material only with a certificate chain obtained from an endpoint and service owner that the operator is authorized to test. Keep peer and hostname verification enabled.
  3. Reproduce the connector response with exact body framing on loopback before attributing a failure to TLS. IDA shows that the old parser lowercases header lines before matching them, and local tests accepted title-case names as well as lowercase names. Keep Content-Length or an intentional EOF boundary so the body cannot be mistaken for a transport failure.
  4. Trace package signature verification from package input to TClient_handleUpdatePackageDownloaded before testing executable replacement.
  5. Add image-parser limits before exercising malformed PNG, GIF, JPEG, BMP, or TGA data. Keep the corpus and harness local and disposable.
  6. Exercise deep links, WebView messages, script paths, archive names, and update entries only against loopback and disposable directories.
  7. Do not publish private keys, account credentials, production server responses, or full game assets. Hashes and structural metadata are enough for an auditable report.

Reproduction

From the repository root, with the original APK at its default private path:

python3 tools/audit_original_apk.py

The command writes the compact report to artifacts/original_apk_security_audit_20260830.json. It makes no network connection and does not install the package. The IDA call-site exporter is run inside the open original ARM64 IDA database and writes the second report after the IDA_SECURITY_REVIEW_OUT environment variable is set. Both reports carry the input hash so a later result can be compared without publishing the input.

There aren't any published security advisories