• security
    • ios
    • android
    • reverse-engineering
    • privacy
    • mobile-security

    Reverse engineering and decrypting the Temu app on iOS and Android

    Filip Luchianenco

    Founder at CanITrustThat

    37 min read

    Temu is a marketplace of cheap goods shipped straight from sellers, published as com.einnovation.temu by PDD Holdings. It has sat near the top of the free charts on the App Store and Google Play for two years.

    The app ships an anti-fraud fingerprinting SDK, a PayPal data collector, a custom networking stack, and a home-grown cipher applied to its telemetry on top of TLS.

    The setup

    This is analysis of an app running on phones I own. TLS was decrypted with a root certificate installed on those devices, to read their own traffic; Frida observed the app's behavior in memory.

    The Basics

    Bundle ID / packagecom.einnovation.temu
    Version analyzediOS 4.71.0 / Android 4.39.0
    SellerPDD Holdings
    FrameworkNative — a single 36.7 MB Mach-O on iOS; a split APK whose 10 Temu-authored native libraries load from an arm64 split on Android
    Fingerprinting SDKsForter, Braintree / PayPalDataCollector
    MethodFrida 17.16.4 + mitmproxy (iOS); Frida 17.x + Binary Ninja (Android)

    Finding 1: Temu Erases All Ten of Its Native Libraries From the File a Security Scanner Reads, Including the Library That Does the Erasing (Android)

    Every security tool that wants to know what native code an Android app is running reads one file: /proc/self/maps. Antivirus, corporate device management, and mobile-security research tooling all read it. On the Android build, Temu removes all ten of its own native libraries from that file after loading them. Direct memory enumeration with Frida sees 349 loaded modules; /proc/self/maps shows zero Temu .so files.

    The removal is done by libntool.so, which hooks the Android dynamic linker at two points and rewrites the linker's own bookkeeping so the loaded libraries never appear in the map:

    // Frida + Binary Ninja, libntool.so (unpacked from memory), Temu Android v4.39.0
    // Two inline hooks installed into linker64 during load
    linker64 + 0x52000   do_dlopen                              // intercepts every library load
    linker64 + 0x7e000   android_namespace_t::remove_soinfo     // strips the loaded library's entry from the linker's soinfo list
    

    /proc/self/maps is generated from that soinfo list. Stripping the entry removes the library from the file while it stays resident and running in memory.

    // Frida direct-memory module walk vs. read of /proc/self/maps, same session (Session 5)
    Modules visible to Frida (direct memory)      349
    Temu .so files visible in /proc/self/maps       0
    Total hidden Temu libraries                     10
    

    Here is the inventory of what is hidden, with the runtime sizes read out of memory:

    LibrarySize (bytes)HiddenPurpose
    libriver.so3,215,360YesCipher engine, request signing, device fingerprinting
    libtcplink.so471,040YesCustom DNS resolution, GSLB/GTM, network quality
    libntool.so131,072YesInline-hook engine (the library doing the hiding)
    libasape.so90,112YesAPM orchestrator, ART method replacement, input interception
    libtestore.so139,264YesEncrypted key-value storage
    libapm_caam.so106,496YesCrash/ANR monitoring, native thread dumps
    libmgc.so61,440YesART runtime access, logcat reading, /proc/self/maps saving
    libguard_handler.so40,960YesActivity guard, force-finish detection
    libc++_shared.so1,409,024YesC++ standard runtime
    base.odex1,458,176NoCompiled DEX bytecode — cannot be hidden without breaking the ART runtime

    First, libntool hides its own entry from that same list. Second, the linker hooks would normally trip Android's Control Flow Integrity checks, so libntool resolves __cfi_slowpath and __cfi_slowpath_diag from libdl.so and bypasses CFI to keep the hooks in place. libmgc's nativeSaveMpsToFile() writes /proc/self/maps to disk, consistent with a before-and-after comparison to confirm the libraries are absent.

    Individual libraries hide from maps all the time, usually for anti-piracy.

    The difference is scope: every native library the developer authored is removed, in a shopping app installed on hundreds of millions of phones, with a bypass of the OS integrity check to keep the hooks in place. Google Play's own vocabulary has a term for the pattern. Play Protect classifies Maskware as software that "uses evasion techniques to hide functionality," and the Mobile Unwanted Software policy requires an app to tell the user "about all of its principal and significant functions." Comprehensive, self-concealing removal of an entire native codebase from the standard inventory file sits in tension with both, and with the Deceptive Behavior policy besides. No store rule distinguishes hiding nine libraries from hiding ten; the tension is with the transparency the policy assumes.

    iOS exposes no /proc/self/maps, and Temu ships as a single 36.7 MB Mach-O with its cipher, networking, and fingerprinting code statically linked into that one binary. Frida enumerated 878 modules, Temu among them, fully visible.

    Finding 2: Temu Rewrites Its Own Java Methods at Runtime, Controlled by a Server Flag (Android)

    On Android, Temu can swap any Java method in the running app for native code after the app is on the phone, and which methods get swapped is gated by a server-side flag. The mechanism lives in three cooperating native libraries and hangs off a single Java bridge class, com.whaleco.apm.enhance.Java2C, which exposes 40 native methods.

    // Frida, RegisterNatives (JNI vtable index 215), Temu Android v4.39.0
    // libasape.so registers 40 native methods on com.whaleco.apm.enhance.Java2C at JNI_OnLoad
    // The two that matter for method replacement:
    artMethodInitByPlaceHolder(Method) -> boolean   // copies ART entry points onto a target method
    artMethodPlaceHolder()             -> void       // empty stub; its ArtMethod struct is the template
    

    The replacement itself is a structure copy. Every compiled Java method has an ArtMethod record inside the runtime, and one field in that record, entry_point_from_quick_compiled_code_, is the address the runtime jumps to when the method is called. artMethodInitByPlaceHolder reads that field from the empty placeholder stub and writes it onto the target method. After the copy, calling the Java method runs native code out of libasape.so instead. The three libraries split the work: libasape.so (90 KB) orchestrates and holds the Java2C bridge, libntool.so (131 KB) is the hook engine (the PHNew Java-proxy and IHNew inline-hook primitives), and libmgc.so (61 KB) supplies the ART runtime access via superDl_open, getArtPath, and symbol resolution.

    disableVerifier() calls art::Runtime::DisableVerifier(), which switches off the runtime's bytecode verifier, and it was confirmed called successfully at startup. removeVdexMem() scans /proc/self/maps for base.vdex, the verified DEX image Android keeps for integrity checks, and calls madvise() to release it.

    Which methods get reforwarded is chosen at runtime. artMethodInitByPlaceHolder replaces whatever java.lang.reflect.Method it is handed, and the surrounding hook activation is gated by a server-side A/B flag. One was observed in the wild: ab_apm_enable_ihnew_42400, which turns IHNew inline hooking on. A flag flipped on Temu's servers, not an app update, decides whether the machinery runs.

    Hot-fix and performance-monitoring frameworks like YAHFA and MethodHook use the same ArtMethod technique to patch crashes without shipping a full update, and everything observed here was performance and crash-fix work: the graphics-pipeline and Samsung-SQLite crash fixes, input-latency timing, GC tuning. It re-points already-shipped native code.

    The exact Java methods being replaced were not captured. The hooks install during JNI_OnLoad, before a Frida spawn-attach can get in, and Temu's anti-tamper crashes the process on pre-JNI_OnLoad instrumentation, so the target list is the one thing the teardown could not read directly.

    The capability stands in tension with Google Play policy. Play's Device and Network Abuse policy states that an app "may not modify, replace, or update itself using any method other than Google Play's update mechanism." Google reviews a specific static build. Server-flagged method replacement lets the app swap native code once it is on the phone, on instructions from Temu's servers. The observed use was APM and crash-fixing rather than the behavior the rule targets, and this is not the interpreted-code carve-out.

    On iOS there is no ART and no Java runtime to patch, so artMethodInitByPlaceHolder has nothing to point at and disableVerifier has no verifier to disable.

    Finding 3: The Telemetry Is Sealed With a Cipher Temu Built Itself, and There Is No Key to Extract (Android + iOS)

    Temu's telemetry rides normal TLS, and once the TLS was decrypted on a device the analysis controls, the bodies were still unreadable: opaque binary that begins \xec\x01. That is a second cipher, applied by the app before the data reaches the network.

    The cipher lives in libriver.so, the 3,215,360-byte crypto engine, in a single function:

    // Binary Ninja, libriver.so unpacked from memory (Temu Android v4.39.0) — sub_4cde58 @ libriver.so+0xcde58
    // the core white-box cipher; note the calling convention
    sub_4cde58(data_ptr, data_length, mode, output_ptr)
    

    The key is dispersed across 60+ immediate constants welded into the function body as ARM64 MOV/MOVK instruction pairs: the key schedule, round constants, and mixing parameters. A separate key-expansion routine (sub_4d3f44 at +0xd3f44, a 10-round expansion consistent with AES-128 structure) sits in the binary but is never called at runtime. The schedule is pre-computed and hardcoded.

    The cipher uses two custom 256-byte substitution tables, both compared byte-for-byte against the standard AES S-box:

    S-boxLocation (Binary Ninja)Positions matching standard AES
    S-box 1libriver.so+0x303d800.4% (1 of 256)
    S-box 2libriver.so+0x303e800.8% (2 of 256)

    Both are valid permutations, with every byte value from 0x00 to 0xFF appearing exactly once, but neither has any relationship to AES. A 0.4% overlap is what pure chance produces; the expected rate for two unrelated permutations is about 0.39%. And the standard AES fingerprints are entirely absent from all 3.2 MB of the library. The AES S-box, the T-tables (Te0Te3, Td0Td3), and the Rcon constants do not appear anywhere in it.

    Reconstructing the cipher means disassembling and re-implementing the entire function from those 60+ constants; there is no single key to lift out.

    On iOS the telemetry is custom-ciphered as well, statically linked into the single 36.7 MB Temu binary. The S-boxes and 60+ constants were disassembled and verified on Android; on iOS, Frida confirmed that Apple's CommonCrypto is never called for these payloads.

    // Frida, Temu iOS v4.71.0 — CCCrypt export hooked across two 45-second sessions
    // CCCrypt call count for \xec\x01 telemetry bodies: 0  → not Apple CommonCrypto
    // POST pftka-us.temu.com/clim/defined — application/octet-stream, body begins \xec\x01
    \xec\x01 ... \n y ª \x9f \x01 \x00 \x00  GJkqHfIz ...
    

    A 256-bit key does sit in the iOS Keychain under the name __SECRET_BOX_MAIN_KEY__. But that key does not decrypt any wire body under AES in any mode tested (CBC, CTR, ECB, GCM, CFB, OFB), nor under ChaCha20, nor under libsodium's secretbox. That key is for local storage. That the iOS cipher is the same white-box construction as Android's is a reasonable inference, not a byte-level match shown on iOS.

    White-box cryptography is a real commercial defensive technology, used to protect keys inside DRM systems and mobile-payment apps. No app-store rule bans custom crypto, and a cipher without an extractable key is, in isolation, a defensible security decision.

    This cipher seals ordinary telemetry rather than payment secrets or DRM licenses. No store rule prohibits it. But it stands in indirect tension with transparency duties that assume outsiders can verify what an app does with data: Apple's purpose-string and privacy-label accuracy requirements (Guideline 5.1.1(ii)), GDPR's access and information rights (Articles 13–15), and Google Play's Mobile Unwanted Software expectation that an app be clear about its functionality.

    The plaintext was recovered by hooking the app one step before it encrypts, at +[TMMetricsEncryptMgr encryptReq:] on iOS, and reading the gzipped JSON going in. Whether the same 60+ embedded constants are identical across devices, versions, and regions is unknown; no claim is made that one re-implementation decrypts everyone's traffic.

    Finding 4: The Encrypted Telemetry Reads as Device, Session, and Navigation Data, Recovered by Hooking the Cipher One Step Before the Wire (Android + iOS)

    The \xec\x01 bodies going to *.temu.com/clim/* are gzipped JSON: a per-batch inventory of the device, the app, the session, and every screen the app draws.

    On iOS the CLIM pipeline runs like this:

    // Frida, Temu iOS v4.71.0 — CLIM send path, reconstructed from the decrypted Mach-O image
    event dicts --NSJSONSerialization(Temu+0x16d2c70)--> JSON
       --> WHMetricsReq{ _body = gzip(JSON), Content-Encoding: gzip }
       --> +[TMMetricsEncryptMgr encryptReq:]        // custom-encrypts here -> \xec\x01 octet-stream
       --> WHHttpLink(sendReq:) --> pftka/thtka/matka *.temu.com  /clim/*
    

    The last readable point is +[TMMetricsEncryptMgr encryptReq:]. A hook on it read WHMetricsReq._body on the way in; the bytes started 1f 8b 08 00, a gzip stream, and gunzipped to the full payload. The wire body is custom_encrypt(gzip(JSON)), and the JSON is now in hand. Eight complete batches were captured this way.

    Each batch carries a fixed envelope and then a list of events:

    // Gunzipped WHMetricsReq._body captured at the encryptReq: input — one batch, abridged
    {
      "device": { "model": "iPhone10,3", "brand": "Apple", "platform": 1 },
      "os":     { "osVersion": "16.7.15" },
      "app":    { "version": 47100, "bizLine": "temu" },
      "user":   { "uin": "", "whid": "adJxn6XHJadG" },        // per-install metrics id == request ETag
      "genericTags": { "timezone": "America/Chicago", "region": "US",
                       "lang": "en", "channel": "testflight" },
      "crc32":  ...,
      "datas":  [ /* ~15 events, drawn from 50 distinct rawId types */ ]
    }
    

    Across the batches, the events fall into three groups. First, cold-start and splash timing: how long the app took to become interactive. Second, per-view class names, so the server knows which screens were drawn and in what order; one event tags the home feed's bottom item as custom_className: BGHomeSceneBottomItemModel. Third, install and backup state: custom_recover_from_backup, custom_is_new_install, custom_device_new_install, custom_last_version: 4.71.0, recording whether this install came from a device backup, whether it is fresh, and what version preceded it. A fourth layer sits under the network events (f_*): GSLB cache state, link reuse, server-group match, negotiated protocol h2, the internals of Temu's own WHHttpLink/WHGslb transport, which is the iOS counterpart to Android's libtcplink.

    The whid (adJxn6XHJadG) rides in every batch and doubles as the request ETag. Behind it sits the persistent Keychain device id __AMSEC_DID__ = 79524A86-166F-4B51-A5D4-DE3FBE748F2E, re-written with SecItemUpdate and surviving app reinstall.

    In one screening capture of 378 TLS-decrypted flows, the CLIM channel dominated the non-content traffic:

    EndpointRequests
    pftka-us.temu.com /clim/defined100
    pftka-us.temu.com /clim/{front_err,api,scene,static}12
    thtka-us.temu.com /c/rs40
    thtka-us.temu.com /c/th37

    The Android build does the same thing under the same name. There, CLIM (Client Information Manager) ships gzipped Protocol Buffers rather than gzipped JSON, but the routing signature is identical: POST /clim/{define,api,apm/j} to pftka-us.temu.com, with headers data-format: PB, Content-Encoding: gzip, ck-region: 211 (US), and an x-clim-info value that carries the app id and the same device id, whid.

    What came out is exhaustive device, session, performance, and navigation telemetry. It is not sensitive personal content. The neutral Swiss lab NTC, working the same Frida method independently, reached the same negative: no sensitive personal data in the encrypted traffic.

    Every large app ships application-performance and real-user monitoring data: crash traces, frame timings, screen names, cold-start latency. Firebase, Sentry, Datadog, and New Relic all collect close to this exact list. What is unusual is the container: a cipher no outside party can open on the wire, keyed to a persistent identifier.

    This stays a grey zone rather than a violation. No app-store rule bans custom cryptography, and the decrypted contents are performance data, not the sensitive categories that would trigger the harder rules. The tension is with transparency, not content: encrypting telemetry so it cannot be audited on the wire sits uneasily beside Apple's 5.1.1(ii) requirement that purpose strings "clearly and completely describe your use of the data" and against the accuracy expected of the App Store privacy label, and beside GDPR Articles 13–15, which oblige a controller to tell a person what is collected and why. Under the EDPB's reading of ePrivacy Article 5(3), the analytics and APM portion of this telemetry is not "strictly necessary" to deliver the shopping service a user requested, which would place it in consent-required territory; a fraud- or security-driven portion is arguably closer to strictly necessary and may not need consent. None of that is an adjudicated finding.

    Finding 5: A Device ID That Survives Factory Reset (Android) and App Reinstall (iOS)

    On Android, Temu reads an identifier that survives factory reset. iOS has no such identifier, so the app keeps a reinstall-persistent copy in the Keychain.

    Temu's native gmd function, one of the 25 JNI methods packed into libriver.so, is called twice at startup and reads the phone's Widevine DRM device ID. Both calls returned the same value.

    // Frida hook on gmd() in libriver.so, Temu Android v4.39.0 (Pixel 6a) — return value, URL-decoded
    // gmd = "get device metadata"; called ×2 at startup only
    0:69146f79804a0b7314c715decfaa37c641358ae2d69170c36f9b17f47258b68a|com.google.android.widevine|[email protected]|Widevine CDM;
    

    The 64-character hex string breaks down as:

    FieldValueWhat it is
    Device ID hash69146f79804a0b7314c715decfaa37c641358ae2d69170c36f9b17f47258b68aHardware-bound Widevine certificate hash. Survives factory reset.
    Plugin packagecom.google.android.widevineThe Widevine DRM module
    Version + build[email protected]Widevine version + Android build
    Plugin nameWidevine CDMDisplay name

    Widevine is the DRM that lets a phone play protected video. Its device ID is provisioned below the operating system, tied to the hardware. That is what "survives factory reset" means here: wiping the device and setting it up as new changes the advertising ID, the app data, the accounts, and almost everything else, yet leaves this hash the same. (A bootloader unlock can change it, which is not a step a normal user takes.)

    Temu's ts telemetry function carries the resettable Google Advertising ID (GAID) alongside android_id (c629bbe5b6d753bb), an install_token, the carrier, and the install time. The ng request-signing function bundles the same android_id with the full build fingerprint (google/bluejay/bluejay:16/BP2A.250605.031.A2/13578606:user/release-keys), the board (bluejay), and the brand (google) into a signed device fingerprint.

    Collecting a resettable advertising ID is ordinary, and reading Widevine for its actual purpose, DRM playback, is what the API is for. Google even ships the App Set ID for exactly this "recognise the install, respect the reset" need.

    Google's User Data policy restricts linking persistent hardware identifiers to resettable or sensitive IDs outside enterprise and telephony uses, and ts links gmd's hardware hash to the GAID and android_id in the same telemetry bundle. That is in tension with the policy, not a proven breach of it; the App Set ID exists precisely so an app does not have to reach for the hardware ID to solve this problem. Under ePrivacy Article 5(3) and GDPR Recital 30, a persistent device identifier used this way is personal data that ordinarily needs consent. The consent trigger bites hardest on the analytics and advertising use, while a fraud- or security-driven use is arguably closer to "strictly necessary" and may not need it. One honest limit: the Widevine hash is not guaranteed globally unique and can shift on a bootloader unlock, so it is a very strong signal, not a perfect serial number.

    iOS has no Widevine equivalent, so persistence runs through the Keychain, which iOS preserves across app reinstalls by design. Temu writes and re-writes a device ID there.

    // Frida — SecItemCopyMatching ×12 + SecItemUpdate, Temu iOS v4.71.0
    // __AMSEC_DID__ recovered from the Keychain; persists across app reinstall
    __AMSEC_DID__ = 79524A86-166F-4B51-A5D4-DE3FBE748F2E
    

    Reinstalling Temu reads __AMSEC_DID__ back the same value, though a full device wipe clears it. That reinstall-only persistence is a real gap from Android. The rest of the iOS fingerprint sits alongside it: sysctlbyname reads hw.machine (×13) and the other hardware descriptors, -[UIDevice identifierForVendor] returns the vendor ID 5AD03A34-F83A-491E-B3E4-517974D4155A, and kern.bootsessionuuid supplies a per-boot re-identification signal. No IDFA was read and no App Tracking Transparency prompt fired, consistent with ATT being denied and the advertising ID zeroed.

    Apple's fingerprinting rule bans deriving a persistent identifier from device signals "regardless of whether a user gives the app permission to track." A Keychain-backed device ID that outlives reinstall sits in the grey zone that shades toward tension with that rule. The same ePrivacy and GDPR persistent-identifier duties apply, with the same strictly-necessary caveat and the consent trigger biting hardest on the analytics use, and the closest-analog reading is that this is the iOS way to solve the problem Widevine solves on Android, one platform layer up.

    Finding 6: On Android, Temu Resolves Its Own Hostnames and Never Asks the System (on iOS It Asks 337 Times)

    On Android, an app is expected to ask the operating system for the IP address behind a hostname like us.temu.com through getaddrinfo, and the operating system answers according to whatever rules the device is under: a parental-controls filter, a corporate DNS policy, a national resolver. Temu's Android build imports getaddrinfo and then never calls it.

    The resolution lives in libtcplink.so, one of the ten libraries that do not appear in /proc/self/maps. Its GetHostByName export does the work:

    // Binary Ninja, libtcplink.so (unpacked from memory), Temu Android v4.39.0
    // libtcplink.so exports 19 JNI methods; the resolver replaces getaddrinfo
    GetHostByName   libtcplink.so+0x12b48   // custom DNS resolution
    PreResolve      libtcplink.so+0x13e48   // pre-resolve hostnames before use
    GetIpStack      libtcplink.so+0x12980   // determine IPv4/IPv6 capability
    

    Over 35 seconds of monitored use, the import table and the call trace show:

    FunctionPurposeCalled in 35s
    getaddrinfoAndroid system DNS resolverimported, never called
    inet_pton / inet_ntopconvert resolved IP stringsactive
    socket / connectopen TCP to a resolved IPactive

    The app converts and connects to IP addresses it resolved on its own. All resolved addresses were Microsoft Azure endpoints, Temu's cloud provider, reached through a Global Server Load Balancing and Global Traffic Management layer whose configuration Temu ships itself:

    // libtcplink Init (+0x13154) parameters, StDnsInitParams
    gslbSvrConf        // Global Server Load Balancing config
    gtmSvrConf         // Global Traffic Management config
    StDnsRequest.useH3 // DNS-over-HTTP/3 (QUIC) capability flag
    

    Content delivery networks and performance stacks do exactly this to steer a user toward the nearest, fastest server; shipping a GSLB/GTM layer is an ordinary infrastructure decision for a company serving hundreds of millions of requests.

    On iOS, the same architecture behaves differently. Temu's iOS build calls getaddrinfo 337 times, for the identical hostnames, gslb-us.temu.com and gtm-us.temu.com among them.

    // Frida, Temu iOS v4.71.0 — same GSLB/GTM hostnames, iOS system resolver
    getaddrinfo called 337×  // gslb-us.temu.com, gtm-us.temu.com, pftka/thtka/us/us-ds.temu.com, *.kwcdn.com
    

    iOS routes every one of these hostnames through the system resolver, so whatever DNS filtering a device is under still applies.

    Under policy, this sits in a grey zone leaning toward tension on an effect basis. No Google Play rule names DNS bypass, and custom resolvers are legitimate for CDN and performance work. But the outcome, defeating the DNS-based filtering and parental controls a device or network has in place, brushes Google Play's Device and Network Abuse policy, which prohibits apps that "interfere with... networks... [or] services."

    A correction: the connection to a Google DNS address is not a covert channel

    An early pass flagged repeated connections to 8.8.8.8:20480, Google's public DNS address on an unusual port, as a candidate proprietary or exfiltration channel. It is not.

    Hooking connect, send, sendto, write, recv, recvfrom, and read around those connections showed the connection succeed and then carry nothing:

    // Frida socket-I/O hooks, Temu Android v4.39.0 — 6 probes observed
    // connect() succeeds; zero data bytes sent before the fd is reused for the real server
    fd    time      8.8.8.8:20480   IPv6 probe   real destination
    445   15.152s   OK              FAIL         109.61.92.56:443
    557   15.160s   OK              n/a          (fd reused)
    554   15.379s   OK              FAIL         104.18.9.114:443
    562   15.422s   OK              FAIL         104.18.9.114:443
    265   31.038s   OK              FAIL         109.61.92.47:443
    520   35.230s   OK              FAIL         172.66.1.241:443
    

    No send, sendto, or write fired between the probe and the real connection. This is Chromium's Happy Eyeballs v2 (RFC 8305): before committing to IPv6 or IPv4, the stack probes each for reachability, then connects to the actual server on port 443. The probe to 8.8.8.8 uses a well-known, globally-routable address as a reachability target; port 20480 (0x5000) is chosen to avoid colliding with DNS on 53 or HTTPS on 443.

    Finding 7: Native Code That Sees Touch Input Before Android Does, and Reads the System Log While Hiding Its Own (Android)

    On Android, Temu installs itself below the app framework in two places: the input pipeline and the system log. Neither surfaced on iOS.

    The input hook

    At startup, Temu's APM orchestrator calls Java2C.proxyInputFunc(Object) and installs inline hooks, via IH::IHNew::hookSymName, on the functions that carry input events up from the kernel:

    // Frida, libasape.so via com.whaleco.apm.enhance.Java2C, Temu Android v4.39.0
    // hooks installed during JNI_OnLoad; targets confirmed by RegisterNatives capture
    libinput.so:
      android::InputConsumer::consume            (multiple overloads)
      android::InputConsumer::consumeSamples
      android::InputChannel::receiveMessage
      android::InputChannel::sendMessage
    libandroid_runtime.so:
      NativeInputEventReceiver::consumeEvents
      NativeInputEventReceiver::finishInputEvent
    

    InputConsumer::consume and consumeSamples are the choke points every input event passes through from the input driver to the Java view. Hooking them puts Temu's native code on that path, ahead of the framework.

    The observed purpose is input-latency measurement: timing how long an event takes to travel the pipeline, which is ordinary application-performance monitoring. The Swiss lab NTC, which examined the same behavior with the same method, saw the input-device fields in the outgoing bundle and did not characterize them as keystroke logging. Neither does this analysis.

    Whether the content of those events, the characters typed and the coordinates of each tap, is recorded or transmitted was not confirmed.

    Under Google Play policy this lands in a grey zone that turns on that unproven question. Apple's rule against apps that "surreptitiously discover passwords or private data" (Guideline 5.1.1(vi)) and Google's hidden-functionality provisions would be implicated only if content capture were shown, and it was not.

    The log hook

    Separately, libmgc.so exports nativeGetLogcatBuffer, which reads the device's logcat, the running system log. On older Android, that buffer can hold URLs, error strings, and identifiers written by other apps on the phone. At the same time, libntool.so hooks the linker's LogdRead path (linker64+0x185000) to filter log entries:

    // Binary Ninja + Frida, Temu Android v4.39.0
    libmgc.so   -> nativeGetLogcatBuffer      // reads the system log buffer
    libntool.so -> hook @ linker64+0x185000   // LogdRead: filters entries on the way out
    

    There is a real limiter. On Android 11 and later (API level 30) an app's access to logcat is restricted to its own entries unless it holds the READ_LOGS permission, which ordinary apps are not granted. On a current phone, nativeGetLogcatBuffer mostly reads back only Temu's own output; the cross-app exposure is real chiefly on older Android versions or in privileged contexts. NTC filed the same behavior under "Relativisations," present but constrained, rather than as active surveillance.

    The asymmetry is what keeps this on the list under Google's Deceptive Behavior and Mobile Unwanted Software policies: a component that reads from a shared log while removing its own entries from what that same path reports is difficult to square with the disclosure those policies ask for.

    The iOS counterweight

    Neither behavior appears on iOS. iOS sandboxes the system log: one app cannot read another's entries, so there is no cross-app log to draw from and nothing analogous to the libntool filter. Native input interception was not observed on iOS either, though that pass was screening-depth, so the honest statement is not seen here, not cannot exist.

    Finding 8: Temu Probes for Jailbreak Tweaks Before It Trusts the Phone (iOS), and Catches Crashes That Would Rat It Out (Android)

    On launch, the iOS app checks whether the phone is jailbroken by looking for the two files a rootless jailbreak uses to inject tweaks:

    // Frida syscall trace, Temu iOS v4.71.0 — two access() calls at launch
    // both return -1 on this device: palera1n ROOTFUL, tweaks live in /cores/binpack, not /var/jb
    access("/var/jb/usr/lib/TweakLoader.dylib", F_OK) = -1
    access("/var/jb/usr/lib/TweakInject.dylib", F_OK) = -1
    

    This test device is palera1n rootful, so it keeps its tweaks under /cores/binpack. Both checks returned -1, the probe missed, and the app ran a full session under instrumentation without complaint.

    No PT_DENY_ATTACH call surfaced through the libSystem ptrace wrapper, the usual anti-debugger flag that tells the kernel to refuse a debugger. That could mean the app does not set it, or that it sets it through a direct syscall the export hook would not catch. This pass did not test at the syscall layer. Whether Temu blocks debuggers by another route was not confirmed.

    On Android this shows up as crash suppression rather than file probing. libriver.so hooks the five libc functions a security check would call to kill the process on detecting tampering, and routes all of them through an anonymous read-write-execute trampoline:

    // Frida Interceptor, libriver.so (unpacked from memory), Temu Android v4.39.0
    // backtrace on abort() lands in a trampoline at 0x757b3e5000, not a direct libc call
    hooked: abort · exit · _Exit · signal · sigaction
    trampoline: 0x757b3e5000  (28 KB, anonymous RWX, unnamed in /proc/self/maps)
    

    The hooks cover abort, exit, _Exit, signal, and sigaction. If an integrity check somewhere in the app decides the environment is hostile and calls abort() to bail out, libriver catches that call before it lands, so the process keeps running.

    Every app handling payments and a fraud budget ships some of it, and the neutral Swiss lab NTC that ran the same Frida method rated Temu's root- and debug-detection Low.

    Standard anti-tamper is within policy on both platforms; the iOS probing is within policy leaning grey zone only because PT_DENY_ATTACH went untested and the fuller anti-debug posture is therefore unmeasured. None of it is a Play Store or App Store violation.

    Finding 9: Two Fraud-Fingerprinting SDKs in the Shopping App, Forter and PayPal's Data Collector

    Temu's iOS Frameworks directory ships two third-party device-fingerprinting kits, both dedicated to fraud:

    // otool -L / directory listing, Temu.app/Frameworks — iOS v4.71.0
    ForterSDK.framework                 // Forter anti-fraud device fingerprinting
    BraintreeCore / BraintreePayPal     // PayPal payment SDK
    PayPalDataCollector                 // PayPal's device-fraud data collector (Magnes)
    

    Forter is an anti-fraud vendor whose product is device fingerprinting. PayPalDataCollector is the Magnes fraud collector that ships inside PayPal's Braintree payment SDK.

    Fraud fingerprinting at checkout is standard and legitimate. PayPal ships a collector specifically so merchants can fight payment fraud; finding it inside a checkout-heavy shopping app is unremarkable. Forter is a mainstream vendor doing a recognized job. The open question is disclosure: whether the fingerprinting is disclosed, and at what scale and persistence it operates combined with everything else in this app.

    Beside the ordinary fraud fingerprinting every shop fits at checkout, Temu also carries the reinstall-persistent iOS Keychain __AMSEC_DID__ = 79524A86-166F-4B51-A5D4-DE3FBE748F2E, the reset-surviving Widevine gmd hash on Android, and the custom telemetry cipher.

    The compliance verdict turns on disclosure. Fraud fingerprinting is a recognized legitimate purpose, so this is a grey zone that resolves the moment the disclosure is checked. One caveat: Apple's fingerprinting ban has only a narrow tolerance for fraud and security signals, so even a legitimate fraud purpose does not make these SDKs automatically exempt, and disclosure still has to carry the weight. Three obligations attach. The developer is responsible for what a bundled SDK does, so "it's Forter's code" does not transfer the duty (Google). Since 1 May 2024, each SDK must ship its own Privacy Manifest and declare any Required Reason APIs, and those declarations must feed Apple's privacy Nutrition Label; a fingerprinting SDK that fails to do so is an accuracy gap the app owner still answers for. And a CCPA/CPRA opt-out has to actually reach these fraud flows, not stop at the advertising layer. Whether Temu's labels and disclosures name Forter and PayPal's collector accurately was not verified in this pass; the SDKs are present, and the compliance question rests on that disclosure being complete.

    One caveat: caller attribution across these embedded SDKs was not fully resolved. Several Keychain and fingerprint-source reads were observed at the app level, but walking the return-address chain to split Temu's own reads from Forter's and Braintree's is listed as remaining work. Some of the device-identity machinery attributed to the app may belong to a bundled SDK doing its declared fraud job.

    Complete Data Flow

    Where the bytes go, once the app is running on a phone someone owns and the TLS is decrypted on that same device:

    DestinationDataCompany / Jurisdiction
    pftka- / thtka- / matka-us.temu.com (CLIM)Encrypted device + session + navigation telemetry: model, OS, cold-start and splash timing, per-view class names, install/backup state, GSLB cache internals — gzip'd JSON, custom-ciphered, keyed to __AMSEC_DID__Temu / PDD Holdings (served via Azure)
    us.temu.com /api/ph/v1/{m1,e1} (bgc / ng)Encrypted device-fingerprint signature — inputs include hw.machine, hw.memsize, kern.bootsessionuuid, IDFVTemu / PDD Holdings
    Forter SDKDevice fingerprint for anti-fraudForter Inc. (US)
    Braintree / PayPalDataCollectorPayment and device-fraud dataPayPal (US)
    *.kwcdn.comConfig and image assets (CDN)Temu, fronted by Cloudflare

    Compliant vs. grey zone

    Verdict scale: Within policy · Grey zone · In tension with. No row asserts illegality. Only the DSA and PIPC matters below are adjudicated, and neither concerns the native-code behaviors.

    BehaviorPlatformVerdictPolicy / regulation
    Comprehensive native-library hiding (all 10 libs erased from /proc/self/maps; libntool hooks do_dlopen + remove_soinfo; CFI bypass)AndroidIn tension withGoogle Play Maskware + Mobile Unwanted Software (disclose "all principal and significant functions") + Deceptive Behavior
    Runtime ART method replacement + disableVerifier(), server-flag-gated (ab_apm_enable_ihnew_42400)AndroidIn tension withGoogle Play Device and Network Abuse — an app "may not modify, replace, or update itself using any method other than Google Play's update mechanism"
    Custom un-auditable white-box cipher on telemetry (wraps CLIM + ng/bgc; also covers Finding 4's telemetry-contents verdict)Android + iOSGrey zoneNo store rule bans custom crypto; indirect tension with transparency duties — Apple 5.1.1(ii), GDPR Arts. 13–15, MUwS (clear about functionality)
    Persistent device ID surviving reset/reinstall — Widevine gmd (Android); Keychain __AMSEC_DID__ (iOS)Android + iOSAndroid: In tension with · iOS: Grey zone → tensionGoogle User Data policy (bans linking persistent hardware IDs); Apple fingerprinting ban ("regardless of… permission"); ePrivacy Art. 5(3) (consent bites hardest on analytics use); GDPR Recital 30; CCPA/CPRA persistent-identifier rules
    System-DNS bypass (libtcplink custom resolver; getaddrinfo never called)AndroidGrey zone, leaning tension (effect-based)Google Device and Network Abuse ("interfere with… networks… services"); no rule names DNS bypass. Not replicated on iOS
    Native input interception (proxyInputFunc; hooks libinput/InputConsumer)AndroidGrey zone (capability-vs-use gap)Apple 5.1.1(vi) + Google hidden-functionality only if content capture were shown; observed purpose was input-latency APM. Keystroke capture not confirmed
    Logcat reading (nativeGetLogcatBuffer) while filtering own entries (LogdRead hook)AndroidIn tension withDeceptive Behavior / MUwS. Limiter: Android 11+ restricts logcat to own entries absent READ_LOGS
    Third-party fraud fingerprinting — Forter, PayPal/Braintree PayPalDataCollectorAndroid + iOSGrey zone (turns on disclosure)Fraud fingerprinting is a recognized purpose; Apple's fingerprinting ban has only narrow fraud tolerance; obligation is accurate disclosure — Apple Privacy Manifest / Required Reason API (since 1 May 2024), Google SDK-responsibility, CCPA/CPRA opt-out
    Anti-crash / anti-debug hooks (libriver hooks abort/exit/signal/sigaction via RWX trampoline)AndroidWithin policy → grey zoneCommon anti-tamper; NTC rates root/debug detection Low. Concern only within the broader opacity pattern
    Anti-debug / jailbreak-tweak probing (access() on TweakLoader/TweakInject paths)iOSWithin policy → grey zoneStandard anti-tamper, lighter than Android. No PT_DENY_ATTACH confirmed
    Single visible Mach-O, no library hidingiOSWithin policy878 modules enumerated, Temu visible; no hiding surface — the fairness counterweight
    System resolver used, DNS not bypassediOSWithin policygetaddrinfo called 337×; GSLB/GTM hostnames resolve normally
    No ART/Java, no runtime method replacementiOSN/APlatform forecloses it

    The regulatory penalties on the record sit apart from all of the above. The €200M DSA fine (May 2026), the ~₩1.386B (~US$982k) South Korea PIPC fine (May 2025), and the $2M FTC INFORM Act settlement (September 2025) concern illegal products, marketplace disclosure, and consumer protection. None of them adjudicates the data behaviors in this teardown.

    What remains unconfirmed

    • Keystroke and input-content capture (Android). The input-interception infrastructure is confirmed. proxyInputFunc is one of libasape's 40 Java2C native methods, and native hooks on libinput's input consumers were installed at startup. Whether those hooks record or transmit keystroke content was not shown. The capability is present; content capture was not observed. NTC did not allege keylogging either.
    • The native bgc/ng fingerprint byte layout (iOS). The CLIM channel is fully decrypted. The native ph encoder that builds the bgc/ng encryptedData was not hooked. Its inputs are known (hw.machine, hw.memsize, kern.bootsessionuuid, IDFV, locale); its exact byte layout is not.
    • Cross-device and cross-version key consistency. Whether the 60+ embedded constants in sub_4cde58 are identical across devices, versions, and regions is unknown. No claim is made that one reimplementation decrypts all traffic everywhere.
    • iOS cipher internals. The white-box S-boxes and embedded constants were verified on Android. On iOS the evidence is that CommonCrypto is never called for these bodies and the Keychain key decrypts nothing; that the iOS cipher is the same construction is inferred, not shown at the byte level.
    • iOS input interception. No iOS analog surfaced this pass, and the area was not tested at depth. Its existence on iOS is neither asserted nor denied.
    • iOS PT_DENY_ATTACH. Not observed through the libSystem ptrace wrapper. It could be absent, or a direct-syscall variant an export hook would miss. Untested at the syscall layer.

    Recommendations

    For a security team deciding whether Temu, or any app like it, belongs on a managed fleet:

    1. Treat un-auditable telemetry as a policy question, not a technical one. A cipher no outside party can inspect means the data-flow claims in a privacy label cannot be verified, only trusted. Decide whether that is acceptable before deployment.
    2. On Android, assume the app on the phone may differ from the app the store reviewed. Server-flag-gated runtime method replacement means store review certifies a build, not a behavior. Manage the app as if it can change without an update.
    3. Test on hardware the team owns, with its own tooling. Everything in this teardown is reproducible with Frida, mitmproxy against a locally installed root certificate, and a static-analysis tool. A managed environment can run the same checks against its own installs.
    4. Watch the fingerprinting SDKs' disclosures, not just the app's. Forter and PayPal/Braintree carry their own privacy-manifest obligations. Verify those flow through to the app's declared data practices.

    For a Temu user: the advertising ID reset does not reach these identifiers, so it buys less than it appears to. The identifier that matters here, Widevine gmd on Android and __AMSEC_DID__ in the iOS Keychain, is not one an app-settings toggle clears. On iOS a full device reset clears the Keychain copy; on Android the hardware ID does not clear without new hardware.

    Methodology

    iOS. A jailbroken iPhone X (iPhone10,3, iOS 16.7.15, palera1n rootful, tweaks living at /cores/binpack rather than /var/jb). Frida 17.16.4 over USB, spawn-gated. TLS decrypted with mitmproxy via a root certificate installed on the device. Two 45-second runs for the screening pass, then follow-up runs that recovered the CLIM plaintext by hooking +[TMMetricsEncryptMgr encryptReq:] at the cipher input. Target: com.einnovation.temu v4.71.0, US region.

    Android. A rooted Pixel 6a (Magisk). Frida 17.x for runtime instrumentation, Binary Ninja for static analysis of the 14 native libraries it decompiled. libriver.so, a 3.2 MB module that decompiled to one function / 23 lines while obfuscated on disk, yielded 78,021 lines across 1,293 functions once unpacked from memory. Six sessions. Target: v4.39.0, US region.

    No Temu server was touched. The wire cipher was never key-broken on either platform; the plaintext was read one step before encryption, at the app's own cipher boundary.

    Corrections

    Three findings from earlier sessions were disproved by later evidence and withdrawn.

    • OpenSSL patching (disproved). An early hypothesis held that libriver hot-patches OpenSSL at load time. Byte-for-byte memory dumps of all three crypto libraries (stable_cronet_libcrypto.so, Conscrypt's libcrypto.so, and the system libcrypto.so) showed zero differences from their on-disk versions. The mprotect calls were scanning crypto function prologues, not modifying them. "Patches OpenSSL" implies TLS interception; "hooks abort/signal" is a different, lighter threat model.
    • The "AES-256 key" was ciphertext. A value initially read as an extracted key (680d2664…) turned out to be the cipher's output, not its input. The calling convention of sub_4cde58 is (data_ptr, data_length, mode, output_ptr), with no key argument. There is no extractable key to pull out. That makes the traffic harder to audit, not easier.
    • 8.8.8.8:20480 was Happy Eyeballs, not a covert channel. A connection to a Google DNS IP on a non-standard port looked like a proprietary protocol. Hooking every socket I/O function showed zero data bytes transmitted; the socket is immediately reused to probe IPv6 and then the real server. It is Chromium's Happy Eyeballs v2 (RFC 8305) connectivity probe, benign and present in any Chromium networking stack.

    Related work

    The iOS and Android teardowns were run independently, on different devices, with overlapping but distinct tooling. The cross-platform Temu machinery (the custom cipher, the CLIM channel, the ng/bgc fingerprint signing, the jailbreak probing, the fraud SDKs) appears on both, and the headline Android mechanisms (library hiding, ART method replacement, DNS bypass, logcat reading) appear only where the Android platform allows them.

    Independent corroboration comes from the National Test Institute for Cybersecurity (NTC, Switzerland), whose December 2024 Frida-based analysis reached the same core observations. Those included the encrypted device-detail bundle they could not always decrypt, the Widevine ID visible in the outgoing payload (mediaDrm=…|Widevine CDM), and the input-device string. Like this teardown, NTC found no sensitive personal data in the encrypted traffic and did not call the input interception keylogging (https://www.ntc.swiss/hubfs/temu-security-analysis-ntc-en.pdf). Custom DNS, dynamic code execution, anti-debug, and cross-app inspection were first raised publicly by Grizzly Research in September 2023, though that report came from a short-seller with a financial position and should be read with that context (https://grizzlyreports.com/). In December 2025 the Arizona Attorney General sued Temu citing its own forensic review, which alleges multiple encryption layers, self-editing frameworks it names "Manwe" and "ZipPatch," and retained Pinduoduo code. Those claims are not yet validated by an independent lab and are in active litigation (https://cyberinsider.com/arizona-ag-sues-temu-over-malware-and-data-exfiltration-activities/). The parent-company precedent is established, including Pinduoduo malware in off-Play builds, Google's March 2023 suspension, and CVE-2023-20963 (CVSS 7.8), but this teardown found no OS-CVE exploitation in Temu and makes no such claim.

    Disclosure

    No coordinated disclosure was made. The behaviors documented here are design decisions in Temu's own app, a custom cipher, a persistent identifier, an on-device fingerprinting pipeline, rather than vulnerabilities in Temu's servers or in anyone else's systems. There is no defect for Temu to patch and no third party at risk from publication. The findings affect the people carrying the app, so the audience is those people and the security teams who evaluate the app, and the venue is open publication. Where mechanisms overlap with adjudicated matters, those are already before regulators and a court.

    The honest bottom line

    The headline Android mechanisms are, at bottom, Android-platform mechanisms: hiding all ten libraries, rewriting Java methods under a server flag, and resolving hostnames past the system DNS all need seams the iOS sandbox does not provide, and on iOS they did not appear. The \xec\x01 bodies are exhaustive device, session, and navigation telemetry: gzipped JSON read one step before a cipher that was never broken. There is no key in the binary to lift, only a cipher smeared across 60+ constants, which makes the traffic harder to audit. The decrypted telemetry is normal-category APM and device data, not messages or photos or keystrokes. Temu builds ordinary telemetry, seals it so no outsider can verify what is inside, ties it to an identity that survives reset, and on Android carries the infrastructure to change its own behavior after store review, while the same corporate parent, PDD Holdings, has a documented history of shipping exactly that kind of capability in Pinduoduo.