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.soSHA-256:9348dd87a571050e05a9c9b76d71d37aa697de1836be5b86ea9982eb00e5b9c8. - Package:
com.quattroplay.GraalClassic. - Version: code
6158, name1.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.
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:
- The APK has network permission and four native ABI variants. The native library opens sockets directly and contains its own TLS implementation.
- The main activity accepts
graalclassic://andgraalclassicplus://. The manifest does not write an explicitandroid:exportedvalue, but its intent filters make the activity effectively exported under the old Android component rules. Other applications can therefore request that activity. - 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
addJavascriptInterfacebridge. The audit establishes these capabilities and their boundaries, but not attacker reachability. - 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. - Device-identification code reads the
eth0hardware 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. - The DEX contains
onReceivedSslError,DISABLE_SSL_CHECK_FOR_TESTING, andfz_server_domain_sslindicators. 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. - The native
TSocketproperty table exposesbind,connect,send, andsendudp. Its connection helper contains localbind,listen, andacceptoperations 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. - The remaining Android JNI callbacks show that the render loop is also the
native heartbeat.
onAppPausecan set the close flag before a client exists or while loading is still in its early states, after whichQPlayLoopcallsexit(0). This is a compatibility and observability lead for silent startup failures, not evidence of a server-side TLS rejection.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
TSocketPropertiesregisters the script functionsbind,connect,send, andsendudp. The bind callback isjump_TSocket_bind_int_boolat0x205b94, and the UDP callback isjump_TSocket_sendUDP_TString_const_TString_const_intat0x2052e4.TSocketConnection_bindSocket_int_boolat0x2068b4creates and configures a socket, then callsbindat0x206940andlistenat0x2069f0.TSocketConnection_acceptSocket_voidcallsacceptat0x206e60.- The static cleanup helper
TSocket_clearStaticStringsat0xe0680clears the allowed-port and allowed-outbound-socket string state. The script callbacks at0x204678and0x204688replace 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
0x2079accompares the range against its comma-separated entry index inW21, not the requested port inW26. The bind matcher at0x2057a0makes the same mistake with entry indexW20. 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 isartifacts/original_socket_policy_review_20260902.json. - The ordinary connector uses the separate nonblocking TCP path. The UDP
branch reaches
sendtoat0x2071f0andrecvfromat0x207730, 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.
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.
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.
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.
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.
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_connectSocketat0x206bd8creates the TCP connection and resolves the host.TSocketConnection_enableSSLOnSocketat0x206450selects 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 callsCyaSSL_connect.TSocketConnection_readat0x2074d4reads 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.
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.
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.
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.
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.
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.
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.
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.
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:
- Copies the configured full executable path.
- Calls
chmod(path, 0775). - Prepares shutdown and forks.
- In the child, calls
execvp(path, argv)with the path asargv[0]and no additional arguments. - 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.
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.
The identification path is more privacy-sensitive than it first appears.
TIdentification_getMacAddressBuffer_voidasks the kernel foreth0usingSIOCGIFHWADDR.TIdentification_retrieveMACAddress_boolstores the six-byte result in the native identification state.TIdentification_getNetworkID_voidcomputes an MD5 digest over the stored MAC address when retrieval succeeds.TIdentification_getSystemID_intselects among a fixeddc:id2value, hard-disk ID, network ID, OS ID, and Android ID.TIdentification_getCookieFilename_voidchoosesbasedatafolder/cache/creationtime.datwhen present, otherwisebasedatafolder/files/creationtime.dat.TIdentification_getCookie_voidloads 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.
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_STACKprogram headers; - a GNU RELRO segment; and
BIND_NOWdynamic-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.
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.
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.
The practical order is:
- Keep the original APK and library hashes recorded. Use a disposable private diagnostic build for every patch.
- 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.
- 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-Lengthor an intentional EOF boundary so the body cannot be mistaken for a transport failure. - Trace package signature verification from package input to
TClient_handleUpdatePackageDownloadedbefore testing executable replacement. - Add image-parser limits before exercising malformed PNG, GIF, JPEG, BMP, or TGA data. Keep the corpus and harness local and disposable.
- Exercise deep links, WebView messages, script paths, archive names, and update entries only against loopback and disposable directories.
- Do not publish private keys, account credentials, production server responses, or full game assets. Hashes and structural metadata are enough for an auditable report.
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.