
Reverse Engineering the Renpho Health API
The Problem
I have a Renpho smart scale that measures weight, body fat, muscle mass, and about a dozen other metrics. The data syncs to their mobile app, but I wanted it in my own fitness tracking application - a Laravel app I built to consolidate all my health data.
"Easy," I thought. "I'll just use their API."
The legacy Renpho API is reasonably well-documented. There are Python libraries, community projects, even a Home Assistant integration. I implemented the service, tested it, and got back:
{ "status_code": "50000", "status_message": "Email was not registered" }
Turns out I'd registered with the Renpho Health app (the new version), which uses an entirely different backend at cloud.renpho.com instead of the legacy renpho.qnclouds.com. My account simply doesn't exist on the old system.
No documentation. No community projects. No way forward except to figure out what the new app is actually doing.
The Plan: Man-in-the-Middle
The classic approach to reverse engineering mobile APIs is straightforward:
- Set up a proxy (mitmproxy) on your computer
- Point your phone at the proxy
- Install the proxy's CA certificate on the phone
- Watch the traffic flow through
I spun up mitmproxy and configured my Android phone to route through it. HTTP traffic worked perfectly - I could see requests to example.com clear as day.
Then I opened the Renpho app.
"Network exception, please try again later"
Nothing in the proxy logs. The app refused to talk.
Certificate Pinning: The First Wall
Modern apps don't blindly trust any CA certificate your phone trusts. They pin specific certificates - either the server's exact certificate or a known CA - and reject anything else. My proxy was presenting its own certificate, and the app was having none of it.
The solution? Frida - a dynamic instrumentation toolkit that lets you inject JavaScript into running processes. With the right script, you can hook into the SSL verification functions and make them accept any certificate.
But there's a catch. Frida needs to run as root, or you need to patch the APK to include the Frida gadget. Since my daily driver phone isn't rooted, I went with option two.
Emulator Hell: A Brief Detour
Before pulling out my physical phone, I tried the emulator route because I thought it would be oh so convenient to do everything on my desktop.
Android Studio Emulator: Downloaded the APK from APKMirror, tried to install it, got slapped with:
INSTALL_FAILED_NO_MATCHING_ABIS: Failed to extract native libraries
The Renpho app includes native ARM libraries (arm64-v8a, armeabi-v7a) and the x86_64 emulator can't run them. The "ARM translation" feature only works for Java/Kotlin code, not native libraries. Dead end.
Waydroid: A container-based Android runtime for Linux. It actually uses your host kernel, so on an x86_64 machine, you need x86_64 binaries. But wait - there's libhoudini, an ARM translation layer!
I installed Waydroid, set up libhoudini, and the app actually ran. Progress! But when I tried to run Frida server inside the container... permission denied. SELinux disabled. Still permission denied. The LXC containerization was blocking execution of arbitrary binaries in /data/local/tmp/.
I patched the APK with the Frida gadget embedded, installed it, and could connect to Frida. But Java.available was always false - Frida couldn't access the Java VM, probably due to the ARM translation layer adding an extra level of indirection.
Four hours later, I admitted defeat and grabbed my physical phone.
The Physical Phone: Where Things Actually Work
The patched APK approach with a real phone is surprisingly smooth:
# Patch the APK with objection (Frida gadget gets embedded)
objection patchapk -s renpho.apk -a arm64-v8a
# Install on phone
adb install renpho.objection.apk
The gadget is configured to listen on port 27042. When the app starts, Frida is already running inside it. Connect from your computer and we're in business.
But here's the thing - I didn't even need the SSL pinning bypass. The Frida gadget gave me something better: direct access to the app's network layer.
Capturing Traffic: The SSL_write Hook
Instead of trying to intercept traffic at the TLS layer (which requires dealing with certificate pinning), I hooked SSL_write - the function that sends data over an encrypted connection. By the time data reaches SSL_write, it's still plaintext. TLS encryption happens inside that function.
Interceptor.attach(ssl_write_address, {
onEnter: function (args) {
var data = args[1].readUtf8String(args[2].toInt32());
if (data.indexOf('POST ') === 0 || data.indexOf('renpho') !== -1) {
console.log(data);
}
},
});
I logged into the app, poked around, and watched the requests flow through:
POST /renpho-aggregation/user/login HTTP/1.1
Host: cloud.renpho.com
Content-Type: application/json;charset=UTF-8
User-Agent: okhttp/4.9.0
{"encryptData":"fMlxsFnJjeNnFe4Tn45ENstJUNcpCjgd4jC6uoYlvnE="}
Wait. The request body is... encrypted?
The Encryption Layer: AES in the Wild
Every single request to the Renpho API wraps its payload in an encryptData field containing base64-encoded ciphertext. The response? Also encrypted (and gzip compressed for good measure).
I captured dozens of requests and started analysing patterns:
| Encrypted | Context |
|---|---|
b0LiByPBHmL5J19HjuIYDQ== | Appeared 19 times |
fMlxsFnJjeNnFe4Tn45ENstJUNcpCjgd4jC6uoYlvnE= | Login-related |
The first ciphertext appeared everywhere - probably an empty object {}. It's exactly 16 bytes decoded, which is one AES block. The same plaintext always produced the same ciphertext, which means no IV - this is ECB mode.
Why ECB mode is considered weak
ECB (Electronic Codebook) mode encrypts each block independently with the same key. This means identical plaintext blocks produce identical ciphertext blocks. You can sometimes see patterns in the encrypted data - the famous "ECB penguin" demonstrates this by showing how an image encrypted with ECB still reveals the outline of the original.
For API payloads like JSON, it's less visually obvious, but it does mean an attacker can identify when the same data is being sent, which leaks information.
That said, for a fitness app API, it's probably "good enough" to deter casual snooping.
Decompiling the APK
To understand the encryption, I decompiled the APK with apktool:
apktool d renpho.apk -o renpho-decompile
Searching through the smali bytecode, I found AESUtil.smali:
const-string v2, "AES/ECB/PKCS5Padding"
invoke-static {v2}, Ljavax/crypto/Cipher;->getInstance(Ljava/lang/String;)Ljavax/crypto/Cipher;
AES/ECB/PKCS5Padding - confirmed. The key is fetched from RenphoEncryptKey.getRenphoPassword(), which is... a native method. The key lives in compiled C++ code inside libnative-lib.so.
This is where things get interesting. The key isn't stored as a simple string in the binary - it's returned by a function. Searching for strings in the .so file turned up some candidates, but none of them worked.
After some investigation with Frida's native hooking capabilities, I was able to observe the key being used at runtime. The 16-character string that makes everything work.
Putting It Together
With the encryption sorted, implementing the Laravel service was straightforward:
private function encrypt(array $data): string
{
$json = json_encode($data);
$padded = $this->pkcs5Pad($json);
$encrypted = openssl_encrypt($padded, 'AES-128-ECB', $this->encryptionKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING);
return base64_encode($encrypted);
}
private function decrypt(string $encryptedBase64): array
{
$ciphertext = base64_decode($encryptedBase64);
$decrypted = openssl_decrypt($ciphertext, 'AES-128-ECB', $this->encryptionKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING);
return json_decode($this->pkcs5Unpad($decrypted), true);
}
The login endpoint returns a JWT token and user ID. Subsequent requests include these in headers:
$response = Http::withHeaders([
'token' => $this->token,
'userId' => $this->userId,
'appVersion' => '7.5.2',
'platform' => 'android',
// ... other headers
])->post($url, ['encryptData' => $this->encrypt($payload)]);
The measurements endpoint (/RenphoHealth/scale/queryAllMeasureDataList/) returns an array of body composition data:
{
"timeStamp": 1768119986,
"weight": 103.2,
"bmi": 26.9,
"bodyfat": 16.6,
"muscle": 53.8,
"water": 60.2,
"bone": 4.34,
"bmr": 2243,
"bodyage": 31
}
Now I have automatic sync running as a scheduled Laravel command. Fresh measurement appear in my dashboard once a week.
The Rabbit Holes
Why Do Apps Use Certificate Pinning?
Beyond preventing MITM attacks from malicious actors, pinning protects against:
- Corporate proxies that inspect HTTPS traffic
- Compromised CA certificates (remember DigiNotar?)
- Users who might want to... reverse engineer your API
It's a legitimate security measure. The arms race between app developers and researchers continues.
What's the Deal with Native Libraries?
Java bytecode (and its evolution, smali) is relatively easy to decompile and understand. Native code compiled to ARM or x86 is much harder. By putting sensitive logic - like encryption keys - in native libraries, developers add a layer of obfuscation.
It's not foolproof. Tools like Ghidra can decompile native code, and Frida can hook native functions at runtime. But it does raise the bar significantly.
Why ECB Mode Though?
I genuinely don't know. It's 2026. AES-GCM exists. CBC with a random IV exists. My best guess is that ECB was simpler to implement - no IV to transmit, no state to manage - and someone decided the security tradeoff was acceptable for a fitness app.
They're not wrong, exactly. The encryption does prevent casual interception of your body fat percentage. But it's a curious choice.
Lessons Learned
Emulators are great until they're not. Native ARM libraries on x86_64 hosts remain a pain point. If you're doing mobile security research, having a physical device is worth it.
SSL pinning isn't the end. Hooking at the right layer (before encryption, after decryption) often gives you what you need without fighting the TLS implementation.
Application-layer encryption adds complexity but isn't impenetrable. ECB mode in particular leaks information that helps analysis. The key has to live somewhere - either in the binary or fetched from a server - and "somewhere" is usually accessible with enough persistence.
AI is genuinely helpful for this kind of work. When you're staring at smali bytecode at 2 AM, being able to ask "what does this invoke-virtual instruction do" without feeling judged is invaluable.
Final Thoughts
This entire project took about a week of evenings. Most of that time was spent on dead ends - the emulator attempts, various Frida configurations that didn't work, and misunderstanding the payload structure.
The actual working solution is simple: patch APK, hook SSL_write, analyse traffic, implement client. The journey to get there was anything but.
Is this legally grey? Probably. I'm accessing my own data, from my own account, for personal use. But I wouldn't recommend publishing the encryption key or building a commercial product around this. Renpho could change their API tomorrow and break everything - or send a cease and desist.
For now, though, my scale talks to my Laravel app and I have access to my own data. And that's all I wanted.