Reverse engineering and decrypting the Temu app on iOS and Android
Filip Luchianenco
Founder at CanITrustThat
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 / package | com.einnovation.temu |
| Version analyzed | iOS 4.71.0 / Android 4.39.0 |
| Seller | PDD Holdings |
| Framework | Native — 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 SDKs | Forter, Braintree / PayPalDataCollector |
| Method | Frida 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)
When security tool, like antivirus, corporate device management, or mobile-security need to know what native code an Android app is running it reads /proc/self/maps. 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:
| Library | Size (bytes) | Hidden | Purpose |
|---|---|---|---|
| libriver.so | 3,215,360 | Yes | Cipher engine, request signing, device fingerprinting |
| libtcplink.so | 471,040 | Yes | Custom DNS resolution, GSLB/GTM, network quality |
| libntool.so | 131,072 | Yes | Inline-hook engine (the library doing the hiding) |
| libasape.so | 90,112 | Yes | APM orchestrator, ART method replacement, input interception |
| libtestore.so | 139,264 | Yes | Encrypted key-value storage |
| libapm_caam.so | 106,496 | Yes | Crash/ANR monitoring, native thread dumps |
| libmgc.so | 61,440 | Yes | ART runtime access, logcat reading, /proc/self/maps saving |
| libguard_handler.so | 40,960 | Yes | Activity guard, force-finish detection |
| libc++_shared.so | 1,409,024 | Yes | C++ standard runtime |
| base.odex | 1,458,176 | No | Compiled 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.
In Google Play's terms this is classified as Maskware: 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.
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. This is controlled 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
In Android, 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)
- 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.
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.
The 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.
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. Though the observed use was APM and crash-fixing rather than the behavior the rule targets.
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 Encrypted telemetry that I was able to read anyways (Android + iOS)
Temu's telemetry uses default TLS, and once the TLS was decrypted on a device the analysis controls, the request 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 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.
Reconstructing the cipher means disassembling and re-implementing the entire function from those 60+ constants.
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. 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. 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.
I was able to recover the plaintext though by hooking the app one step before it encrypts, at +[TMMetricsEncryptMgr encryptReq:] on iOS, and reading the gzipped JSON going in.
Finding 4: The Encrypted Telemetry Data
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) is added to 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:
| Endpoint | Requests |
|---|---|
pftka-us.temu.com /clim/defined | 100 |
pftka-us.temu.com /clim/{front_err,api,scene,static} | 12 |
thtka-us.temu.com /c/rs | 40 |
thtka-us.temu.com /c/th | 37 |
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. No sensitive personal content was observed. 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.
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.
Finding 5: A Device ID That Survives Factory Reset on Android and App Reinstall on 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:69146f79804a0b7314c715dec...41358ae2d69170c36f9b17f47258b68a|com.google.android.widevine|[email protected]|Widevine CDM;
The 64-character hex string breaks down as:
| Field | Value | What it is |
|---|---|---|
| Device ID hash | 69146f79804a0b7314c715dec...41358ae2d69170c36f9b17f47258b68a | Hardware-bound Widevine certificate hash. Survives factory reset. |
| Plugin package | com.google.android.widevine | The Widevine DRM module |
| Version + build | [email protected] | Widevine version + Android build |
| Plugin name | Widevine CDM | Display 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". Wiping the device and setting it up as new changes the advertising ID, the app data, the accounts, and almost everything else, but this hash remains 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 to "recognise the install, respect the reset".
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. 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.
iOS has no Widevine equivalent, so persistence runs through the Keychain, which iOS preserves across app reinstalls by design. Temu writes and re-writes the 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. The rest of the iOS fingerprint can be found 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.
Finding 6: On Android, Temu Resolves Its Own Hostnames instead of using system provided service
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:
| Function | Purpose | Called in 35s |
|---|---|---|
getaddrinfo | Android system DNS resolver | imported, never called |
inet_pton / inet_ntop | convert resolved IP strings | active |
socket / connect | open TCP to a resolved IP | active |
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 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 called 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."
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.
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.
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 can happen on older Android versions. 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.
Finding 8: Temu probes for jailbreak tweaks before it trusts the phone (iOS), and catches crashes that would get reported (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.
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 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. Forter is a mainstream vendor doing a recognized job.
Fraud fingerprinting is a recognized legitimate purpose. 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.
- 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.
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
| Destination | Data | Company / 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, IDFV | Temu / PDD Holdings |
| Forter SDK | Device fingerprint for anti-fraud | Forter Inc. (US) |
Braintree / PayPalDataCollector | Payment and device-fraud data | PayPal (US) |
*.kwcdn.com | Config and image assets (CDN) | Temu, fronted by Cloudflare |
What remains unconfirmed
- Keystroke and input-content capture (Android). The input-interception infrastructure is confirmed.
proxyInputFuncis one of libasape's 40Java2Cnative 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/ngfingerprint byte layout (iOS). The CLIM channel is fully decrypted. The nativephencoder that builds thebgc/ngencryptedDatawas 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_4cde58are 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 libSystemptracewrapper. It could be absent, or a direct-syscall variant an export hook would miss. Untested at the syscall layer.
Recommendations
For teams deciding whether Temu belongs on a managed fleet:
- Treat un-auditable telemetry as a policy question A cipher no outside party can inspect means the data-flow claims in a privacy label cannot be verified. Decide whether that is acceptable before deployment.
- On Android, assume the app on the phone may differ from the app the store reviewed. Server-flag-gated runtime method replacement means behavior can change. Manage the app as if it can change without an update.
- Watch the fingerprinting SDKs' disclosures 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 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). 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.
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.