Analyzing the Login Protocol of a Fitness App

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.


Analyzing the Login Protocol of a Fitness App

Preface

While exploring reverse engineering, I analyzed the login protocol of a fitness app and I’m recording the process and results here — for learning/research only. Few tools, simple content, beginner-level (experts can skip). I focused on the password-login flow: enter a random phone number and password, then analyze with a packet-capture tool.

Packet capture

  1. Capture with Charles — install and configure its certificate yourself.
  2. Trigger the login endpoint by tapping password login. Use fake credentials; the capture succeeds.

Captured login request

Sign analysis

First, there’s a sign field in the request headers:

sign: b61df9a8bce7a8641c5ca986b55670e633a7ab29

It’s 40 chars long. MD5 is 32, so my first instinct says it’s not MD5 — but it could be MD5 concatenated with something else. SHA-1 is exactly 40, which fits. Let’s verify one by one. First check for MD5 traces with a quick frida script. The script is straightforward: hook MD5’s Init, Update, and Final and print their inputs and outputs. Key code:

    // hook CC_MD5
    // unsigned char * CC_MD5(const void *data, CC_LONG len, unsigned char *md);
    Interceptor.attach(Module.findExportByName("libcommonCrypto.dylib", g_funcName), {
        onEnter: function(args) {
            console.log(g_funcName + " begin");
            var len = args[1].toInt32();
            console.log("input:");
            dumpBytes(args[0], len);
            this.md = args[2];
        },
        onLeave: function(retval) {
            console.log(g_funcName + " return value");
            dumpBytes(this.md, g_funcRetvalLength);

            console.log(g_funcName + ' called from:\n' +
                Thread.backtrace(this.context, Backtracer.ACCURATE)
                .map(DebugSymbol.fromAddress).join('\n') + '\n');
        }
    });

    // hook CC_MD5_Update
    // int CC_MD5_Update(CC_MD5_CTX *c, const void *data, CC_LONG len);
    Interceptor.attach(Module.findExportByName("libcommonCrypto.dylib", g_updateFuncName), {
        onEnter: function(args) {
            console.log(g_updateFuncName + " begin");
            var len = args[2].toInt32();
            console.log("input:");
            dumpBytes(args[1], len);
        },
        onLeave: function(retval) {
            console.log(g_updateFuncName + ' called from:\n' +
                Thread.backtrace(this.context, Backtracer.ACCURATE)
                .map(DebugSymbol.fromAddress).join('\n') + '\n');
        }
    });

    // hook CC_MD5_Final
    // int CC_MD5_Final(unsigned char *md, CC_MD5_CTX *c);
    Interceptor.attach(Module.findExportByName("libcommonCrypto.dylib", g_finalFuncName), {
        onEnter: function(args) {
            //console.log(func.name + " begin");
            finalArgs_md = args[0];
        },
        onLeave: function(retval) {
            console.log(g_finalFuncName + " return value");
            dumpBytes(finalArgs_md, g_funcRetvalLength);

            console.log(g_finalFuncName + ' called from:\n' +
                Thread.backtrace(this.context, Backtracer.ACCURATE)
                .map(DebugSymbol.fromAddress).join('\n') + '\n');
        }
    });

Lucky: the logs clearly show the sign-related content, but the last part of the sign is missing. So the sign is 32(md5) + 8. Let’s look at how the MD5 data is built.

b61df9a8bce7a8641c5ca986b55670e6 33a7ab29

MD5 hook output for the sign

From the logs, the sign’s MD5 is built from three parts: bodyData + Url + Str. The body is also available from Charles.

  • {“body”:“5gJEXtLqe3tzRsP8a/bSwehe0ta3zQx6wG7K74sOeXQ6Auz1NI1bg68wNLmj1e5Xl7CIwWelukC445W7HXxJY6nQ0v0SUg1tVyWS5L8E2oaCgoSeC6ypFNXV2xVm8hHV”}
  • /account/v4/login/password
  • V1QiLCJhbGciOiJIUzI1NiJ9

The three sign components

A question arises: is the third part, V1QiLCJhbGciOiJIUzI1NiJ9, a fixed string or does it change per request? I suspect it’s fixed, used as an MD5 salt. Request again to verify.

Second request

The new sign is 131329a5af4ecb025fb5088615d5e5c526dbd1a3, and the script output confirms the third part is a fixed string.

MD5({“body”:“12BcOSg50nLxdbt++r7liZpeyWAVpmihTy8Zu8BmpA6a1hqdevS5PPYwnbtpjN05xgeyReSihh9idyfriR6qx1Fbo8AA0k8HQt6gJ3spWITI21GhLTzh9PDUkgjCtrEK”}/account/v4/login/passwordV1QiLCJhbGciOiJIUzI1NiJ9)

MD5 input composition confirmed

Sign tail analysis

Next, the tail of the sign. Blind guessing or more frida hooks won’t cut it — let’s look at the actual implementation in IDA. (The MD5 analysis above could also have started from IDA by searching for sign, but I prefer hooking first — if it hits directly, no need to spend time analyzing.)

The MD5 script also prints the call stack, which helps a lot for quick localization. Searching for [KEPPostSecuritySign kep_signWithURL: body:] shows obvious string concatenation, and IDA is smart enough to have already recognized the MD5 salt.

IDA pseudocode for kep_signWithURL:body:

Digging in, I locate [NSString kep_networkStringOffsetSecurity], which does the string processing internally — a loop with various conditions and shift operations. If you’re not afraid of a bit of work, you can analyze and rewrite the logic.

kep_networkStringOffsetSecurity in IDA

My approach was more brute-force: since kep_networkStringOffsetSecurity is an NSString Category, just call it directly. Attach with frida, find the NSString class, call the method with the MD5 output — and the sign value magically appears.

Calling the category method via frida

x-ads analysis

After the sign, there’s an x-ads field. As usual, try hooking first — the common ciphers are DES, AES, or RC4.

x-ads field

Hook AES128, DES, 3DES, CAST, RC4, RC2, Blowfish, and friends. Key code:

var handlers = {
    CCCrypt: {
        onEnter: function(args) {
            var operation = CCOperation[args[0].toInt32()];
            var alg = CCAlgorithm[args[1].toInt32()].name;
            this.options = CCoptions[args[2].toInt32()];
            var keyBytes = args[3];
            var keyLength = args[4].toInt32();
            var ivBuffer = args[5];
            var inBuffer = args[6];
            this.inLength = args[7].toInt32();
            this.outBuffer = args[8];
            var outLength = args[9].toInt32();
            this.outCountPtr = args[10];
            if (this.inLength < MIN_LENGTH || this.inLength > MAX_LENGTH){
            	return;
            }
            if (operation === "kCCEncrypt") {
                this.operation = "encrypt"
                console.log("***************** encrypt begin **********************");
            } else {
                this.operation = "decrypt"
                console.log("***************** decrypt begin **********************");
            }
            console.log("CCCrypt(" +
                "operation: " + this.operation + ", " +
                "CCAlgorithm: " + alg + ", " +
                "CCOptions: " + this.options + ", " +
                "keyBytes: " + keyBytes + ", " +
                "keyLength: " + keyLength + ", " +
                "ivBuffer: " + ivBuffer + ", " +
                "inBuffer: " + inBuffer + ", " +
                "inLength: " + this.inLength + ", " +
                "outBuffer: " + this.outBuffer + ", " +
                "outLength: " + outLength + ", " +
                "outCountPtr: " + this.outCountPtr + ")"
            );

            //console.log("Key: utf-8 string:" + ptr(keyBytes).readUtf8String())
            //console.log("Key: utf-16 string:" + ptr(keyBytes).readUtf16String())
            console.log("key: ");
            dumpBytes(keyBytes, keyLength);

            console.log("IV: ");
            // ECB mode needs no iv, so iv is null
            dumpBytes(ivBuffer, keyLength);

            var isOutput = true;
            if (!SHOW_PLAIN_AND_CIPHER && this.operation == "decrypt") {
		            isOutput = false;
            }

            if (isOutput){
            	// Show the buffers here if this is an encryption operation
		            console.log("In buffer:");
		            dumpBytes(inBuffer, this.inLength);
            }
            
        },
        onLeave: function(retVal) {
        	// Skip output for too-long or too-short lengths
            if (this.inLength < MIN_LENGTH || this.inLength > MAX_LENGTH){
            	return;
            }
            var isOutput = true;
            if (!SHOW_PLAIN_AND_CIPHER && this.operation == "encrypt") {
		            isOutput = false;
            }
            if (isOutput) {
		            // Show the buffers here if this is a decryption operation
		            console.log("Out buffer:");
		            dumpBytes(this.outBuffer, Memory.readUInt(this.outCountPtr));
            }
            // Print the call stack — it resolves class and function names, very handy
            console.log('CCCrypt called from:\n' +
                Thread.backtrace(this.context, Backtracer.ACCURATE)
                .map(DebugSymbol.fromAddress).join('\n') + '\n');
        }
    },
};


if (ObjC.available) {
    console.log("frida attach");
    for (var func in handlers) {
    	console.log("hook " + func);
        Interceptor.attach(Module.findExportByName("libcommonCrypto.dylib", func), handlers[func]);
    }
} else {
    console.log("Objective-C Runtime is not available!");
}

The logs hit AES128 directly, and the output Base64 matches exactly — luck was on my side.

CCCrypt hook output hitting AES128

With the key and IV, decryption works. x-ads analysis done — didn’t even need to decompile the code. :)

Decrypting x-ads

Body analysis

Finally, how is the sign’s body component computed? On a hunch, I tried decrypting it with the same algorithm, key, and IV from the x-ads analysis:

{
“body”: “5gJEXtLqe3tzRsP8a/bSwXDiK0VslZZZyOEj1jBDBhtYTGGdWltuIjLbzwZ2OxMcb3mFX7bJtgH3WlqGET5W34P4dTEIDhLH6FkT3HSLaDnEXYHvEl9IZRQKf19wMG/t”
}

Body decryption

This time it’s less about luck — more that the developers were lazy, or a bit careless about security: AES-CBC, with the IV left unchanged.

Summary

Overall this wasn’t technically heavy — the scripts did most of the work — and the app uses conventional encryption/signing. That’s a reminder for security-minded developers: make strategic changes. Take MD5 as an example of what you can tweak.

https://opensource.apple.com/source/ppp/ppp-37/ppp/pppd/md5.c.auto.html

First, in MD5Init you can change the initialization constants:

void MD5Init (mdContext)
MD5_CTX *mdContext;
{
  mdContext->i[0] = mdContext->i[1] = (UINT4)0;

  /* Load magic initialization constants.
   */
  mdContext->buf[0] = (UINT4)0x67452301;
  mdContext->buf[1] = (UINT4)0xefcdab89;
  mdContext->buf[2] = (UINT4)0x98badcfe;
  mdContext->buf[3] = (UINT4)0x10325476;
}

Second, in Transform you can change a few of the constants:

static void Transform (buf, in)
UINT4 *buf;
UINT4 *in;
{
  UINT4 a = buf[0], b = buf[1], c = buf[2], d = buf[3];

  /* Round 1 */
#define S11 7
#define S12 12
#define S13 17
#define S14 22
  FF ( a, b, c, d, in[ 0], S11, UL(3614090360)); /* 1 */
  FF ( d, a, b, c, in[ 1], S12, UL(3905402710)); /* 2 */
  FF ( c, d, a, b, in[ 2], S13, UL( 606105819)); /* 3 */
  FF ( b, c, d, a, in[ 3], S14, UL(3250441966)); /* 4 */
  FF ( a, b, c, d, in[ 4], S11, UL(4118548399)); /* 5 */
  FF ( d, a, b, c, in[ 5], S12, UL(1200080426)); /* 6 */
  FF ( c, d, a, b, in[ 6], S13, UL(2821735955)); /* 7 */
  FF ( b, c, d, a, in[ 7], S14, UL(4249261313)); /* 8 */
  FF ( a, b, c, d, in[ 8], S11, UL(1770035416)); /* 9 */
  FF ( d, a, b, c, in[ 9], S12, UL(2336552879)); /* 10 */
  FF ( c, d, a, b, in[10], S13, UL(4294925233)); /* 11 */
  FF ( b, c, d, a, in[11], S14, UL(2304563134)); /* 12 */
  FF ( a, b, c, d, in[12], S11, UL(1804603682)); /* 13 */
  FF ( d, a, b, c, in[13], S12, UL(4254626195)); /* 14 */
  FF ( c, d, a, b, in[14], S13, UL(2792965006)); /* 15 */
  FF ( b, c, d, a, in[15], S14, UL(1236535329)); /* 16 */

  /* Round 2 */
#define S21 5
#define S22 9
#define S23 14
#define S24 20
  GG ( a, b, c, d, in[ 1], S21, UL(4129170786)); /* 17 */
  GG ( d, a, b, c, in[ 6], S22, UL(3225465664)); /* 18 */
  
  ...

After a simple tweak like that, even if a script hooks the function, calling it offline still won’t work — you’d have to resort to decompilation or dynamic debugging. Combined with code obfuscation, VMP-style static protection, and anti-debugging, the bar for attackers goes up accordingly.


Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch