A research-style walkthrough of how the Android client PicPat signs and encrypts requests to its own backend, and which design decisions on the way look questionable.
All user IDs, firebase IDs, signatures and nonces in the examples below are synthetic — same format and length as the real ones, generated values. The analysis was done against my own account on test devices.
PicPat is a clone of Locket Widget — an app for sharing photos through a widget on the home screen. You snap a photo, hit send, and it shows up live in your friend's home-screen widget; their photos do the same on yours. The category's whole pitch is: no notifications, no feed, just a "live" widget that pipes photos in from your inner circle.
Internally PicPat barely tries to hide its lineage from the original: the package name is locket.live, the application class is LocketApplication, the theme is Theme.Locket. The "Locket" brand is nowhere on the store page or in the UI, though — it's a separate product on the shop floor, with the rename never propagated into the code.
| Field | Value |
|---|---|
| Store name | PicPat — Photos Widget & Share |
| Android package | locket.live |
| Version (at time of analysis) | 1.5.7 (vcode 59) |
| Google Play | play.google.com/.../locket.live — 1M+ installs, 2.8★ from ~2.6K reviews, Social category, 12+ |
| Developer site | build4world.com |
| Developer | Build4world (legal entity: One Dot Mobile Limited, Hong Kong) |
| Split-APK architectures | armeabi-v7a, arm64-v8a |
| Backend API | https://api.locket.live |
| Media storage | Firebase: loli-2c6ab.appspot.com (private), loli-2c6ab-public (public) |
| Native crypto library | libamber_security.so (~36 KB) |
| Crypto SDK Java package | com.amber.lib.security |
One curious detail from the Play Store card: under Data safety it declares "Data isn't encrypted". The analysis shows there is transport encryption (AES-256-CBC + HMAC-SHA256); what exactly Google's declaration refers to is an open question — maybe at-rest storage, maybe something else. Either way, the declaration doesn't match what's actually on the wire.
The app uses its own custom client-server crypto wrapper built on the com.amber.lib.security SDK. The SDK ships in a family of 17 apps from the same Chinese vendor. The reverse fully recovered the protocol: request signing (SIGN_V2) and authenticated encryption of the body (REQUEST_V2 / RESPONSE_V2).
The substantive findings:
- The native library bakes in 17 hardcoded 32-byte ASCII secrets — one per app in the family. Extracting one library compromises all 17 products.
- The same 32-byte material is used simultaneously as the AES-256-CBC key, the HMAC-SHA256 key, and the salt for the MD5 signature — a clear key-separation violation.
- The signature is built on MD5 with a custom
MD5(prefix || secret || suffix)construction instead of HMAC, even though HMAC-SHA256 is already implemented in the same library and used for ciphertext authentication. - In debug builds the signature secret is the literal string
"release".
The reconstructed protocol is validated: decrypting captured traffic of my own gives correct plaintext that passes the HMAC check; a self-computed signature matches the one captured by a Frida hook on the original library, byte for byte.
The work was done on an emulator — Nox, x86 with ARM translation, Android 7.1 (api 25), Samsung SM-G965N profile. The emulator is convenient because frida-server runs stably on it.
The class com.amber.lib.security.NET declares an AppId enum with 17 values — one per app from the same vendor, all sharing the same crypto wrapper:
0 WALLPAPER 8 POLICE_SCANNER
1 CALLER 9 LOCATOR
2 EASE 10 CLUBROOM
3 WEATHER_GEO 11 HIDEU
4 DAILY_LUCKY 12 OBOS
5 DAILY_NEWS 13 FILE2
6 DAILY_SALE 14 LOCKET
7 TRACK_PACK 15 WISE_MATE
16 WISE_ART
The names alone sketch out the vendor's portfolio: wallpapers, caller ID, weather, fortune-style predictors (lucky), news, sales/deals, trackers, police scanner, navigation, chat rooms, vault-style apps (HIDEU), file manager (FILE2), AI tools. PicPat is registered as AppId.LOCKET = 14.
The reverse was done in two phases.
Static analysis. APK decompiled through jadx. libamber_security.so (armeabi-v7a, 32-bit ARM, Thumb mode) decompiled and disassembled in Ghidra. The pieces studied: the SecurityController class (flags), NET (JNI wrappers), AES (JNI wrappers), and the entry points into native code via JNI_OnLoad → RegisterNatives.
Dynamic verification. Frida on the Nox emulator, with a hook on com.amber.lib.security.NET.getSign(String[], int, int). That gave live (input array, output array) pairs for request signing — enough to validate the reconstructed algorithm.
Decryption of my own captured traffic was done through a mitm proxy (HTTP Toolkit), then post-processed with a local Python script.
Holds the protocol version flags:
public class SecurityController {
public static final int SIGN_V1; // = 1
public static final int SIGN_V2; // = 2
public static final int RESPONSE_V1; // = 256 (0x100)
public static final int RESPONSE_V2; // = 512 (0x200)
public static final int REQUEST_V1; // = 65536 (0x10000)
public static final int REQUEST_V2; // = 131072(0x20000)
// ...
}The versions of the three independent crypto mechanisms are packed into a single int (6 bits each):
getSignVersion(flag) -> flag & 0x3F // bits 0-5
getResponseVersion(flag) -> (flag >> 8) & 0x3F // bits 8-13
getRequestVersion(flag) -> (flag >> 16)& 0x3F // bits 16-21The active profile for the app under study is SIGN_V2 | REQUEST_V2 | RESPONSE_V2 = 131586, passed to the server in the HTTP header Security-Controller: v=131586.
At init time SecurityController tries NET.getSign({"AAA","BBB"}, 2) as a self-test. On failure it sets sLoadedSecurity = false and encryption silently turns off, with this characteristic log message left in by the vendor without translation:
没有集成security,无法使用加密模块!!!
("Security not integrated, encryption module cannot be used!!!")
public class NET {
static { System.loadLibrary("amber_security"); }
public static native String encrypt(String, int, int, byte[]);
public static native String decrypt(String, int, int);
public static native String[] getSign(String[], int, int);
}The native methods are not exported in the usual Java_<package>_<class>_<method> way. Instead, JNI_OnLoad calls RegisterNatives for the classes com/amber/lib/security/NET (3 methods) and com/amber/lib/security/AES (2 methods):
void JNI_OnLoad(JavaVM* vm) {
JNIEnv* env; (*vm)->GetEnv(vm, &env, JNI_VERSION_1_4);
register_natives(env, "com/amber/lib/security/AES", aes_methods, 2);
register_natives(env, "com/amber/lib/security/NET", net_methods, 3);
}This is a moderate anti-reverse measure: without reading the JNINativeMethod table, the analyst can't directly map Java methods to C functions (in Ghidra those show up as anonymous FUN_xxxxxx).
Once the table is unpacked:
| Java method | C function |
|---|---|
NET.encrypt(String,int,int,byte[]) |
0x17f08 → 0x1627c (trampoline) |
NET.decrypt(String,int,int) |
0x17f20 → 0x1627c (trampoline) |
NET.getSign(String[],int,int) |
0x16360 |
AES.encrypt(byte[]) |
0x17efc → ... (trampoline) |
AES.decrypt(byte[]) |
0x17f02 → ... (trampoline) |
encrypt and decrypt are trampolines over a single universal function with a direction flag. Standard pattern.
At address 0x19124 in the library's .rodata sits an array of 17 char* pointers, each one pointing at a 32-byte ASCII, null-terminated string. These strings are the key material for every crypto operation in the SDK. The strings are printable bytes in the [0x21..0x7E] range, exactly 32 bytes each (256 bits — AES-256 key length), with no internal structure to speak of; they look like the output of pwgen 32 17.
The algorithm was recovered from the native function at 0x16360 and validated by Frida hook.
inputs:
params — dict[str, str] original request parameters
secret — bytes (32) the secret picked by AppId
algorithm:
1. random := uniform_int(1_000_000, 999_999_999) # 7-9 digit nonce
2. pairs := list(params.items()) + [("_random", str(random))]
3. pairs.sort(key=lambda kv: kv[0]) # sort pairs by key
4. concat := "".join(s for kv in pairs for s in kv) # k1 v1 k2 v2 ... no separator
5. pos := random mod len(concat)
6. salted := concat[:pos] + secret + concat[pos:] # secret inserted into the string
7. signature := md5(salted).hexdigest() # 32 hex chars
output:
params["_random"] := str(random)
params["_sign"] := signature
A reference vector (captured live via Frida):
| Field | Value |
|---|---|
| AppId | 14 |
| Version | 2 |
| Input array (flat) | [os_ver, 7.1.2, language, ru, pkg, locket.live, vcode, 59, network, WIFI, image_firebase_id, k8GAudccnAMzatz23HvA53Wykpfh, os_vcode, 25, referrer, utm_source%3Dgoogle-play%26utm_medium%3Dorganic, uid, Z9E2102B6DA8BE106ACDC77022FE9454D9C, vname, 1.5.7, name, PicPat, os_name, Android, model, SM-G965N, time, 1778921496896, lang, _, brand, samsung, _timestamp, 1778929670] |
Returned _random |
650720498 |
Returned _sign |
8de3d8ed288dc10df72c6ce3c26486ee |
Computing the signature manually with the algorithm above against the original (non-sanitized) values reproduces the same _sign byte for byte. The values shown above are synthetic — see the disclaimer at the top.
The Java glue builds the input array roughly like this (simplified, from createCall):
Params params = createBaseParams();
params.merge(NetManager.getInstance().getGlobalParams());
if (request.getAddExtraParams())
params.merge(mPrivacyExtraParams.getExtraParams(context));
params.merge(request.getParams());
params.set("_timestamp", String.valueOf((System.currentTimeMillis() - mTimeDiff) / 1000));
String[] sign = NET.getSign(params.toArray(), signVersion);
for (int i = 0; sign != null && i < sign.length; i += 2)
params.set(sign[i], sign[i + 1]);params.toArray() returns the flat array [k1, v1, k2, v2, ...]. The native function takes that array, appends ("_random", randomStr) itself, sorts pairwise by key, concatenates, inserts the secret at offset random % len, computes MD5. It returns ["_random", str, "_sign", str], and the glue writes those back into params. After that params is serialized to a query string and goes through encryption (§6.2).
A couple of implementation notes:
_timestampis unix seconds, corrected bymTimeDiff(an accumulated client-server clock drift). The separatetimefield is in milliseconds and is business-logical — it represents when the related entity last changed, not the current moment.- The sort is pairwise: positions
iandi+1move together. In the decompilation this shows up as a double loop with stride 2 on both indices and simultaneous swap of(k, v)pairs. - The concatenation uses no separator. In theory this introduces a small ambiguity (
{a:bc}and{ab:c}give the sameabc), but with unique keys inparamsit's not exploitable. As a design principle, though, worth flagging.
The blob format:
┌──────────┬──────────┬──────────────────────────────┐
plaintext ──AES──▶│ IV │ MAC │ AES-256-CBC ciphertext │──▶ base64 ──▶ URL-encode ──▶ HTTP body
│ 16 bytes │ 32 bytes │ (len % 16 == 0) │
└──────────┴──────────┴──────────────────────────────┘
random nonce HMAC-SHA256(secret, ciphertext)
The scheme is Encrypt-then-MAC (Bellare–Namprempre, 2000). Unlike the signature, this is the right composition for authenticated encryption.
Encryption:
iv := random_bytes(16)
padded := plaintext || pkcs7_pad(plaintext)
ciphertext := AES_256_CBC(key=secret, iv=iv).encrypt(padded)
mac := HMAC_SHA256(key=secret, msg=ciphertext)
blob := iv || mac || ciphertext
transport := base64(blob)
Decryption is the mirror image: base64-decode, split into (IV, MAC, ciphertext), recompute HMAC and compare, AES-256-CBC decrypt, strip PKCS#7 padding.
Implementation notes:
- AES is implemented in-house (not a wholesale link against OpenSSL/mbedTLS). Two giveaways: the size of the library (~36 KB) and the characteristic
auStack_128[256]expanded-key buffer — the textbook size of an AES key schedule. - SHA-256 is also in-house, with the canonical initial hash values
0x6a09e667, 0xbb67ae85, .... - HMAC is by the book:
H((K ⊕ opad) || H((K ⊕ ipad) || msg)), whereH = SHA-256, with the standard0x36and0x5cconstants. - Padding is PKCS#7: pad to block on encrypt, verify the last byte sits in
[1..16]and trim on decrypt.
Problem. The 17 secrets sit in plaintext in the library's .rodata. The library ships with every client and is available for extraction to anyone who installs the app.
Impact. Compromising one library = compromising all 17 of the vendor's products (it's shared between them). An attacker with these secrets can decrypt their own traffic from any app in the family; given full knowledge of the server API, they can forge signatures and encrypt arbitrary requests.
How to fix it.
- Replace the hardcoded shared secret with a per-installation key. On first launch the client generates a key pair and publishes its public key to the server (for example through a bootstrap call with device attestation). After that, use a key exchange (ECDH / X25519). A secret shared across all clients can't really be called a secret — it gets published on the Play Store along with the APK.
- Use hardware-backed key stores (Android Keystore + StrongBox where available) for the client's private keys. This blocks trivial extraction even on rooted devices.
- If for some reason a shared secret is genuinely required (e.g. for anti-cheat), then at the very least obfuscate it on the fly and store it encrypted, decrypting only at the moment of use with memory-dump protection. But that's a palliative.
Problem. The same 32-byte material is used simultaneously as:
- the AES-256-CBC key,
- the HMAC-SHA256 key,
- the salt/secret in the MD5 signature.
Impact. A key-separation violation. In the observed implementation no direct exploitable consequence shows up (HMAC and AES in the Encrypt-then-MAC composition with one key still give IND-CCA2 security under idealized assumptions), but it's bad practice: any future change to one primitive, or any newly discovered attack against one of them, automatically creates a vulnerability in the others.
How to fix it. Derive three independent keys from one master secret via HKDF (RFC 5869):
prk = HKDF-Extract(salt = "amber-v3", ikm = master_secret)
K_enc = HKDF-Expand(prk, info = "enc", L = 32)
K_mac = HKDF-Expand(prk, info = "mac", L = 32)
K_sign = HKDF-Expand(prk, info = "sign", L = 32)
Problem. Request signing uses MD5 with the construction MD5(concat[:pos] || secret || concat[pos:]) instead of standard HMAC. MD5 is cryptographically dead: collisions are found nearly instantly (Wang, Yu, 2004; chosen-prefix Stevens et al., 2007).
Impact. For this specific construction no practical attack is known as of this analysis — the position-dependent insertion is somewhat non-standard. But:
- The construction has no public security proof (unlike HMAC).
- Using MD5 for any cryptographic purpose in new development in 2026 has no justification.
- HMAC-SHA256 is already implemented in this same library, and is what should have been used.
How to fix it. Replace with HMAC-SHA256(K_sign, canonical_serialization(params)). The canonical serialization could be JCS (RFC 8785) or simply a key-sorted k1=v1&k2=v2&... string with proper escaping.
Problem. Inside the function at 0x16360 there's a branch like this:
if (FUN_00012f04() == 0) { // is debug build?
secret = "release";
} else {
secret = SECRETS[appId];
}In debug mode the secret is the literal 8-character string "release". The intent is obvious — easier testing; the irony is that the string is named release.
Impact. If FUN_00012f04 relies on a client-checkable flag (for example reading BuildConfig.DEBUG through JNI), it can be patched at runtime via Frida/Xposed to flip the SDK into "debug-secret" mode. Once flipped, every signature becomes trivially forgeable.
How to fix it. Don't leave a debug branch in release builds at all: use compile-time #ifdef DEBUG ... #endif, removed by the preprocessor, not a runtime check. Additionally — use SafetyNet / Play Integrity for server-side build validation.
Problem. RegisterNatives without other defenses gives a false sense of security. Any reverser familiar with JNI reads the JNINativeMethod table in about a minute.
Impact. Not really a vulnerability, more a remark about priorities: effort was spent on this, but not on the things that actually matter (hardcoded secrets, MD5).
How to fix it (if this is treated as a hard requirement). Serious native-code obfuscation: control-flow flattening (OLLVM), string encryption, anti-debug, integrity self-checks, root detection with server-side reporting. But none of this replaces correct cryptographic architecture — it only raises the cost of extraction without making it impossible.
Decrypting a captured request to /user/info produces correct plaintext that passes the HMAC check:
_random=359276626&os_ver=7.1.2&firebase_id=k8GAudccnAMzatz23HvA53Wykpfh&
language=ru&_sign=8de3d8ed288dc10df72c6ce3c26486ee&pkg=locket.live&vcode=59&
network=WIFI&os_vcode=25&referrer=utm_source%3Dgoogle-play%26utm_medium%3Dorganic&
uid=Z9E2102B6DA8BE106ACDC77022FE9454D9C&vname=1.5.7&name=PicPat&os_name=Android&
model=SM-G965N&lang=_&brand=samsung&_timestamp=1778926157
Convergence in both directions — self-computed _sign matches the captured one, and decryption of the captured blob yields readable plaintext that passes the HMAC — settles the question of correctness for the reconstruction.
- The Encrypt-then-MAC composition is the right choice: the MAC is computed over the ciphertext, not over plaintext.
- HMAC, SHA-256, AES are all implemented by the book, with the canonical constants and operation ordering.
- Protocol versioning is baked in architecturally: a single int packs
SIGN/REQUEST/RESPONSEversions and is passed to the server asSecurity-Controller: v=N.
- Hardcoded shared secret in every installation (§7.1).
- Key reuse — one key for three roles (§7.2).
- MD5 + a custom "salt-in-middle" construction instead of HMAC (§7.3).
- Debug fallback to the literal
"release"secret (§7.4). - Reliance on JNI registration as a serious anti-reverse measure (§7.5).
- Don't store shared secrets in clients. Any statically baked-in key is published together with the client. Use asymmetric key exchange at first launch.
- One key, one role. Derive subkeys via HKDF.
- Standard constructions. HMAC-SHA256 for signing. AES-GCM (or ChaCha20-Poly1305) for AEAD instead of a hand-rolled AES-CBC + HMAC composition.
- Canonicalize before signing. If the protocol requires signing structured data — use JCS (RFC 8785) or CBOR with deterministic serialization, not a homegrown "sort and concatenate".
- No debug keys in release builds.
#ifdef, not a runtime flag. - Make versioning strict. Since
Security-Controller: v=Nalready exists, the server should require the latest version for new clients and gradually drop support for old ones; otherwise downgrade attacks are possible in principle. - Don't rely on "obfuscation through JNI". Any anti-reverse measure is only a complement to correct cryptography, not a replacement.
| File | Purpose |
|---|---|
frida_getsign_hook.js |
Frida script: hooks com.amber.lib.security.NET.getSign to collect live (input, output) pairs and validate the signature algorithm. |
protocol_roundtrip.py |
Full protocol round-trip: sign_v2 against the recovered formula, encrypt_v2 / decrypt_v2 (AES-256-CBC + HMAC-SHA256), and a live request to /user/info with response decryption. The SDK secrets are not bundled in the file — extract the 32-byte secret for your AppId from the array at 0x19124 in libamber_security.so and drop it in SECRETS[14] before running. |
libamber_security.so (armeabi-v7a, Thumb, ELF, ~36 KB)
├── JNI_OnLoad @ 0x1688c
│ └── RegisterNatives × 2 for com/amber/lib/security/{AES,NET}
├── NET.encrypt (trampoline 0x17f08 → 0x1627c)
├── NET.decrypt (trampoline 0x17f20 → 0x1627c)
├── NET.getSign @ 0x16360
├── AES.encrypt (trampoline 0x17efc → ...)
├── AES.decrypt (trampoline 0x17f02 → ...)
├── MD5_Update @ 0x13458 (standard A/B/C/D constants)
├── MD5_Final @ 0x13be8
├── SHA-256 @ 0x15a4c (standard H0..H7 constants)
├── HMAC-SHA256 @ 0x15cd0
├── AES-CBC encrypt/decrypt @ 0x15730 (with PKCS#7)
├── AES key expansion @ 0x14c10
├── Base64 enc/dec @ 0x13244 / 0x13024
└── .rodata:
└── 0x19124: array of 17 pointers to 32-byte ASCII secrets
SIGN_V2:
def sign_v2(params: dict, secret: str) -> dict:
rnd = uniform_int(1_000_000, 999_999_999)
pairs = list(params.items()) + [("_random", str(rnd))]
pairs.sort(key=lambda kv: kv[0])
concat = "".join(s for kv in pairs for s in kv)
pos = rnd % len(concat)
salted = concat[:pos] + secret + concat[pos:]
return {**params, "_random": str(rnd), "_sign": md5(salted).hexdigest()}REQUEST_V2 / RESPONSE_V2:
def encrypt_v2(plaintext: bytes, secret: bytes) -> bytes:
iv = random_bytes(16)
ciphertext = AES_256_CBC(secret, iv).encrypt(pkcs7_pad(plaintext))
mac = HMAC_SHA256(secret, ciphertext)
return base64(iv + mac + ciphertext)
def decrypt_v2(blob_b64: bytes, secret: bytes) -> bytes:
blob = base64_decode(blob_b64)
iv, mac, ciphertext = blob[:16], blob[16:48], blob[48:]
assert HMAC_SHA256(secret, ciphertext) == mac
return pkcs7_unpad(AES_256_CBC(secret, iv).decrypt(ciphertext))Everything above is accurate as of May 17, 2026 and refers to a specific version of the app, 1.5.7 (vcode 59). The SDK's logic and the server-side validation can be changed by the vendor at any time; the experimental results and addresses inside the native library reflect the state at the time of analysis.
I do research, share observations, and guarantee nothing — not reproducibility on other versions, not the relevance of any of this at the time of reading, not the absence of mistakes in the protocol reconstruction. Any conclusions or decisions drawn from this document are the reader's own.