55chat登录加密算法分析:从抓包到Python复现 最近接了个移动端App的兼容性测试任务其中一个叫55chat的聊天应用在登录流程里做了不少手脚。用户名密码不是明文提交请求体里还多了一个sign字段。折腾了一天时间从抓包、反编译、定位加密代码到最后写脚本复现登录整个链路总算走通了。这篇文章就把这次“记录55chat登录加密算法分析”的完整过程整理出来重点讲讲我是怎么一步步判断出算法类型、还原加密流程以及这个过程中踩过哪些坑。如果你也在做App安全测试、接口调试或者单纯想了解移动端登录加密的常见套路可以参考一下我的思路。1. 从抓包到接口定位先弄清楚登录请求里到底有什么1.1 登录请求的原始长相分析的第一步永远是抓包不是看代码。拿一台已经装好55chat的Android测试机配置好Charles代理然后在App里点击登录。这里提醒一句我用的都是测试账号在合规的测试环境里操作不要拿真实用户数据去试。登录请求很快就出来了。接口路径大致是/api/v1/auth/loginPOST方法请求体是一段JSON长这样{ username: test_user_001, password: Kx7QdH2mP0c5T3vB9gF...很长的一段Base64, sign: e4d8f5a2b7c1d9e0f2a3b4c5d6e7f8a9, timestamp: 1728000000000, nonce: 9f2e3c1a4b5d6e7f8a9b0c1d2e3f4a5b }五个字段其中username是明文timestamp是标准的13位毫秒级时间戳nonce是32位的十六进制字符串看起来像是UUID去掉横线后的结果。password是一长串Base64字符长度比明文密码明显大很多明显是加密后的结果。sign是32位的十六进制字符串第一反应是MD5。这里有个很重要的判断同样是32位hex可能只是MD5但也可能是别的算法取前32位。先不要急着下结论继续看。请求头里还有一个自定义字段X-Client-Version值大概是android_3.2.1。这说明App可能在服务端校验客户端版本加密逻辑也可能跟版本相关。这个先记下来后面定位算法时有大用。1.2 初步判断加密类型从密文特征入手看到password是标准Base64而且长度不是固定的某个小值我第一反应是RSA。因为RSA加密后输出的是二进制密文再用Base64编码会得到比较长的字符串而且每次加密结果不一样。如果是AES加密通常会有初始向量IV要么拼在密文前面要么单独放在参数里但请求体里没有iv字段所以AES的可能性相对低。不过也不能只看长度就断定。我做了个小实验在App里反复点登录用同一个密码抓了三次包。对比发现三次请求的password值完全不同nonce也每次不同而username和timestamp是可控的。如果password用的是AES-CBC固定key加密理论上同样明文会得到同样密文除非加入随机IV。所以这里更倾向于RSA或“RSAAES混合加密”的其中一种。sign字段也比较有意思。它由32位hex组成而且每次登录都会变化。初步猜测sign是把请求参数按某种规则拼接后做了MD5。但具体拼接顺序是什么哪个字段是密钥单靠猜不行必须去代码里找。1.3 反编译前的热身收集App基本信息定位代码之前先把App装到测试机上用解压工具打开APK。看包名结构发现是com.techbase.fiftyfivechat这个名字明显是混淆过的把业务模块都用a、b、c代替了。同时用jadx加载APK查看AndroidManifest.xml确认App没有开启android:debuggable但也没有特别强的加固措施这算是比较友好的情况。然后就是锁定目标既然登录接口叫/api/v1/auth/login那么在代码里搜auth、login、password、sign这些关键字配合jadx的文本搜索功能应该在Java层能找到相关逻辑。实际上大多数移动App的登录加密不会放在so库里因为使用密码学库比如标准Java的RSA实现本身就够安全开发者也懒得额外写native代码。2. 加密参数的识别与定位如何从代码里找出算法2.1 用jadx反编译跟随关键字一路找打开jadx后先把APK拖进去等它解析完。然后在搜索框输入password结果密密麻麻。没关系按类名排序重点看包含login、auth、encrypt、util的类。很快我注意到一个叫a/b/f.java的类。这个类里面有几个方法签名非常可疑比如public static String a(String str)、public static String b(String str, String str2)。方法体里调用了javax.crypto.Cipher和一些看起来很长的字符串常量。双击进去看到中间有那么几行Cipher cipher Cipher.getInstance(RSA/ECB/PKCS1Padding); cipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] encrypted cipher.doFinal(plainText.getBytes(UTF-8)); return Base64.encodeToString(encrypted, Base64.NO_WRAP);到这里基本可以确认password字段就是标准的RSA加密模式是ECB填充方式是PKCS1Padding。在Java里RSA/ECB/PKCS1Padding是默认的常用写法。ECB模式对RSA来说没有分组概念每次加密一个数据块输出长度取决于密钥长度。从Base64解码后的长度推算这个公钥大概率是1024位因为1024位RSA加密后密文是128字节Base64编码后约172个字符如果是2048位密文就是256字节Base64后约344个字符。我看到的password长度是172左右进一步印证了1024位RSA。接下来要找公钥。同样在这个类里有一个长字符串常量以-----BEGIN PUBLIC KEY-----开头那无疑就是公钥的PEM编码。我之前遇到过有些App会把公钥藏在so文件或资源目录里这个App直接用常量写死在Java代码里算是好处理的。sign字段不那么直观。搜索sign会找到很多星星无关的内容我改变思路去搜索MD5相关的类名。在这个App里看到了java.security.MessageDigest并且很长一段代码都在操作字符串。这个类不是密码学库里的示例而是业务封装里面拼的是一个字符串String raw username : encryptedPassword : timestamp : nonce : secretKey; String sign md5(raw);那个secretKey是一个固定值同样以明文形式存在于代码中看起来是一串32位的随机字符。到这里整个登录加密流程已经大致浮出水面明文密码先用RSA公钥加密Base64编码后放到password字段把username、加密后的password、timestamp、nonce、secretKey按冒号拼接计算MD5作为sign。这个设计思路挺典型RSA保证密码本身不被泄露sign保证请求参数不被篡改timestamp和nonce则用来防止重放攻击避免同样的请求包被反复提交。2.2 为什么不是AES聊聊移动端对称加密的硬伤很多人会问既然要加密为什么不直接用AES这里涉及到一个移动端加密的核心痛点密钥存储。AES是对称加密加密和解密用同一个密钥如果密钥硬编码在App里攻击者反编译后就能提取密钥直接解密服务器返回的数据或构造合法请求。RSA是非对称加密App里只存公钥私钥留在服务端即使攻击者看到公钥也无法反推私钥。所以在登录场景中用RSA加密密码是比AES更稳妥的选择。那为什么还会出现sign这种MD5签名因为RSA加密只保护了密码但username、timestamp这些字段还是明文如果没签名攻击者可以篡改。比如把timestamp改成一个很久以前的值或者替换username服务器如果没做严格校验就可能产生业务漏洞。加入sign并用secretKey参与计算服务端拿到请求后重新计算MD5如果跟sign不一致说明请求被篡改过直接拒绝。再加上timestamp和nonce即使签名算法泄露也无法重放旧请求。这其实是一个典型的“加密保护敏感信息签名保护完整性随机数防止重放”的组合方案。理解了这个设计意图后面的还原工作就轻松很多。2.3 要注意Anti-Frida和SSL Pinning不是所有App都这么温柔这个55chat还算良心Java层直接写了算法。但很多商用App会在AndroidManifest里开启networkSecurityConfig或者在Java层调用checkServerTrusted做SSL Pinning。这样即使配置了Charles代理抓包也会失败显示证书错误或者直接断连。遇到这种情况需要在测试机上挂Fridahook掉HTTP库的证书校验方法。Frida hook的基本套路是在Java层hookokhttp3.CertificatePinner如果用了OkHttp或者hookTrustManager相关的checkServerTrusted方法强行让它返回成功。也可以直接用objection的ssl_pinning_bypass命令一行命令搞定。另外如果App检测到Frida运行可能直接闪退或者进不了登录页。常见做法是Hook各种检测点比如io.github.lsposed相关的包名检查、/proc/self/maps里的frida痕迹检查。这些属于对抗复杂的范畴遇到再展开。这里先说清楚能做技术分析的是自己合法测试的App或者经过授权的安全测试项目千万不要拿这套流程去做未授权的事情。3. 算法还原与代码复现手把手写一个登录模拟器3.1 提取公钥和secretKey要复现登录必须具备两个关键素材RSA公钥、secretKey。从jadx的代码里直接复制公钥形如-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC... -----END PUBLIC KEY-----这里要注意公钥字符串里面可能包含换行符Python读取时要正确处理。建议把公钥保存成单独的pem文件或者用字符串时保留换行。secretKey是在同一个类中的另一个常量我看到的是一串32位的小写字母和数字类似a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6。这个值和App版本有关因为请求头里有X-Client-Version我不排除不同版本可能用不同secretKey。测试的时候如果发现签名对不上先确认当前App版本对应的secretKey是否和我一样。3.2 编写Python还原脚本拿到了素材之后直接写Python脚本。需要用到的库是Crypto即pycryptodome和hashlib、time、uuid。完整脚本如下import base64 import hashlib import time import uuid from Crypto.Cipher import PKCS1_v1_5 from Crypto.PublicKey import RSA PUBLIC_KEY_PEM -----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC... -----END PUBLIC KEY----- SECRET_KEY a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6 def rsa_encrypt_password(plain_password: str) - str: key RSA.import_key(PUBLIC_KEY_PEM) cipher PKCS1_v1_5.new(key) encrypted cipher.encrypt(plain_password.encode(utf-8)) return base64.b64encode(encrypted).decode(utf-8) def generate_sign(username: str, encrypted_password: str, timestamp: str, nonce: str) - str: raw_string f{username}:{encrypted_password}:{timestamp}:{nonce}:{SECRET_KEY} return hashlib.md5(raw_string.encode(utf-8)).hexdigest() def build_login_payload(username: str, password: str): encrypted_password rsa_encrypt_password(password) timestamp str(int(time.time() * 1000)) nonce uuid.uuid4().hex sign generate_sign(username, encrypted_password, timestamp, nonce) return { username: username, password: encrypted_password, sign: sign, timestamp: timestamp, nonce: nonce, } if __name__ __main__: payload build_login_payload(test_user_001, MyTestPass123!) print(payload)这段脚本做的事情和App里的逻辑一模一样。我推荐先拿抓包记录的原始请求字段做校验手动构造一个payload把sign和抓包得到的sign对比。如果一致说明算法还原成功如果不一致大概率是拼接顺序或者字段内容有问题比如是否包含换行、冒号分隔符的形式等等。3.3 踩坑记录Padding、编码和字段顺序第一个坑是PKCS1_v1_5和标准库的兼容问题。Java的RSA/ECB/PKCS1Padding对应的是PKCS#1 v1.5填充Python里用PKCS1_v1_5.new(key)就能对上。千万不要用OAEP虽然OAEP更安全但和App的Java实现不匹配服务端会解密失败。第二个坑是Base64编码的换行问题。Java的Base64.NO_WRAP表示输出不带换行Python的base64.b64encode默认也不带换行两边一致。但如果用了带换行的编码请求体里的password字符串中间会出现\n服务端和sign计算都会出错。第三个坑是secretKey的使用位置。我一开始想当然地把secretKey拼在最后算出来的sign总是和抓包不一致。后来认真看了jadx反编译的代码发现它用冒号把所有字段串成一个字符串比如用户名:加密密码:时间戳:nonce:secretKey。冒号本身就是分隔符不是额外加的。如果漏掉冒号或者把secretKey放到最前面整个MD5结果就不一样了。第四个坑是时间戳单位。App用的是13位毫秒时间戳不是10位秒时间戳。如果不小心用了秒级时间戳服务端会认为请求过期而且非配计算出来的sign也跟真实请求对不上因为timestamp本身就是签名的一部分。4. 常见问题与排查技巧这些坑我替你先踩了4.1 抓不到HTTPS包怎么办最典型的场景测试机装好Charles证书后App里登录还是报错Charles里显示SSL handshake failed。这基本都是SSL Pinning在起作用。解决办法是使用Frida绕过或者用objection的免root绕过。简单操作流程是在测试机上跑objection -g com.techbase.fiftyfivechat explore然后在objection的shell里执行android ssl pinning bypass。如果App有更严格的root检测还需要先配合Magisk隐藏root。有一种特殊情况是App用自己的私有HTTP库不走系统WebView或OkHttp这样的SSL Pinning绕过起来要麻烦一些。需要hook原生层的SSL_CTX_ctrl或SSL_CTX_set_verify但55chat的测试环境没有这么变态这里就不展开细节了。4.2 为什么每次加密后的password都不一样如果你发现同名密码两次登录加密结果不同这是正常现象。RSA带随机填充PKCS#1 v1.5时即使同一明文每次加密结果也不同。因为填充字节里有随机数密文每次变化。这个特性也意味着不能把加密后的password当做固定值缓存必须在每次请求前重新加密。但如果两次加密结果完全一样反而要怀疑App是不是用了AES或者固定随机种子。这个时候回看代码确认一下算法别被表象骗了。4.3 还原算法之后sign还是不对怎么排查我整理了一张排查表能处理大部分签名验证失败的情况可能原因排查方法secretKey版本不对确认当前App版本对应的secretKey有些App会在升级时更换拼接顺序错了用jadx反编译代码逐行比对特别是冒号和字段顺序参与签名的password是加密后的值不要用明文密码拼sign要用加密后的Base64字符串timestamp字段不一致确认用的是请求体里的timestamp而不是重新生成的时间戳nonce字段不一致nonce必须和请求体里的nonce保持一致编码问题检查Base64字符串是否多出换行、空格检查UTF-8编码如果仍然不对我建议直接在Frida里Hook生成sign的函数把入参打出来。这样能拿到App在真实请求里使用的原始字符串和Python里拼接的结果对比异常一目了然。这个方法在逆向排错时特别高效。4.4 如果算法被混淆得很深怎么办有些App会把Sign算法放到自定义的字符串编码中甚至用ProGuard做字符串加密jadx里看不到明文。这时候有几个方向在jadx里搜MessageDigest、Cipher、Mac这些密码学相关类基本能定位到加密点用objection的android hooking list activities等命令辅助定位最保险的是用Frida直接HookmessageDigest.digest()、Cipher.doFinal()这些系统API运行时观察入参和返回值。很多App做得再复杂最终还是要调用系统的密码学API。这个方法的核心思路是不要狗刨式看代码而是直接面向运行时。哪个函数输出了我们需要的数据就把哪里作为突破口。5. 写在最后的一点体会这次分析55chat的登录加密算法表面上看是破解了一个RSA加MD5的组合实际上就是一套移动端常见的安全设计模板RSA保护核心敏感数据签名保护请求完整性随机数防重放。很多聊天应用、电商应用、金融App的登录接口都沿用了类似思路。所以弄懂这一个案例以后遇到别的App登录加密至少能少走一半弯路。我个人在实操中最深的感受是逆向分析拼的不是纯技术而是耐心和细心。拿到一个加密请求先别急着反编译先把抓包数据研究透。密文长度、字段名称、参数变化规律这些外部特征能提供大量线索。然后带着线索去代码里找证据比自己扎进代码海洋瞎翻高效得多。最后再分享一个小技巧分析完算法后一定要写一个可以直接复现的脚本然后反复修改请求参数观察服务端的错误响应。有些App服务端会返回“参数错误”“签名错误”“时间戳过期”这类具体提示这些提示能反过来校验你对算法的理解。我就是通过不断比较返回信息确认了自己的拼接顺序是否准确。希望这篇记录对你有用。如果你在分析别的App登录加密时遇到了奇怪的现象欢迎交流但记住只做合法范围内的安全研究。