May 11, 2026
Six locks on a 256-byte secret: reverse-engineering an Android face-recognition SDK
Picking apart a commercial face-recognition library — to see what layers of protection a shipping native binary actually stacks up in…
Chanchal Raj
21 min read
Picking apart a commercial face-recognition library — to see what layers of protection a shipping native binary actually stacks up in practice, and exactly what each one costs an attacker who walks in cold.
Why bother taking it apart?
Most writing on software protection is either marketing copy ("military-grade obfuscation!") or pure theory (papers about VM-based protection schemes nobody actually ships). What's hard to find is a concrete, end-to-end walkthrough of a real shipping native library — showing which techniques actually get stacked together, where they slow you down, and where they don't.
So this post is that walkthrough. The subject is libttvfacesdk.so, the native library inside the FaceOnLive Face-Recognition-SDK-Android project on GitHub — a commercial Android face-recognition SDK shipped as a demo app plus a binary AAR. It runs on both arm64-v8a and armeabi-v7a, and it guards itself with a runtime license check signed by an embedded RSA public key. The exercise: find that public key, and along the way document every protection layer the vendor stacked in front of it.
The layers turned out to be more interesting than the destination. In order, they are:
-
Static OpenSSL link with full symbol export. Defeats naive "what crypto APIs does this lib link against?" curiosity by hiding internal call sites among hundreds of public OpenSSL symbols.
-
UPX packing. Encrypts the entire
.textand.rodataat rest; only an unpacker stub is visible to static analysis. -
Magic-byte stripping on UPX. Wipes the
UPX!header soupx -drefuses to recognise the file as packed, on the arm64 build. -
Header-field tampering on UPX. Corrupts a non-essential PackHeader field on the arm32 build, enough to trip the stricter validation in modern UPX 5.x but not the older UPX 3.96.
-
No PEM/DER key in the binary. The public key is split: modulus stored as a raw base64 string in
.rodata, exponent assembled into a stackstd::stringat runtime by a pair of immediate-move instructions. -
A non-obvious signing scheme. The Cryptolens C++ client this SDK uses signs license responses over the decoded JSON, not the base64 wrapper string — different from the better-documented .NET SDK, and enough to make a third-party validator silently fail.
Each of these is a cheap, well-known technique on its own. Stacked, they form a useful baseline of "what a competent vendor actually ships." None of them is rocket science. All of them, combined, are enough to make most analysts give up around the second hour. That's the design — and that's the interesting part.
This post walks the whole stack from the outside in.
The interesting bits to flag up front:
-
The .so is UPX-packed, with the
UPX!magic deliberately wiped on the arm64 build to breakupx -d. -
The key isn't stored as a PEM, an SPKI DER blob, or anywhere the obvious greps would find it. It's a base64 modulus literal, with the matching exponent assembled at runtime by a pair of Thumb-2
movw/movtinstructions. -
The library uses Cryptolens for licensing, and its C++ client signs license responses differently from the .NET one — a divergence that quietly breaks third-party validators.
The first clue: a suspiciously round 256 bytes
Before touching the binary, the demo app itself tells you almost everything you need to know about the protection scheme. MainActivity.kt literally has the license response hardcoded as a Kotlin string passed to FaceEngine.setActivation(…):
var ret = FaceEngine.getInstance().setActivation("""
{
"licenseKey": "eyJQcm9kdWN0SWQiOjIwNzg1LCJJRCI6MSwiS2V5IjoiTVlMQ08tU0JVR…==",
"signature": "DzA6tC4ByQqtmJvLltg8eDNsGUNZLqV2vEjwSZESjDFPQnI0QN/h+s8…==",
"result": 0,
"message": ""
}
""")var ret = FaceEngine.getInstance().setActivation("""
{
"licenseKey": "eyJQcm9kdWN0SWQiOjIwNzg1LCJJRCI6MSwiS2V5IjoiTVlMQ08tU0JVR…==",
"signature": "DzA6tC4ByQqtmJvLltg8eDNsGUNZLqV2vEjwSZESjDFPQnI0QN/h+s8…==",
"result": 0,
"message": ""
}
""")Four fields. Two of them — result and message — are obviously transport metadata. The other two are where the protection lives, and they each give up structural information just by looking at them.
licenseKey is base64-encoded JSON. Decoding it produces the actual license claims:
{
"ProductId": 20785,
"ID": 1,
"Key": "MYLCO-SBUDD-PXQJW-JIMEZ",
"Created": 1688527374, // 2023-07-05
"Expires": 1705807374, // 2024-01-21
"Period": 200,
"F1": true, "F2": false, …,
"GlobalId": 359411,
"ActivatedMachines": [
{ "Mid": "com.ttv.facedemo", "IP": "88.99.145.7", "Time": 1688527444 }
],
"MaxNoOfMachines": 1,
"SignDate": 1688527444
}{
"ProductId": 20785,
"ID": 1,
"Key": "MYLCO-SBUDD-PXQJW-JIMEZ",
"Created": 1688527374, // 2023-07-05
"Expires": 1705807374, // 2024-01-21
"Period": 200,
"F1": true, "F2": false, …,
"GlobalId": 359411,
"ActivatedMachines": [
{ "Mid": "com.ttv.facedemo", "IP": "88.99.145.7", "Time": 1688527444 }
],
"MaxNoOfMachines": 1,
"SignDate": 1688527444
}This is plaintext data: a product ID, an expiry timestamp, a list of features (F1…F8), and — crucially — an ActivatedMachines list binding the license to the Android package name com.ttv.facedemo. That last field maps directly to the F_LICENSE_APPID_ERROR constant in FaceEngine's public API: the SDK refuses to run if the calling app's package doesn't match the one signed into the license. If this JSON were stored unauthenticated, you could just edit the Expires field and Mid package name and call it a day.
signature is what stops that. It's 344 base64 characters with == padding, which decodes to exactly 256 raw bytes. That number is the entire tell. A 256-byte signature is the output size of one specific operation: a textbook RSA-2048 sign. (256 × 8 = 2048 bits, and RSA's signature is always exactly the same width as its modulus.) Nothing else in the standard crypto inventory produces 256-byte signatures by default — ECDSA P-256 is 64–72 bytes DER-encoded, Ed25519 is 64 bytes, HMAC outputs are typically 32 bytes. The size alone is enough to identify the scheme.
So before reading a single instruction of native code, the protection model is already clear:
-
The licensing server holds an RSA-2048 private key.
-
It signs the base64 licenseKey JSON (or, as we'll see later, the decoded JSON — that's the one subtle catch) and ships the 256-byte signature alongside.
-
The SDK holds the matching public key and uses it to verify the signature inside
setActivation(…). -
Tampering with the license JSON is detected because the signature won't re-verify against the embedded public key.
That gives us a concrete target: somewhere inside libttvfacesdk.so there's a 2048-bit RSA public key. Every protection layer the vendor added is, in effect, an attempt to make that key hard to find or modify. The rest of this post is the tour of those layers, in the order an attacker would hit them.
But before we go layer-shopping, let's stop for a paragraph on why RSA signatures are the answer to a licensing problem in the first place — because the answer makes everything that follows feel less paradoxical.
A short detour: how signing actually protects a license
There's a paradox sitting at the centre of this post. The whole protection scheme rests on an RSA key that is literally called the public key. Every protection layer we're about to walk through is an effort to hide a value that, cryptographically speaking, doesn't need to be hidden at all. Why?
The short answer: because secrecy isn't what the public key needs. Integrity is.
The slightly-longer answer is a three-property tour of asymmetric signing, which is worth knowing if you've ever wondered why offline license checks aren't laughably trivial to defeat.
Property 1 — two keys, asymmetric roles. An RSA keypair has a signing key (private, kept on the licensing server, never leaves it) and a verifying key (public, shipped to every customer inside the SDK). The math is one-directional: the private key produces signatures the public key accepts; the public key, no matter how much you stare at it, cannot produce new signatures. This isn't an implementation detail — it's the entire point of the scheme.
Property 2 — verification needs no secret. This is the property that makes offline licensing possible. The SDK doesn't have to phone home, doesn't need a network connection at all. It just runs verify(signature, message, public_key) locally and trusts the answer, because the math guarantees no attacker without the private key can have produced that signature.
Property 3 — the attacker model is sharply bounded. With full read access to libttvfacesdk.so, every byte of the disassembly, every model file, every constant in .rodata — the attacker still cannot forge a license. Why? Because the binary only contains the public key. The private key is on a server in some Cryptolens datacentre and never left it. To forge signatures, the attacker would need to either steal the private key (out of scope) or solve RSA-2048 (factor a 2048-bit integer in your spare time — go ahead).
So far, so good. The binary can be completely open and the scheme still holds. The vendor doesn't need to hide the cryptographic operations, the API calls, the algorithm choices, or even the public key itself. Pure cryptography is doing the work.
So why do we get six layers of hiding?
Because there's exactly one attack left in the model: replace the public key. Swap in a public key for which you hold the private key, and the SDK happily verifies any license you sign. The cryptography hasn't been broken — the SDK has been redirected to trust your keypair instead of the vendor's. The scheme didn't fail; it was reparented.
Now the protection model makes sense. The public key's secrecy is irrelevant — it's marked "public" for a reason. Its integrity inside the shipped binary is what holds the whole thing together. Every trick in the rest of this post is about making the modulus harder to locate, because once an attacker locates 256 bytes of base64 in .rodata, swapping them for their own modulus is a one-line patch and the whole scheme collapses. Hiding the key isn't paranoia. It's the only attack surface left.
A few more cryptography details, because they come up later:
-
PKCS#1 v1.5 vs PSS padding. RSA on its own only works on numbers smaller than the modulus, so the signed data needs to be wrapped in a padding scheme. PKCS#1 v1.5 is the old one — deterministic, simpler, no security proof but also no known attacks in the signing direction. RSA-PSS is the modern one — randomised, formally proven, what you'd pick for a new design today. The SDK here uses PKCS#1 v1.5, which is fine in this context and turns out to be helpful for the reverse engineer because the encoding is bit-exact and verifiable by hand.
-
Why hash first. RSA can only sign numbers smaller than its modulus, but real messages can be any size. The fix: hash the message first (here, SHA-256), then sign the 32-byte hash. The signature ends up with a fixed internal structure — a DigestInfo ASN.1 wrapper carrying the hash algorithm's OID and the hash bytes, padded out with
00 01 ff ff … ff 00to fill the 256-byte RSA block. We'll meet this structure for real later, when raw-decrypting a signature turns out to be the cleanest way to figure out which bytes the SDK actually signed. -
e = 65537, also known asAQAB. The public exponent of nearly every RSA key in the world is 65537 (0x10001), the Fermat prime F4. It's prime, small enough for fast verification, but big enough to dodge low-exponent attacks. It's so universally 65537 that some crypto libraries don't even let you choose. In base64, the three bytes01 00 01encode toAQAB— a four-character string you'll see in essentially any modern RSA key dump, and a fingerprint you'll learn to spot in reverse engineering very quickly. -
Why 2048 bits. Long enough that no public factoring attack threatens it, short enough that verification is microsecond-fast even on a phone. 2048 is the conservative-but-sensible choice for anything signed today. (3072 if you're paranoid; 4096 if you really are.) A 2048-bit modulus is exactly 256 bytes, which is why the 256-byte signature we noticed at the top is such a strong identifier.
With the crypto in hand, the whole post simplifies to one sentence:
The SDK has to keep a 256-byte modulus around in a way that's easy for it to read but hard for an attacker to replace.
How does it actually do that? Stack of six tricks. Let's start at the outside and work in.
What's in the box
Before diving into the .so, it's worth describing what the repository actually ships, because the layout itself tells you something about the threat model.
The Face-Recognition-SDK-Android repo is a standard Android Studio project: a thin Kotlin/Java demo app on top of a binary AAR that contains all the real code. The top-level layout looks like:
Face-Recognition-SDK-Android/
├── app/
│ ├── build.gradle # applicationId "com.ttv.facedemo"; depends on libs/ttvface.aar
│ ├── src/main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/com/ttv/facerecog/ # demo app sources (Kotlin + Java)
│ │ │ ├── MainActivity.kt # entry point; calls FaceEngine.setActivation() with a hardcoded license JSON
│ │ │ ├── CameraActivity.kt # camera preview + on-screen face overlay
│ │ │ ├── UserActivity.kt # enroll/list users
│ │ │ ├── DBHelper.java # SQLite store for face embeddings
│ │ │ ├── FaceRectView.java # drawing of detection boxes
│ │ │ └── … # FaceEntity, Utils, UsersAdapter, etc.
│ │ └── res/ # layouts, drawables, strings
│ └── libs/
│ └── ttvface.aar # ← the actual SDK (binary; everything interesting is here)
├── build.gradle
├── README.md
└── settings.gradleFace-Recognition-SDK-Android/
├── app/
│ ├── build.gradle # applicationId "com.ttv.facedemo"; depends on libs/ttvface.aar
│ ├── src/main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/com/ttv/facerecog/ # demo app sources (Kotlin + Java)
│ │ │ ├── MainActivity.kt # entry point; calls FaceEngine.setActivation() with a hardcoded license JSON
│ │ │ ├── CameraActivity.kt # camera preview + on-screen face overlay
│ │ │ ├── UserActivity.kt # enroll/list users
│ │ │ ├── DBHelper.java # SQLite store for face embeddings
│ │ │ ├── FaceRectView.java # drawing of detection boxes
│ │ │ └── … # FaceEntity, Utils, UsersAdapter, etc.
│ │ └── res/ # layouts, drawables, strings
│ └── libs/
│ └── ttvface.aar # ← the actual SDK (binary; everything interesting is here)
├── build.gradle
├── README.md
└── settings.gradleThe demo app under app/src/main/java/com/ttv/facerecog/ is small and unobfuscated — Kotlin activities for the camera and user list, a SQLite helper for storing face embeddings, a few drawing helpers, and a MainActivity that wires it all together. Reading it gives you the surface API and nothing else; all the protection lives inside ttvface.aar.
### Cracking open the AAR
An AAR is a zip with a fixed layout. Unzipping ttvface.aar gives:
ttvface/
├── AndroidManifest.xml # declares package "com.ttv.face", minSdk 21
├── classes.jar # only ~2 KB - JNI bridge classes
├── jni/
│ ├── arm64-v8a/ # 7 .so files
│ │ ├── libttvfacesdk.so # ← the target: SDK logic + license check (UPX-packed)
│ │ ├── libMNN.so # Alibaba MNN inference framework
│ │ ├── libncnn.so # Tencent ncnn inference framework
│ │ ├── libopencv_core.so
│ │ ├── libopencv_imgcodecs.so
│ │ ├── libopencv_imgproc.so
│ │ └── libtensorflowlite_c.so
│ └── armeabi-v7a/ # same set of 7 .so files for 32-bit ARM
├── assets/
│ ├── detection/ # ncnn-format face detector (detection.bin + .param)
│ ├── face_modeling/ # ncnn landmark predictor (landmark.bin + .param)
│ ├── face_recognize/ # ncnn embedding model (recognize.bin + .param)
│ ├── live/ # liveness model (live.bin)
│ └── ocu/ # MNN-format model (ocu.mnn) - occlusion check
├── META-INF/
├── proguard.txt # empty
└── R.txt # emptyttvface/
├── AndroidManifest.xml # declares package "com.ttv.face", minSdk 21
├── classes.jar # only ~2 KB - JNI bridge classes
├── jni/
│ ├── arm64-v8a/ # 7 .so files
│ │ ├── libttvfacesdk.so # ← the target: SDK logic + license check (UPX-packed)
│ │ ├── libMNN.so # Alibaba MNN inference framework
│ │ ├── libncnn.so # Tencent ncnn inference framework
│ │ ├── libopencv_core.so
│ │ ├── libopencv_imgcodecs.so
│ │ ├── libopencv_imgproc.so
│ │ └── libtensorflowlite_c.so
│ └── armeabi-v7a/ # same set of 7 .so files for 32-bit ARM
├── assets/
│ ├── detection/ # ncnn-format face detector (detection.bin + .param)
│ ├── face_modeling/ # ncnn landmark predictor (landmark.bin + .param)
│ ├── face_recognize/ # ncnn embedding model (recognize.bin + .param)
│ ├── live/ # liveness model (live.bin)
│ └── ocu/ # MNN-format model (ocu.mnn) - occlusion check
├── META-INF/
├── proguard.txt # empty
└── R.txt # emptyA few things to call out:
-
The native lib is the brains.
libttvfacesdk.sois ~2 MB on arm64 and ~1.7 MB on arm32. The other six.sofiles (MNN, ncnn, OpenCV, TFLite) are standard open-source inference runtimes; they're not custom code and they're not protected. Replacing or rebuilding them wouldn't help an attacker because the actual face-recognition pipeline (and the license check) lives inlibttvfacesdk.so. -
The models are plain files. The
assets/directory has unencrypted ncnn and MNN model files. The vendor isn't trying to protect the AI — those formats have well-known parsers and could be loaded into any inference runtime. The protection effort is entirely focused on the licensing. -
classes.jaris tiny. Just two classes —FaceEngineandFaceResult.FaceEngineis aContextWrapperexposing the JNI surface; every real method isnative. That's the entirety of the Java-visible API:
public class com.ttv.face.FaceEngine extends android.content.ContextWrapper {
public static final int F_OK;
public static final int F_LICENSE_KEY_ERROR;
public static final int F_LICENSE_APPID_ERROR;
public static final int F_LICENSE_EXPIRED;
public static final int F_INIT_ERROR;
public native int setActivation(java.lang.String);
public native int init();
public native int uninit();
public native java.util.List<FaceResult> detectFaceFromBitmap(android.graphics.Bitmap);
public native java.util.List<FaceResult> detectFaceFromYuv(byte[], int, int, int);
public native int extractFeatureFromBitmap(android.graphics.Bitmap, java.util.List<FaceResult>);
public native int extractFeatureFromYuv(byte[], int, int, int, java.util.List<FaceResult>);
public native float compareFeature(byte[], byte[]);
} public class com.ttv.face.FaceEngine extends android.content.ContextWrapper {
public static final int F_OK;
public static final int F_LICENSE_KEY_ERROR;
public static final int F_LICENSE_APPID_ERROR;
public static final int F_LICENSE_EXPIRED;
public static final int F_INIT_ERROR;
public native int setActivation(java.lang.String);
public native int init();
public native int uninit();
public native java.util.List<FaceResult> detectFaceFromBitmap(android.graphics.Bitmap);
public native java.util.List<FaceResult> detectFaceFromYuv(byte[], int, int, int);
public native int extractFeatureFromBitmap(android.graphics.Bitmap, java.util.List<FaceResult>);
public native int extractFeatureFromYuv(byte[], int, int, int, java.util.List<FaceResult>);
public native float compareFeature(byte[], byte[]);
}The five error constants are the closest thing to a written threat model the SDK gives you. F_LICENSE_KEY_ERROR is the signature-failed branch, F_LICENSE_APPID_ERROR is the package-name-mismatch branch (and explains why the captured license payload carries "ActivatedMachines": [{"Mid": "com.ttv.facedemo", …}] — the SDK binds licenses to the calling app's package), and F_LICENSE_EXPIRED is the date check. The protection layers documented in the rest of this post all exist to make the path from setActivation(…) to F_OK hard to forge.
With that map in hand, the question reduces to: somewhere inside libttvfacesdk.so there's an RSA public key the setActivation native function uses to verify the license JSON. Where is it, and what's stopping us from finding it?
The answer turns out to be six stacked layers, in roughly increasing order of cleverness. The first one isn't even meant to be a layer — it just happens to be one.
— -
## Layer 1: hide in plain sight (static OpenSSL)
The library is a stripped ELF shared object. The natural first move is to look at the strings table and the dynamic symbol table.
$ file libttvfacesdk.so
libttvfacesdk.so: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV),
dynamically linked, stripped
$ nm -D libttvfacesdk.so | grep -E "RSA_verify|d2i_RSA|PEM_read|EVP_DigestVerify"
00000000000d6140 T RSA_verify
00000000000ccf5c T EVP_DigestVerifyInit
00000000000cd124 T EVP_DigestVerifyFinal
0000000000127934 T d2i_RSAPublicKey
000000000017b080 T d2i_RSA_PUBKEY
0000000000198bdc T PEM_read_bio_RSAPublicKey
0000000000198cbc T PEM_read_bio_RSA_PUBKEY$ file libttvfacesdk.so
libttvfacesdk.so: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV),
dynamically linked, stripped
$ nm -D libttvfacesdk.so | grep -E "RSA_verify|d2i_RSA|PEM_read|EVP_DigestVerify"
00000000000d6140 T RSA_verify
00000000000ccf5c T EVP_DigestVerifyInit
00000000000cd124 T EVP_DigestVerifyFinal
0000000000127934 T d2i_RSAPublicKey
000000000017b080 T d2i_RSA_PUBKEY
0000000000198bdc T PEM_read_bio_RSAPublicKey
0000000000198cbc T PEM_read_bio_RSA_PUBKEYOpenSSL is statically linked, and every key-loading entry point is present — RSA_verify, both d2i_RSA* decoders, both PEM_read_* decoders, the high-level EVP_DigestVerify* family. So the symbol table alone tells you almost nothing about which API the SDK actually uses. That's the first protection layer working as intended: a casual reverse engineer can't follow d2i_RSA_PUBKEY cross-references to find a hardcoded DER blob, because they don't know if that function is even called.
Looking directly for an embedded key:
import re
d = open('libttvfacesdk.so','rb').read()
for sig, label in [
(bytes.fromhex('2a864886f70d010101'), 'rsaEncryption OID'),
(b'-----BEGIN', 'PEM BEGIN'),
(bytes.fromhex('30820122'), 'SPKI 2048 prefix'),
(bytes.fromhex('3082010a'), 'PKCS1 2048 prefix'),
]:
hits = [m.start() for m in re.finditer(re.escape(sig), d)]
if hits:
print(f'{label}: {hits[:5]}')import re
d = open('libttvfacesdk.so','rb').read()
for sig, label in [
(bytes.fromhex('2a864886f70d010101'), 'rsaEncryption OID'),
(b'-----BEGIN', 'PEM BEGIN'),
(bytes.fromhex('30820122'), 'SPKI 2048 prefix'),
(bytes.fromhex('3082010a'), 'PKCS1 2048 prefix'),
]:
hits = [m.start() for m in re.finditer(re.escape(sig), d)]
if hits:
print(f'{label}: {hits[:5]}')Output: nothing. No PEM blocks, no DER-encoded keys, not even the rsaEncryption OID anywhere in the file. That's already suspicious — the OID alone should appear if a DER-encoded key is embedded.
Disassembling at the address nm reported for RSA_verify:
$ python3 -c "
import struct
d = open('libttvfacesdk.so','rb').read()
for i in range(0, 32, 4):
print(hex(struct.unpack_from('<I', d, 0xd6140+i)[0]))
"
0xe90f6eb6
0x131c8feb
0xcb1ef44b
…
$ python3 -c "
import struct
d = open('libttvfacesdk.so','rb').read()
for i in range(0, 32, 4):
print(hex(struct.unpack_from('<I', d, 0xd6140+i)[0]))
"
0xe90f6eb6
0x131c8feb
0xcb1ef44b
…Those aren't valid ARM64 instructions. There are also zero NOP (D503201F) instructions in the entire 2 MB file, and only a handful of RET instructions clustered at the very end of the executable segment. A real 2 MB ARM64 library has thousands of each. Something is wrapping the real code.
## Layer 2: where did the code go? (UPX packing)
Scanning for known packer signatures near that cluster of RETs turned up the giveaway:
$ strings -a libttvfacesdk.so | grep -i upx
$Info: This file is packed with the UPX executable packer http://upx.sf.net $
$Id: UPX 3.96 Copyright © 1996–2019 the UPX Team. All Rights Reserved.$
$ strings -a libttvfacesdk.so | grep -i upx
$Info: This file is packed with the UPX executable packer http://upx.sf.net $
$Id: UPX 3.96 Copyright © 1996–2019 the UPX Team. All Rights Reserved.$So it's UPX 3.96. Easy fix, right?
$ upx -d libttvfacesdk_arm64.so
upx: libttvfacesdk_arm64.so: NotPackedException: not packed by UPX
$ upx -d libttvfacesdk_arm32.so
upx: libttvfacesdk_arm32.so: CantUnpackException: MemBuffer invalid array index -20$ upx -d libttvfacesdk_arm64.so
upx: libttvfacesdk_arm64.so: NotPackedException: not packed by UPX
$ upx -d libttvfacesdk_arm32.so
upx: libttvfacesdk_arm32.so: CantUnpackException: MemBuffer invalid array index -20
Two different failures — that's layers 3 and 4 of the protection stack.
**## Layers 3 & 4: break the tools, not the format**
A grep for the `UPX!` magic explains both failures:
```bash
# arm64 file: 0 occurrences of "UPX!" - magic deliberately wiped
# arm32 file: 3 occurrences, last one at file offset 0x197b0c (the real PackHeader)
Two different failures — that's layers 3 and 4 of the protection stack.
**## Layers 3 & 4: break the tools, not the format**
A grep for the `UPX!` magic explains both failures:
```bash
# arm64 file: 0 occurrences of "UPX!" - magic deliberately wiped
# arm32 file: 3 occurrences, last one at file offset 0x197b0c (the real PackHeader)The arm64 build had its UPX! magic byte string zeroed out so UPX outright refuses to recognise it. The arm32 build kept the magic but tampered with a header field somewhere, which is enough to make modern UPX 5.x abort with a buffer-index check.
This kind of tampering shows up a lot in shipping Android libraries. The reasoning is pragmatic: the vendor isn't trying to defeat a determined RE — that's hopeless against UPX, since the format is open and stubs are well-understood — they're just trying to defeat off-the-shelf tooling. upx -d is the first command anyone tries; if that fails, a lot of people give up. Cost to the vendor: about ten bytes of header edits in a post-build script. Cost to a casual attacker: looks like a brick wall.
Cost to a slightly-less-casual attacker: a small detour. Homebrew ships UPX 5.1.1, which is too strict for the tampered arm32 header. There's no prebuilt UPX 3.96 for macOS either. So:
## …so we build our own UPX
UPX 3.96 needs the UCL compression library:
$ brew install ucl
$ curl -fsSLO https://github.com/upx/upx/releases/download/v3.96/upx-3.96-src.tar.xz
$ tar -xf upx-3.96-src.tar.xz && cd upx-3.96-src
$ CPPFLAGS="-I$(brew --prefix ucl)/include" \
LDFLAGS="-L$(brew --prefix ucl)/lib" \
CXXFLAGS_WERROR="-Wno-misleading-indentation -Wno-error" \
make -C src all$ brew install ucl
$ curl -fsSLO https://github.com/upx/upx/releases/download/v3.96/upx-3.96-src.tar.xz
$ tar -xf upx-3.96-src.tar.xz && cd upx-3.96-src
$ CPPFLAGS="-I$(brew --prefix ucl)/include" \
LDFLAGS="-L$(brew --prefix ucl)/lib" \
CXXFLAGS_WERROR="-Wno-misleading-indentation -Wno-error" \
make -C src allThat produces src/upx.out. Running it against the tampered arm32 file:
$ ./upx.out -d libttvfacesdk_arm32.so -o arm32_unpacked.so
File size Ratio Format Name
1995160 <- 1669936 83.70% linux/arm arm32_unpacked.so
Unpacked 1 file.$ ./upx.out -d libttvfacesdk_arm32.so -o arm32_unpacked.so
File size Ratio Format Name
1995160 <- 1669936 83.70% linux/arm arm32_unpacked.so
Unpacked 1 file.UPX 3.96 is more lenient than 5.x on the corrupted field and decompresses the payload anyway.
The arm64 build still won't unpack because its UPX! magic is gone. Restoring it by hand using the arm32 PackHeader as a template is straightforward but unnecessary — we already have the arm32 lib fully unpacked, and any key material will be byte-identical between the two builds.
So layers 2–4 are defeated. Total time once you know what you're looking at: maybe 20 minutes including the source build. The protection here isn't strong it's just strong enough to filter out anyone who isn't going to bother building a packer from source.
## Layer 5: the key that isn't a key
### Finally, a binary we can read
file and nm now show a normal shared object with real OpenSSL code, and disassembling RSA_verify returns sane Thumb-2:
0x88af8: push {r4, r5, r7, lr}
0x88afa: add r7, sp, #8
0x88afc: sub sp, #0x10
0x88afe: ldr.w ip, [r7, #0xc]
…
0x88af8: push {r4, r5, r7, lr}
0x88afa: add r7, sp, #8
0x88afc: sub sp, #0x10
0x88afe: ldr.w ip, [r7, #0xc]
…
Even better, the JNI bridge functions are exposed:
$ nm -D arm32_unpacked.so | grep ttv_face
000789f4 T Java_com_ttv_face_FaceEngine_init
00077a00 T Java_com_ttv_face_FaceEngine_setActivation
00077a00 T Java_com_ttv_face_FaceEngine_setActivation
…$ nm -D arm32_unpacked.so | grep ttv_face
000789f4 T Java_com_ttv_face_FaceEngine_init
00077a00 T Java_com_ttv_face_FaceEngine_setActivation
00077a00 T Java_com_ttv_face_FaceEngine_setActivation
…setActivation is exactly where the license check should be.
But re-running the original key scans against the unpacked binary still turns up nothing — no PEM, no SPKI, no rsaEncryption OID, no DER prefixes. The library is genuinely not storing the key in any of the standard forms. This is the most interesting protection layer of the lot, and the one I want to dwell on.
### The "AQAB" giveaway
Disassembling setActivation, one snippet jumped out:
0x77b3c: movs r0, #8 ; std::string SSO length byte (4 << 1)
0x77b3e: strb.w r0, [sp, #0x150]
0x77b42: movw r0, #0x5141 ; bytes 0x41 0x51 -> "AQ"
0x77b46: movt r0, #0x4241 ; bytes 0x41 0x42 -> "AB"
0x77b4a: str.w r0, [sp, #0x151] ; writes "AQAB" inline
0x77b3c: movs r0, #8 ; std::string SSO length byte (4 << 1)
0x77b3e: strb.w r0, [sp, #0x150]
0x77b42: movw r0, #0x5141 ; bytes 0x41 0x51 -> "AQ"
0x77b46: movt r0, #0x4241 ; bytes 0x41 0x42 -> "AB"
0x77b4a: str.w r0, [sp, #0x151] ; writes "AQAB" inline
That builds a 4-character std::string literal "AQAB" on the stack. AQAB is the base64 encoding of \x01\x00\x01 — the RSA public exponent 65537 — and it's the standard exponent string in Cryptolens public keys.
The protection trick here is elegant in its smallness: by encoding the exponent into the immediate operands of two move instructions, the bytes 01 00 01 never appear contiguously in the file. Anyone searching .rodata for the literal exponent bytes — a very common reverse-engineering shortcut — finds nothing. And as far as the C++ source code is concerned, the developer just wrote std::string e = "AQAB";. There's no special obfuscation work; the compiler's standard short-string-optimisation does the hiding for free.
So the exponent is hidden in the instruction stream. The modulus has to be somewhere else as data — but stored how?
### Now find 256 bytes of base64 in a 2 MB haystack
If the exponent is base64, the modulus probably is too. A Cryptolens public key is published as a JSON-ish {n, e} pair, both base64-encoded. The modulus of a 2048-bit key encodes to exactly 344 base64 characters with == padding (256 raw bytes).
So: scan the binary for long runs of base64 characters.
b64set = set(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=")
runs = []
i = 0
while i < len(d):
if d[i] in b64set:
j = i
while j < len(d) and d[j] in b64set: j += 1
if j - i >= 80: runs.append((i, j-i, d[i:j]))
i = j
else:
i += 1
runs.sort(key=lambda x: -x[1])
b64set = set(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=")
runs = []
i = 0
while i < len(d):
if d[i] in b64set:
j = i
while j < len(d) and d[j] in b64set: j += 1
if j - i >= 80: runs.append((i, j-i, d[i:j]))
i = j
else:
i += 1
runs.sort(key=lambda x: -x[1])
The longest run, at vaddr 0x1ae8f0, length 344 characters:
ug0MPrCLNSeiq06bCjIq3r8ZDPOyql8dpZCtFngeBaRXcTGYMig3fUPSLKQUzmZ0
BJ//1sbSt5anBrscRikl3pPlS+wqs3F0BLrmyRz9UT2EqwxEaHfwlEw2ph+u9Phw
Pfn1J69KseqAQPPZd8A8UvA3feCgniSAVEN+IiJheU4YN0dj3JJoyDknG8TAWjDv
fxl2HB+7rcaF+AqRX5DndF1UZzKAXX/YFaFS4Rf/rC7RgXNW5PBvVna+FFjj+HgE
mTVafJQTNish5Y6hulmCIKTJ7e1/vAnx7Y/SPTHpEyNwCb8uscY0f8L4ApJxp7US
nXAv4J/JXdDGCe37D2HYGQ==ug0MPrCLNSeiq06bCjIq3r8ZDPOyql8dpZCtFngeBaRXcTGYMig3fUPSLKQUzmZ0
BJ//1sbSt5anBrscRikl3pPlS+wqs3F0BLrmyRz9UT2EqwxEaHfwlEw2ph+u9Phw
Pfn1J69KseqAQPPZd8A8UvA3feCgniSAVEN+IiJheU4YN0dj3JJoyDknG8TAWjDv
fxl2HB+7rcaF+AqRX5DndF1UZzKAXX/YFaFS4Rf/rC7RgXNW5PBvVna+FFjj+HgE
mTVafJQTNish5Y6hulmCIKTJ7e1/vAnx7Y/SPTHpEyNwCb8uscY0f8L4ApJxp7US
nXAv4J/JXdDGCe37D2HYGQ==It decodes to exactly 256 bytes. The high bit of the first byte (0xba) is set, which is what you'd expect of a full-width 2048-bit unsigned-integer modulus.
The data region around it contains other SDK string constants (face_modeling/landmark.param, face_modeling/landmark.bin), confirming this is part of the SDK's own .rodata, not OpenSSL's.
Another nearby string sealed the identification:
$ strings arm32_unpacked.so | grep cryptolens
ttvfacedemo/TTVFaceRecog/ttvface/src/main/cpp/cryptolens/include/cryptolens/imports/std/
$ strings arm32_unpacked.so | grep cryptolens
ttvfacedemo/TTVFaceRecog/ttvface/src/main/cpp/cryptolens/include/cryptolens/imports/std/
That's a leftover compilation path from a developer machine — the SDK uses the Cryptolens C++ client. Worth noting as its own minor finding: stripped binaries still leak the developer's source tree, because __FILE__ macros, assert messages, and exception-throwing code all bake the full path into .rodata. A determined vendor strips those too; this one didn't.
So the modulus is hidden in plain sight as plausible-looking junk text. The trick relies on a base64-encoded RSA modulus looking exactly like a base64-encoded model file, asset checksum, or any other binary blob — which there are many of in a face-recognition SDK. There's no signature for the scanner to match. The only thing that gives it away is length: 344 characters with == padding is almost certainly a 2048-bit number.
### Putting the key back together
Wrapping the modulus + AQAB exponent in a SubjectPublicKeyInfo:
n = int.from_bytes(base64.b64decode(mod_b64), "big")
e = 65537
# … encode (n, e) as DER SPKI …
n = int.from_bytes(base64.b64decode(mod_b64), "big")
e = 65537
# … encode (n, e) as DER SPKI …
Verifying with OpenSSL:
$ openssl rsa -pubin -in ttvface_pubkey.pem -text -noout
Public-Key: (2048 bit)
Modulus:
00:ba:0d:0c:3e:b0:8b:35:27:a2:ab:4e:9b:0a:32:
2a:de:bf:19:0c:f3:b2:aa:5f:1d:a5:90:ad:16:78:
...
Exponent: 65537 (0x10001)
$ openssl rsa -pubin -in ttvface_pubkey.pem -text -noout
Public-Key: (2048 bit)
Modulus:
00:ba:0d:0c:3e:b0:8b:35:27:a2:ab:4e:9b:0a:32:
2a:de:bf:19:0c:f3:b2:aa:5f:1d:a5:90:ad:16:78:
...
Exponent: 65537 (0x10001)
Done — clean 2048-bit RSA public key.
## Layer 6: right key, wrong bytes (the accidental layer)
### Sanity check: does it actually verify a real license?
A real activation response from the SDK looks like this:
{
"licenseKey": "eyJQcm9kdWN0SWQiOjIwNzg1...", // base64-encoded JSON
"signature": "DzA6tC4ByQqtmJvL...", // base64-encoded RSA signature
"result": 0
}{
"licenseKey": "eyJQcm9kdWN0SWQiOjIwNzg1...", // base64-encoded JSON
"signature": "DzA6tC4ByQqtmJvL...", // base64-encoded RSA signature
"result": 0
}The obvious first try — the .NET Cryptolens client signs the base64 string directly:
$ openssl dgst -sha256 -verify ttvface_pubkey.pem -signature sig.bin licenseKey_base64.txt
Verification failure$ openssl dgst -sha256 -verify ttvface_pubkey.pem -signature sig.bin licenseKey_base64.txt
Verification failureFails. So either the key is wrong, or the signed bytes aren't what I assumed.
Rather than guess, decrypt the signature with the public key directly (raw RSA: s^e mod n) and read what comes out:
0001ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
... (PKCS#1 v1.5 padding)
ffffffffffffffffffffffff00 3031300d060960864801650304020105000420
a5d238b3f9560e2b5c6710b5fa0d2ad46239276b83bb68f7938a1efcb9b6cf8d0001ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
... (PKCS#1 v1.5 padding)
ffffffffffffffffffffffff00 3031300d060960864801650304020105000420
a5d238b3f9560e2b5c6710b5fa0d2ad46239276b83bb68f7938a1efcb9b6cf8dThat's a textbook EMSA-PKCS1-v1_5 envelope. The DigestInfo prefix 3031300d0609608648016503040201 is SHA-256, and the trailing 32 bytes are the expected hash:
a5d238b3f9560e2b5c6710b5fa0d2ad46239276b83bb68f7938a1efcb9b6cf8d.
Now hash a few candidate inputs and look for a match:
Now hash a few candidate inputs and look for a match:
-
licenseKeybase64 string (UTF-8) — ❌ -
licenseKeybase64 string +\n— ❌ -
licenseKeyUTF-16LE — ❌ -
decoded JSON bytes of
licenseKey— ✅
Confirmed with OpenSSL:
$ openssl dgst -sha256 -verify ttvface_pubkey.pem -signature sig.bin decoded_json.bin
Verified OK$ openssl dgst -sha256 -verify ttvface_pubkey.pem -signature sig.bin decoded_json.bin
Verified OK### Two SDKs, two answers (the C++/.NET divergence)
This is the bit worth flagging for anyone else reverse-engineering a Cryptolens-protected product.
The .NET Cryptolens SDK (LicenseKey.HasValidSignature) verifies:
rsa.VerifyData(
Encoding.UTF8.GetBytes(this.RawResponse.LicenseKey), // the base64 string itself
Convert.FromBase64String(this.RawResponse.Signature),
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)rsa.VerifyData(
Encoding.UTF8.GetBytes(this.RawResponse.LicenseKey), // the base64 string itself
Convert.FromBase64String(this.RawResponse.Signature),
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)The C++ Cryptolens client signs over the decoded license-key JSON bytes:
// pseudocode of what the verifier does
auto message = base64::decode(response.licenseKey);
verifier.verify(message, response.signature, public_key);// pseudocode of what the verifier does
auto message = base64::decode(response.licenseKey);
verifier.verify(message, response.signature, public_key);Same key on the server, but the bytes the signature covers are different. A third-party tool that copies the .NET formula and tries to validate a Cryptolens-C++ response will reliably fail — and fail in a way that looks identical to "wrong public key", which is what threw me off until I raw-decrypted the signature.
This isn't really an intentional protection layer — it's more like accidental obscurity arising from two SDKs in the same product family making different choices. But the practical effect is the same as a real protection: it eats time and makes the attacker doubt their own results. Worth noting that "accidental obscurity from SDK fragmentation" is a category of defence on its own, and a very cheap one for a vendor to benefit from passively.
## What we walked away with
-
Public key extracted:
ttvface_pubkey.pem(RSA-2048,e=65537). -
Signature on a real
setActivationresponse: cryptographically valid, signed by Cryptolens for ProductId 20785. -
License contents are decoded and inspectable offline.
-
The expiry on the captured license has already passed (Jan 2024), so the SDK rejects it on its own date check — but that's an SDK-side policy, not a signature problem.
The original goal — locate the public key — is met.
## Six cheap tricks, one solid afternoon-proof wall
Stacking the six protection layers side by side, the picture is clear. Here's each one with what it actually does and what it costs an attacker who walks in cold:
1. Static OpenSSL link.
Hides which crypto APIs are used internally by drowning them in hundreds of public symbols.
→ Attacker cost: minutes — still inferable from runtime behaviour.
2. UPX packing.
Compresses and encrypts code/data at rest; only the unpacker stub is visible to static analysis.
→ Attacker cost: seconds with upx -d — if it works.
3. UPX! magic stripping.
Zeroes the four-byte magic so upx -d refuses to recognise the file as packed.
→ Attacker cost: minutes — restore the magic, or unpack the sibling architecture instead.
4. PackHeader field tampering.
Corrupts a non-essential header field to trip modern UPX 5.x's stricter validation.
→ Attacker cost: ~15 minutes — build UPX 3.96 from source; older versions are more lenient.
5. Split key storage (base64 modulus + immediate-move exponent).
Stores the modulus as plausible-looking base64 in .rodata, builds the exponent at runtime from movw/movt immediates so the bytes 01 00 01 never appear contiguously.
→ Attacker cost: ~30 minutes — recognise the AQAB tell, scan for long base64 runs.
6. Cryptolens C++ vs .NET signing divergence.
Accidental, not designed — but third-party validators that copy the .NET formula fail in a way that looks identical to "wrong public key".
→ Attacker cost: ~30 minutes — raw-decrypt a signature to identify what's actually being signed.
Total: a couple of hours for someone who knows what they're doing. Each layer on its own is borderline trivial; stacked, they're enough to defeat probably 95%+ of casual analysis, which is the bar most vendors are aiming at anyway. Nobody serious about protection thinks UPX will stop a determined RE — but UPX plus magic stripping plus a non-standard key encoding plus a non-standard signing scheme will absolutely stop the kind of analysis you can do in an afternoon between meetings.
The general lesson, which generalises beyond this SDK: defence-in-depth at the cost-effective end of the spectrum is real. You don't need VMprotect-grade obfuscation to make a binary expensive to attack. You need a stack of cheap tricks, each of which forces the attacker to either build custom tooling or reason about a non-obvious encoding. The cost to the vendor is essentially zero; the cost to the attacker is a multiplicative chain of small detours that filters out everyone except the people who genuinely want it.
The flip side, for vendors: none of this stops a motivated attacker. The key is sitting in .rodata in plaintext base64, in a packer format whose unpacker stub is also in the binary itself. There's nothing here that a focused person can't get through in an afternoon. If your threat model includes anyone willing to spend an afternoon, you need a different design — server-side license verification with no local key would be the obvious one. Local-only verification with an embedded public key is, fundamentally, a deterrent and not a guarantee.
— -
## The one step I'm not taking — and why
The obvious next move: replace the 344-byte base64 modulus with one whose private key I hold, and sign my own licenses forever. The patch is mechanical — same offset, same length, no surrounding bytes to fix up.
I'm not doing it, and I'm not publishing the recipe. Demonstrating that protection is weak isn't the same action as defeating it; this post is the former. Once a patched .so or a byte-patch recipe is on the internet, it stops being research and starts being a crack.
— -
## TL;DR
A reverse-engineering case study on a real shipping Android SDK, working through six stacked protection layers:
-
Layer 1 — static OpenSSL link. Every key-loading API is exported, so the symbol table reveals nothing about which one is actually used.
-
Layer 2 — UPX packing.
libttvfacesdk.sois UPX 3.96-packed; the real.text/.rodatais compressed at rest. -
Layers 3 & 4 — UPX header tampering. The
UPX!magic is wiped on arm64; the arm32 PackHeader has a corrupted field. Both break stockupx -d. Build UPX 3.96 from source (brew install ucl, thenmake) and the arm32 build decompresses cleanly. -
Layer 5 — split key storage. No PEM or DER anywhere. The modulus is a 344-character base64 string in
.rodataat vaddr0x1ae8f0. The matching exponentAQABis assembled at runtime bymovw r0,#0x5141; movt r0,#0x4241, so the bytes01 00 01never appear contiguously in the binary. -
Layer 6 — non-standard signing scheme. The Cryptolens C++ client this SDK uses signs over the decoded JSON of
licenseKey, not the base64 wrapper string the .NET SDK signs. Third-party validators that follow the .NET formula fail in a way indistinguishable from "wrong key". -
Total cost to defeat: a few hours. Each layer is borderline trivial; stacked, they filter out the vast majority of casual analysis at near-zero vendor cost. The general lesson is that defence-in-depth works at the cheap end of the spectrum too, but local-only license verification with an embedded key is fundamentally a deterrent, not a guarantee.
-
Patching the modulus would mint unlimited valid licenses. Not doing it, not publishing it. Demonstrating weak protection is not the same action as defeating it.