[{"data":1,"prerenderedAt":1415},["ShallowReactive",2],{"i-nk:github":3,"i-nk:mail":8,"\u002Fblog\u002Freverse-engineering-renpho-health-api":11,"i-nk:copy":1413},{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":7},0,1024,false,"\u003Cg fill=\"currentColor\">\u003Cpath fill-rule=\"evenodd\"\r\n              clip-rule=\"evenodd\"\r\n              d=\"M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z\"\r\n              transform=\"scale(64)\" \u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":9,"height":9,"rotate":4,"vFlip":6,"hFlip":6,"body":10},24,"\u003Cg fill=\"currentColor\">\u003Cpath d=\"M1.5 8.67v8.58a3 3 0 003 3h15a3 3 0 003-3V8.67l-8.928 5.493a3 3 0 01-3.144 0L1.5 8.67z\" \u002F>\r\n        \u003Cpath d=\"M22.5 6.908V6.75a3 3 0 00-3-3h-15a3 3 0 00-3 3v.158l9.714 5.978a1.5 1.5 0 001.572 0L22.5 6.908z\" \u002F>\u003C\u002Fg>",{"id":12,"title":13,"body":14,"date":1392,"description":1393,"draft":6,"extension":1394,"extract":1395,"featuredImage":26,"mainTag":1396,"meta":1397,"navigation":301,"ogImage":1398,"path":1399,"readingTime":1400,"seo":1401,"stem":1402,"tags":1403,"__hash__":1412},"blog\u002Fblog\u002Freverse-engineering-renpho-health-api.md","Reverse Engineering the Renpho Health API: A Tale of Certificate Pinning, Emulator Hell, and Encrypted Payloads",{"type":15,"value":16,"toc":1374},"minimark",[17,28,33,38,41,44,47,92,108,111,115,118,134,141,144,149,152,156,163,174,177,181,184,190,218,229,235,242,253,256,260,263,321,324,327,331,341,528,531,605,608,612,619,622,662,672,691,695,701,721,727,744,754,761,764,768,771,1025,1028,1150,1157,1276,1279,1283,1288,1291,1303,1306,1310,1313,1316,1320,1323,1326,1330,1336,1342,1348,1354,1358,1361,1364,1367,1370],[18,19,20],"p",{},[21,22],"img",{"alt":23,"className":24,"src":26,"title":27},"Renpho Reverse Engineering",[25],"w-full","\u002Fimages\u002Fblog\u002Frenpho.webp","Reverse engineering the Renpho Health API",[29,30,32],"h1",{"id":31},"reverse-engineering-the-renpho-health-api","Reverse Engineering the Renpho Health API",[34,35,37],"h2",{"id":36},"the-problem","The Problem",[18,39,40],{},"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.",[18,42,43],{},"\"Easy,\" I thought. \"I'll just use their API.\"",[18,45,46],{},"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:",[48,49,54],"pre",{"className":50,"code":51,"language":52,"meta":53,"style":53},"language-json shiki shiki-themes github-light-high-contrast ayu-dark","{ \"status_code\": \"50000\", \"status_message\": \"Email was not registered\" }\n","json","",[55,56,57],"code",{"__ignoreMap":53},[58,59,62,66,70,74,78,81,84,86,89],"span",{"class":60,"line":61},"line",1,[58,63,65],{"class":64},"sbKop","{ ",[58,67,69],{"class":68},"siBZp","\"status_code\"",[58,71,73],{"class":72},"s8M_Z",":",[58,75,77],{"class":76},"s4OvH"," \"50000\"",[58,79,80],{"class":72},",",[58,82,83],{"class":68}," \"status_message\"",[58,85,73],{"class":72},[58,87,88],{"class":76}," \"Email was not registered\"",[58,90,91],{"class":64}," }\n",[18,93,94,95,99,100,103,104,107],{},"Turns out I'd registered with the ",[96,97,98],"strong",{},"Renpho Health"," app (the new version), which uses an entirely different backend at ",[55,101,102],{},"cloud.renpho.com"," instead of the legacy ",[55,105,106],{},"renpho.qnclouds.com",". My account simply doesn't exist on the old system.",[18,109,110],{},"No documentation. No community projects. No way forward except to figure out what the new app is actually doing.",[34,112,114],{"id":113},"the-plan-man-in-the-middle","The Plan: Man-in-the-Middle",[18,116,117],{},"The classic approach to reverse engineering mobile APIs is straightforward:",[119,120,121,125,128,131],"ol",{},[122,123,124],"li",{},"Set up a proxy (mitmproxy) on your computer",[122,126,127],{},"Point your phone at the proxy",[122,129,130],{},"Install the proxy's CA certificate on the phone",[122,132,133],{},"Watch the traffic flow through",[18,135,136,137,140],{},"I spun up mitmproxy and configured my Android phone to route through it. HTTP traffic worked perfectly - I could see requests to ",[55,138,139],{},"example.com"," clear as day.",[18,142,143],{},"Then I opened the Renpho app.",[18,145,146],{},[96,147,148],{},"\"Network exception, please try again later\"",[18,150,151],{},"Nothing in the proxy logs. The app refused to talk.",[34,153,155],{"id":154},"certificate-pinning-the-first-wall","Certificate Pinning: The First Wall",[18,157,158,159,162],{},"Modern apps don't blindly trust any CA certificate your phone trusts. They ",[96,160,161],{},"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.",[18,164,165,166,173],{},"The solution? ",[167,168,172],"a",{"href":169,"rel":170},"https:\u002F\u002Ffrida.re\u002F",[171],"nofollow","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.",[18,175,176],{},"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.",[34,178,180],{"id":179},"emulator-hell-a-brief-detour","Emulator Hell: A Brief Detour",[18,182,183],{},"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.",[18,185,186,189],{},[96,187,188],{},"Android Studio Emulator",": Downloaded the APK from APKMirror, tried to install it, got slapped with:",[48,191,195],{"className":192,"code":193,"language":194,"meta":53,"style":53},"language-bash shiki shiki-themes github-light-high-contrast ayu-dark","INSTALL_FAILED_NO_MATCHING_ABIS: Failed to extract native libraries\n","bash",[55,196,197],{"__ignoreMap":53},[58,198,199,203,206,209,212,215],{"class":60,"line":61},[58,200,202],{"class":201},"sAlq1","INSTALL_FAILED_NO_MATCHING_ABIS:",[58,204,205],{"class":76}," Failed",[58,207,208],{"class":76}," to",[58,210,211],{"class":76}," extract",[58,213,214],{"class":76}," native",[58,216,217],{"class":76}," libraries\n",[18,219,220,221,224,225,228],{},"The Renpho app includes native ARM libraries (",[55,222,223],{},"arm64-v8a",", ",[55,226,227],{},"armeabi-v7a",") and the x86_64 emulator can't run them. The \"ARM translation\" feature only works for Java\u002FKotlin code, not native libraries. Dead end.",[18,230,231,234],{},[96,232,233],{},"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!",[18,236,237,238,241],{},"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 ",[55,239,240],{},"\u002Fdata\u002Flocal\u002Ftmp\u002F",".",[18,243,244,245,248,249,252],{},"I patched the APK with the Frida gadget embedded, installed it, and could connect to Frida. But ",[55,246,247],{},"Java.available"," was always ",[55,250,251],{},"false"," - Frida couldn't access the Java VM, probably due to the ARM translation layer adding an extra level of indirection.",[18,254,255],{},"Four hours later, I admitted defeat and grabbed my physical phone.",[34,257,259],{"id":258},"the-physical-phone-where-things-actually-work","The Physical Phone: Where Things Actually Work",[18,261,262],{},"The patched APK approach with a real phone is surprisingly smooth:",[48,264,266],{"className":192,"code":265,"language":194,"meta":53,"style":53},"# Patch the APK with objection (Frida gadget gets embedded)\nobjection patchapk -s renpho.apk -a arm64-v8a\n\n# Install on phone\nadb install renpho.objection.apk\n",[55,267,268,274,296,303,309],{"__ignoreMap":53},[58,269,270],{"class":60,"line":61},[58,271,273],{"class":272},"sDmQu","# Patch the APK with objection (Frida gadget gets embedded)\n",[58,275,277,280,283,287,290,293],{"class":60,"line":276},2,[58,278,279],{"class":201},"objection",[58,281,282],{"class":76}," patchapk",[58,284,286],{"class":285},"s_ir-"," -s",[58,288,289],{"class":76}," renpho.apk",[58,291,292],{"class":285}," -a",[58,294,295],{"class":76}," arm64-v8a\n",[58,297,299],{"class":60,"line":298},3,[58,300,302],{"emptyLinePlaceholder":301},true,"\n",[58,304,306],{"class":60,"line":305},4,[58,307,308],{"class":272},"# Install on phone\n",[58,310,312,315,318],{"class":60,"line":311},5,[58,313,314],{"class":201},"adb",[58,316,317],{"class":76}," install",[58,319,320],{"class":76}," renpho.objection.apk\n",[18,322,323],{},"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.",[18,325,326],{},"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.",[34,328,330],{"id":329},"capturing-traffic-the-ssl_write-hook","Capturing Traffic: The SSL_write Hook",[18,332,333,334,337,338,340],{},"Instead of trying to intercept traffic at the TLS layer (which requires dealing with certificate pinning), I hooked ",[55,335,336],{},"SSL_write"," - the function that sends data over an encrypted connection. By the time data reaches ",[55,339,336],{},", it's still plaintext. TLS encryption happens inside that function.",[48,342,346],{"className":343,"code":344,"language":345,"meta":53,"style":53},"language-js shiki shiki-themes github-light-high-contrast ayu-dark","Interceptor.attach(ssl_write_address, {\n    onEnter: function (args) {\n        var data = args[1].readUtf8String(args[2].toInt32());\n        if (data.indexOf('POST ') === 0 || data.indexOf('renpho') !== -1) {\n            console.log(data);\n        }\n    },\n});\n","js",[55,347,348,368,389,435,490,505,511,520],{"__ignoreMap":53},[58,349,350,353,356,360,363,365],{"class":60,"line":61},[58,351,352],{"class":64},"Interceptor",[58,354,241],{"class":355},"sSsah",[58,357,359],{"class":358},"szG8M","attach",[58,361,362],{"class":64},"(ssl_write_address",[58,364,80],{"class":72},[58,366,367],{"class":64}," {\n",[58,369,370,373,375,379,382,386],{"class":60,"line":276},[58,371,372],{"class":358},"    onEnter",[58,374,73],{"class":72},[58,376,378],{"class":377},"srkIe"," function",[58,380,381],{"class":64}," (",[58,383,385],{"class":384},"sbydi","args",[58,387,388],{"class":64},") {\n",[58,390,391,394,397,401,404,408,411,413,416,419,422,424,426,429,432],{"class":60,"line":298},[58,392,393],{"class":377},"        var",[58,395,396],{"class":64}," data ",[58,398,400],{"class":399},"s5uiV","=",[58,402,403],{"class":64}," args[",[58,405,407],{"class":406},"s3qwe","1",[58,409,410],{"class":64},"]",[58,412,241],{"class":355},[58,414,415],{"class":358},"readUtf8String",[58,417,418],{"class":64},"(args[",[58,420,421],{"class":406},"2",[58,423,410],{"class":64},[58,425,241],{"class":355},[58,427,428],{"class":358},"toInt32",[58,430,431],{"class":64},"())",[58,433,434],{"class":72},";\n",[58,436,437,440,443,445,448,451,454,457,460,463,466,469,471,473,475,478,480,483,486,488],{"class":60,"line":305},[58,438,439],{"class":377},"        if",[58,441,442],{"class":64}," (data",[58,444,241],{"class":355},[58,446,447],{"class":358},"indexOf",[58,449,450],{"class":64},"(",[58,452,453],{"class":76},"'POST '",[58,455,456],{"class":64},") ",[58,458,459],{"class":399},"===",[58,461,462],{"class":406}," 0",[58,464,465],{"class":399}," ||",[58,467,468],{"class":64}," data",[58,470,241],{"class":355},[58,472,447],{"class":358},[58,474,450],{"class":64},[58,476,477],{"class":76},"'renpho'",[58,479,456],{"class":64},[58,481,482],{"class":399},"!==",[58,484,485],{"class":399}," -",[58,487,407],{"class":406},[58,489,388],{"class":64},[58,491,492,495,497,500,503],{"class":60,"line":311},[58,493,494],{"class":64},"            console",[58,496,241],{"class":355},[58,498,499],{"class":358},"log",[58,501,502],{"class":64},"(data)",[58,504,434],{"class":72},[58,506,508],{"class":60,"line":507},6,[58,509,510],{"class":64},"        }\n",[58,512,514,517],{"class":60,"line":513},7,[58,515,516],{"class":64},"    }",[58,518,519],{"class":72},",\n",[58,521,523,526],{"class":60,"line":522},8,[58,524,525],{"class":64},"})",[58,527,434],{"class":72},[18,529,530],{},"I logged into the app, poked around, and watched the requests flow through:",[48,532,536],{"className":533,"code":534,"language":535,"meta":53,"style":53},"language-http shiki shiki-themes github-light-high-contrast ayu-dark","POST \u002Frenpho-aggregation\u002Fuser\u002Flogin HTTP\u002F1.1\nHost: cloud.renpho.com\nContent-Type: application\u002Fjson;charset=UTF-8\nUser-Agent: okhttp\u002F4.9.0\n\n{\"encryptData\":\"fMlxsFnJjeNnFe4Tn45ENstJUNcpCjgd4jC6uoYlvnE=\"}\n","http",[55,537,538,555,565,575,585,589],{"__ignoreMap":53},[58,539,540,543,546,549,552],{"class":60,"line":61},[58,541,542],{"class":377},"POST",[58,544,545],{"class":64}," \u002Frenpho-aggregation\u002Fuser\u002Flogin ",[58,547,548],{"class":377},"HTTP",[58,550,551],{"class":64},"\u002F",[58,553,554],{"class":406},"1.1\n",[58,556,557,560,562],{"class":60,"line":276},[58,558,559],{"class":68},"Host",[58,561,73],{"class":377},[58,563,564],{"class":76}," cloud.renpho.com\n",[58,566,567,570,572],{"class":60,"line":298},[58,568,569],{"class":68},"Content-Type",[58,571,73],{"class":377},[58,573,574],{"class":76}," application\u002Fjson;charset=UTF-8\n",[58,576,577,580,582],{"class":60,"line":305},[58,578,579],{"class":68},"User-Agent",[58,581,73],{"class":377},[58,583,584],{"class":76}," okhttp\u002F4.9.0\n",[58,586,587],{"class":60,"line":311},[58,588,302],{"emptyLinePlaceholder":301},[58,590,591,594,597,599,602],{"class":60,"line":507},[58,592,593],{"class":64},"{",[58,595,596],{"class":68},"\"encryptData\"",[58,598,73],{"class":72},[58,600,601],{"class":76},"\"fMlxsFnJjeNnFe4Tn45ENstJUNcpCjgd4jC6uoYlvnE=\"",[58,603,604],{"class":64},"}\n",[18,606,607],{},"Wait. The request body is... encrypted?",[34,609,611],{"id":610},"the-encryption-layer-aes-in-the-wild","The Encryption Layer: AES in the Wild",[18,613,614,615,618],{},"Every single request to the Renpho API wraps its payload in an ",[55,616,617],{},"encryptData"," field containing base64-encoded ciphertext. The response? Also encrypted (and gzip compressed for good measure).",[18,620,621],{},"I captured dozens of requests and started analysing patterns:",[623,624,625,638],"table",{},[626,627,628],"thead",{},[629,630,631,635],"tr",{},[632,633,634],"th",{},"Encrypted",[632,636,637],{},"Context",[639,640,641,652],"tbody",{},[629,642,643,649],{},[644,645,646],"td",{},[55,647,648],{},"b0LiByPBHmL5J19HjuIYDQ==",[644,650,651],{},"Appeared 19 times",[629,653,654,659],{},[644,655,656],{},[55,657,658],{},"fMlxsFnJjeNnFe4Tn45ENstJUNcpCjgd4jC6uoYlvnE=",[644,660,661],{},"Login-related",[18,663,664,665,668,669,241],{},"The first ciphertext appeared everywhere - probably an empty object ",[55,666,667],{},"{}",". 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 ",[96,670,671],{},"ECB mode",[673,674,675,682,685,688],"details",{},[676,677,678],"summary",{},[679,680,681],"b",{},"Why ECB mode is considered weak",[18,683,684],{},"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.",[18,686,687],{},"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.",[18,689,690],{},"That said, for a fitness app API, it's probably \"good enough\" to deter casual snooping.",[34,692,694],{"id":693},"decompiling-the-apk","Decompiling the APK",[18,696,697,698,73],{},"To understand the encryption, I decompiled the APK with ",[55,699,700],{},"apktool",[48,702,704],{"className":192,"code":703,"language":194,"meta":53,"style":53},"apktool d renpho.apk -o renpho-decompile\n",[55,705,706],{"__ignoreMap":53},[58,707,708,710,713,715,718],{"class":60,"line":61},[58,709,700],{"class":201},[58,711,712],{"class":76}," d",[58,714,289],{"class":76},[58,716,717],{"class":285}," -o",[58,719,720],{"class":76}," renpho-decompile\n",[18,722,723,724,73],{},"Searching through the smali bytecode, I found ",[55,725,726],{},"AESUtil.smali",[48,728,732],{"className":729,"code":730,"language":731,"meta":53,"style":53},"language-smali shiki shiki-themes github-light-high-contrast ayu-dark","const-string v2, \"AES\u002FECB\u002FPKCS5Padding\"\ninvoke-static {v2}, Ljavax\u002Fcrypto\u002FCipher;->getInstance(Ljava\u002Flang\u002FString;)Ljavax\u002Fcrypto\u002FCipher;\n","smali",[55,733,734,739],{"__ignoreMap":53},[58,735,736],{"class":60,"line":61},[58,737,738],{},"const-string v2, \"AES\u002FECB\u002FPKCS5Padding\"\n",[58,740,741],{"class":60,"line":276},[58,742,743],{},"invoke-static {v2}, Ljavax\u002Fcrypto\u002FCipher;->getInstance(Ljava\u002Flang\u002FString;)Ljavax\u002Fcrypto\u002FCipher;\n",[18,745,746,747,750,751,241],{},"AES\u002FECB\u002FPKCS5Padding - confirmed. The key is fetched from ",[55,748,749],{},"RenphoEncryptKey.getRenphoPassword()",", which is... a native method. The key lives in compiled C++ code inside ",[55,752,753],{},"libnative-lib.so",[18,755,756,757,760],{},"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 ",[55,758,759],{},".so"," file turned up some candidates, but none of them worked.",[18,762,763],{},"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.",[34,765,767],{"id":766},"putting-it-together","Putting It Together",[18,769,770],{},"With the encryption sorted, implementing the Laravel service was straightforward:",[48,772,776],{"className":773,"code":774,"language":775,"meta":53,"style":53},"language-php shiki shiki-themes github-light-high-contrast ayu-dark","private function encrypt(array $data): string\n{\n    $json = json_encode($data);\n    $padded = $this->pkcs5Pad($json);\n    $encrypted = openssl_encrypt($padded, 'AES-128-ECB', $this->encryptionKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING);\n    return base64_encode($encrypted);\n}\n\nprivate function decrypt(string $encryptedBase64): array\n{\n    $ciphertext = base64_decode($encryptedBase64);\n    $decrypted = openssl_decrypt($ciphertext, 'AES-128-ECB', $this->encryptionKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING);\n    return json_decode($this->pkcs5Unpad($decrypted), true);\n}\n","php",[55,777,778,801,806,822,844,887,900,904,908,931,936,952,990,1020],{"__ignoreMap":53},[58,779,780,783,785,788,790,793,796,798],{"class":60,"line":61},[58,781,782],{"class":377},"private",[58,784,378],{"class":377},[58,786,787],{"class":358}," encrypt",[58,789,450],{"class":64},[58,791,792],{"class":377},"array",[58,794,795],{"class":64}," $data)",[58,797,73],{"class":399},[58,799,800],{"class":377}," string\n",[58,802,803],{"class":60,"line":276},[58,804,805],{"class":64},"{\n",[58,807,808,811,813,817,820],{"class":60,"line":298},[58,809,810],{"class":64},"    $json ",[58,812,400],{"class":399},[58,814,816],{"class":815},"saQHA"," json_encode",[58,818,819],{"class":64},"($data)",[58,821,434],{"class":72},[58,823,824,827,829,833,836,839,842],{"class":60,"line":305},[58,825,826],{"class":64},"    $padded ",[58,828,400],{"class":399},[58,830,832],{"class":831},"sFsGl"," $this",[58,834,835],{"class":399},"->",[58,837,838],{"class":358},"pkcs5Pad",[58,840,841],{"class":64},"($json)",[58,843,434],{"class":72},[58,845,846,849,851,854,857,859,862,864,866,868,871,873,876,879,882,885],{"class":60,"line":311},[58,847,848],{"class":64},"    $encrypted ",[58,850,400],{"class":399},[58,852,853],{"class":815}," openssl_encrypt",[58,855,856],{"class":64},"($padded",[58,858,80],{"class":72},[58,860,861],{"class":76}," 'AES-128-ECB'",[58,863,80],{"class":72},[58,865,832],{"class":831},[58,867,835],{"class":399},[58,869,870],{"class":64},"encryptionKey",[58,872,80],{"class":72},[58,874,875],{"class":285}," OPENSSL_RAW_DATA",[58,877,878],{"class":399}," |",[58,880,881],{"class":285}," OPENSSL_ZERO_PADDING",[58,883,884],{"class":64},")",[58,886,434],{"class":72},[58,888,889,892,895,898],{"class":60,"line":507},[58,890,891],{"class":377},"    return",[58,893,894],{"class":815}," base64_encode",[58,896,897],{"class":64},"($encrypted)",[58,899,434],{"class":72},[58,901,902],{"class":60,"line":513},[58,903,604],{"class":64},[58,905,906],{"class":60,"line":522},[58,907,302],{"emptyLinePlaceholder":301},[58,909,911,913,915,918,920,923,926,928],{"class":60,"line":910},9,[58,912,782],{"class":377},[58,914,378],{"class":377},[58,916,917],{"class":358}," decrypt",[58,919,450],{"class":64},[58,921,922],{"class":377},"string",[58,924,925],{"class":64}," $encryptedBase64)",[58,927,73],{"class":399},[58,929,930],{"class":377}," array\n",[58,932,934],{"class":60,"line":933},10,[58,935,805],{"class":64},[58,937,939,942,944,947,950],{"class":60,"line":938},11,[58,940,941],{"class":64},"    $ciphertext ",[58,943,400],{"class":399},[58,945,946],{"class":815}," base64_decode",[58,948,949],{"class":64},"($encryptedBase64)",[58,951,434],{"class":72},[58,953,955,958,960,963,966,968,970,972,974,976,978,980,982,984,986,988],{"class":60,"line":954},12,[58,956,957],{"class":64},"    $decrypted ",[58,959,400],{"class":399},[58,961,962],{"class":815}," openssl_decrypt",[58,964,965],{"class":64},"($ciphertext",[58,967,80],{"class":72},[58,969,861],{"class":76},[58,971,80],{"class":72},[58,973,832],{"class":831},[58,975,835],{"class":399},[58,977,870],{"class":64},[58,979,80],{"class":72},[58,981,875],{"class":285},[58,983,878],{"class":399},[58,985,881],{"class":285},[58,987,884],{"class":64},[58,989,434],{"class":72},[58,991,993,995,998,1000,1003,1005,1008,1011,1013,1016,1018],{"class":60,"line":992},13,[58,994,891],{"class":377},[58,996,997],{"class":815}," json_decode",[58,999,450],{"class":64},[58,1001,1002],{"class":831},"$this",[58,1004,835],{"class":399},[58,1006,1007],{"class":358},"pkcs5Unpad",[58,1009,1010],{"class":64},"($decrypted)",[58,1012,80],{"class":72},[58,1014,1015],{"class":406}," true",[58,1017,884],{"class":64},[58,1019,434],{"class":72},[58,1021,1023],{"class":60,"line":1022},14,[58,1024,604],{"class":64},[18,1026,1027],{},"The login endpoint returns a JWT token and user ID. Subsequent requests include these in headers:",[48,1029,1031],{"className":773,"code":1030,"language":775,"meta":53,"style":53},"$response = Http::withHeaders([\n    'token' => $this->token,\n    'userId' => $this->userId,\n    'appVersion' => '7.5.2',\n    'platform' => 'android',\n    \u002F\u002F ... other headers\n])->post($url, ['encryptData' => $this->encrypt($payload)]);\n",[55,1032,1033,1053,1070,1086,1098,1110,1115],{"__ignoreMap":53},[58,1034,1035,1038,1040,1044,1047,1050],{"class":60,"line":61},[58,1036,1037],{"class":64},"$response ",[58,1039,400],{"class":399},[58,1041,1043],{"class":1042},"sj5Y2"," Http",[58,1045,1046],{"class":399},"::",[58,1048,1049],{"class":358},"withHeaders",[58,1051,1052],{"class":64},"([\n",[58,1054,1055,1058,1061,1063,1065,1068],{"class":60,"line":276},[58,1056,1057],{"class":76},"    'token'",[58,1059,1060],{"class":399}," =>",[58,1062,832],{"class":831},[58,1064,835],{"class":399},[58,1066,1067],{"class":64},"token",[58,1069,519],{"class":72},[58,1071,1072,1075,1077,1079,1081,1084],{"class":60,"line":298},[58,1073,1074],{"class":76},"    'userId'",[58,1076,1060],{"class":399},[58,1078,832],{"class":831},[58,1080,835],{"class":399},[58,1082,1083],{"class":64},"userId",[58,1085,519],{"class":72},[58,1087,1088,1091,1093,1096],{"class":60,"line":305},[58,1089,1090],{"class":76},"    'appVersion'",[58,1092,1060],{"class":399},[58,1094,1095],{"class":76}," '7.5.2'",[58,1097,519],{"class":72},[58,1099,1100,1103,1105,1108],{"class":60,"line":311},[58,1101,1102],{"class":76},"    'platform'",[58,1104,1060],{"class":399},[58,1106,1107],{"class":76}," 'android'",[58,1109,519],{"class":72},[58,1111,1112],{"class":60,"line":507},[58,1113,1114],{"class":272},"    \u002F\u002F ... other headers\n",[58,1116,1117,1120,1122,1125,1128,1130,1133,1136,1138,1140,1142,1145,1148],{"class":60,"line":513},[58,1118,1119],{"class":64},"])",[58,1121,835],{"class":399},[58,1123,1124],{"class":358},"post",[58,1126,1127],{"class":64},"($url",[58,1129,80],{"class":72},[58,1131,1132],{"class":64}," [",[58,1134,1135],{"class":76},"'encryptData'",[58,1137,1060],{"class":399},[58,1139,832],{"class":831},[58,1141,835],{"class":399},[58,1143,1144],{"class":358},"encrypt",[58,1146,1147],{"class":64},"($payload)])",[58,1149,434],{"class":72},[18,1151,1152,1153,1156],{},"The measurements endpoint (",[55,1154,1155],{},"\u002FRenphoHealth\u002Fscale\u002FqueryAllMeasureDataList\u002F",") returns an array of body composition data:",[48,1158,1160],{"className":50,"code":1159,"language":52,"meta":53,"style":53},"{\n    \"timeStamp\": 1768119986,\n    \"weight\": 103.2,\n    \"bmi\": 26.9,\n    \"bodyfat\": 16.6,\n    \"muscle\": 53.8,\n    \"water\": 60.2,\n    \"bone\": 4.34,\n    \"bmr\": 2243,\n    \"bodyage\": 31\n}\n",[55,1161,1162,1166,1178,1190,1202,1214,1226,1238,1250,1262,1272],{"__ignoreMap":53},[58,1163,1164],{"class":60,"line":61},[58,1165,805],{"class":64},[58,1167,1168,1171,1173,1176],{"class":60,"line":276},[58,1169,1170],{"class":68},"    \"timeStamp\"",[58,1172,73],{"class":72},[58,1174,1175],{"class":406}," 1768119986",[58,1177,519],{"class":72},[58,1179,1180,1183,1185,1188],{"class":60,"line":298},[58,1181,1182],{"class":68},"    \"weight\"",[58,1184,73],{"class":72},[58,1186,1187],{"class":406}," 103.2",[58,1189,519],{"class":72},[58,1191,1192,1195,1197,1200],{"class":60,"line":305},[58,1193,1194],{"class":68},"    \"bmi\"",[58,1196,73],{"class":72},[58,1198,1199],{"class":406}," 26.9",[58,1201,519],{"class":72},[58,1203,1204,1207,1209,1212],{"class":60,"line":311},[58,1205,1206],{"class":68},"    \"bodyfat\"",[58,1208,73],{"class":72},[58,1210,1211],{"class":406}," 16.6",[58,1213,519],{"class":72},[58,1215,1216,1219,1221,1224],{"class":60,"line":507},[58,1217,1218],{"class":68},"    \"muscle\"",[58,1220,73],{"class":72},[58,1222,1223],{"class":406}," 53.8",[58,1225,519],{"class":72},[58,1227,1228,1231,1233,1236],{"class":60,"line":513},[58,1229,1230],{"class":68},"    \"water\"",[58,1232,73],{"class":72},[58,1234,1235],{"class":406}," 60.2",[58,1237,519],{"class":72},[58,1239,1240,1243,1245,1248],{"class":60,"line":522},[58,1241,1242],{"class":68},"    \"bone\"",[58,1244,73],{"class":72},[58,1246,1247],{"class":406}," 4.34",[58,1249,519],{"class":72},[58,1251,1252,1255,1257,1260],{"class":60,"line":910},[58,1253,1254],{"class":68},"    \"bmr\"",[58,1256,73],{"class":72},[58,1258,1259],{"class":406}," 2243",[58,1261,519],{"class":72},[58,1263,1264,1267,1269],{"class":60,"line":933},[58,1265,1266],{"class":68},"    \"bodyage\"",[58,1268,73],{"class":72},[58,1270,1271],{"class":406}," 31\n",[58,1273,1274],{"class":60,"line":938},[58,1275,604],{"class":64},[18,1277,1278],{},"Now I have automatic sync running as a scheduled Laravel command. Fresh measurement appear in my dashboard once a week.",[34,1280,1282],{"id":1281},"the-rabbit-holes","The Rabbit Holes",[1284,1285,1287],"h3",{"id":1286},"why-do-apps-use-certificate-pinning","Why Do Apps Use Certificate Pinning?",[18,1289,1290],{},"Beyond preventing MITM attacks from malicious actors, pinning protects against:",[1292,1293,1294,1297,1300],"ul",{},[122,1295,1296],{},"Corporate proxies that inspect HTTPS traffic",[122,1298,1299],{},"Compromised CA certificates (remember DigiNotar?)",[122,1301,1302],{},"Users who might want to... reverse engineer your API",[18,1304,1305],{},"It's a legitimate security measure. The arms race between app developers and researchers continues.",[1284,1307,1309],{"id":1308},"whats-the-deal-with-native-libraries","What's the Deal with Native Libraries?",[18,1311,1312],{},"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.",[18,1314,1315],{},"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.",[1284,1317,1319],{"id":1318},"why-ecb-mode-though","Why ECB Mode Though?",[18,1321,1322],{},"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.",[18,1324,1325],{},"They're not wrong, exactly. The encryption does prevent casual interception of your body fat percentage. But it's a curious choice.",[34,1327,1329],{"id":1328},"lessons-learned","Lessons Learned",[18,1331,1332,1335],{},[96,1333,1334],{},"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.",[18,1337,1338,1341],{},[96,1339,1340],{},"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.",[18,1343,1344,1347],{},[96,1345,1346],{},"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.",[18,1349,1350,1353],{},[96,1351,1352],{},"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.",[34,1355,1357],{"id":1356},"final-thoughts","Final Thoughts",[18,1359,1360],{},"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.",[18,1362,1363],{},"The actual working solution is simple: patch APK, hook SSL_write, analyse traffic, implement client. The journey to get there was anything but.",[18,1365,1366],{},"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.",[18,1368,1369],{},"For now, though, my scale talks to my Laravel app and I have access to my own data. And that's all I wanted.",[1371,1372,1373],"style",{},"html pre.shiki code .sbKop, html code.shiki .sbKop{--shiki-default:#0E1116;--shiki-dark:#BFBDB6}html pre.shiki code .siBZp, html code.shiki .siBZp{--shiki-default:#024C1A;--shiki-dark:#39BAE6}html pre.shiki code .s8M_Z, html code.shiki .s8M_Z{--shiki-default:#0E1116;--shiki-dark:#BFBDB6B3}html pre.shiki code .s4OvH, html code.shiki .s4OvH{--shiki-default:#032563;--shiki-dark:#AAD94C}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sAlq1, html code.shiki .sAlq1{--shiki-default:#702C00;--shiki-dark:#59C2FF}html pre.shiki code .sDmQu, html code.shiki .sDmQu{--shiki-default:#66707B;--shiki-default-font-style:inherit;--shiki-dark:#5A6673;--shiki-dark-font-style:italic}html pre.shiki code .s_ir-, html code.shiki .s_ir-{--shiki-default:#023B95;--shiki-dark:#95E6CB}html pre.shiki code .sSsah, html code.shiki .sSsah{--shiki-default:#0E1116;--shiki-dark:#F29668}html pre.shiki code .szG8M, html code.shiki .szG8M{--shiki-default:#622CBC;--shiki-dark:#FFB454}html pre.shiki code .srkIe, html code.shiki .srkIe{--shiki-default:#A0111F;--shiki-dark:#FF8F40}html pre.shiki code .sbydi, html code.shiki .sbydi{--shiki-default:#702C00;--shiki-dark:#D2A6FF}html pre.shiki code .s5uiV, html code.shiki .s5uiV{--shiki-default:#A0111F;--shiki-dark:#F29668}html pre.shiki code .s3qwe, html code.shiki .s3qwe{--shiki-default:#023B95;--shiki-dark:#D2A6FF}html pre.shiki code .saQHA, html code.shiki .saQHA{--shiki-default:#023B95;--shiki-dark:#F07178}html pre.shiki code .sFsGl, html code.shiki .sFsGl{--shiki-default:#023B95;--shiki-default-font-style:inherit;--shiki-dark:#39BAE6;--shiki-dark-font-style:italic}html pre.shiki code .sj5Y2, html code.shiki .sj5Y2{--shiki-default:#023B95;--shiki-dark:#39BAE6}",{"title":53,"searchDepth":276,"depth":276,"links":1375},[1376,1377,1378,1379,1380,1381,1382,1383,1384,1385,1390,1391],{"id":36,"depth":276,"text":37},{"id":113,"depth":276,"text":114},{"id":154,"depth":276,"text":155},{"id":179,"depth":276,"text":180},{"id":258,"depth":276,"text":259},{"id":329,"depth":276,"text":330},{"id":610,"depth":276,"text":611},{"id":693,"depth":276,"text":694},{"id":766,"depth":276,"text":767},{"id":1281,"depth":276,"text":1282,"children":1386},[1387,1388,1389],{"id":1286,"depth":298,"text":1287},{"id":1308,"depth":298,"text":1309},{"id":1318,"depth":298,"text":1319},{"id":1328,"depth":276,"text":1329},{"id":1356,"depth":276,"text":1357},"2026-06-26","When the official app uses a different backend than the documented API, you have two choices: give up, or go down the rabbit hole. I chose the rabbit hole.","md","I wanted to automatically sync my body composition data from my smart scale to my personal fitness tracker app. What followed was a week-long journey through Android emulators, certificate pinning, Frida gadgets, and AES encryption.","reverse-engineering",{},"\u002Fimages\u002Fblog\u002Frenpho-square.png","\u002Fblog\u002Freverse-engineering-renpho-health-api","14",{"title":13,"description":1393},"blog\u002Freverse-engineering-renpho-health-api",[1396,1404,1405,1406,1407,1408,1409,1410,1411],"android","frida","security","api","ssl-pinning","encryption","laravel","rabbit-hole","OTSwEAfn6x5Wnh8Vj40ZvQ1gZ2xZIEikl_42b_jsPpE",{"left":4,"top":4,"width":9,"height":9,"rotate":4,"vFlip":6,"hFlip":6,"body":1414},"\u003C!-- Icon from All by undefined - undefined -->\u003Cpath fill=\"currentColor\" d=\"M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm0-2h9V4H9zM4 8q-.425 0-.712-.288T3 7t.288-.712T4 6t.713.288T5 7t-.288.713T4 8m0 3.5q-.425 0-.712-.288T3 10.5t.288-.712T4 9.5t.713.288T5 10.5t-.288.713T4 11.5M4 15q-.425 0-.712-.288T3 14t.288-.712T4 13t.713.288T5 14t-.288.713T4 15m0 3.5q-.425 0-.712-.288T3 17.5t.288-.712T4 16.5t.713.288T5 17.5t-.288.713T4 18.5M4 22q-.425 0-.712-.288T3 21t.288-.712T4 20t.713.288T5 21t-.288.713T4 22m3.5 0q-.425 0-.712-.288T6.5 21t.288-.712T7.5 20t.713.288T8.5 21t-.288.713T7.5 22m3.5 0q-.425 0-.712-.288T10 21t.288-.712T11 20t.713.288T12 21t-.288.713T11 22m3.5 0q-.425 0-.712-.288T13.5 21t.288-.712T14.5 20t.713.288t.287.712t-.288.713T14.5 22\"\u002F>",1782484034746]