最近接了个移动端App的兼容性测试任务,其中一个叫55chat的聊天应用在登录流程里做了不少手脚。用户名密码不是明文提交,请求体里还多了一个sign字段。折腾了一天时间,从抓包、反编译、定位加密代码,到最后写脚本复现登录,整个链路总算走通了。这篇文章就把这次“记录55chat登录加密算法分析”的完整过程整理出来,重点讲讲我是怎么一步步判断出算法类型、还原加密流程,以及这个过程中踩过哪些坑。如果你也在做App安全测试、接口调试,或者单纯想了解移动端登录加密的常见套路,可以参考一下我的思路。
1. 从抓包到接口定位:先弄清楚登录请求里到底有什么
1.1 登录请求的原始长相
分析的第一步永远是抓包,不是看代码。拿一台已经装好55chat的Android测试机,配置好Charles代理,然后在App里点击登录。这里提醒一句:我用的都是测试账号,在合规的测试环境里操作,不要拿真实用户数据去试。
登录请求很快就出来了。接口路径大致是/api/v1/auth/login,POST方法,请求体是一段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或“RSA+AES混合加密”的其中一种。
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代理,抓包也会失败,显示证书错误或者直接断连。遇到这种情况,需要在测试机上挂Frida,hook掉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直接Hook
messageDigest.digest()、Cipher.doFinal()这些系统API,运行时观察入参和返回值。很多App做得再复杂,最终还是要调用系统的密码学API。
这个方法的核心思路是:不要狗刨式看代码,而是直接面向运行时。哪个函数输出了我们需要的数据,就把哪里作为突破口。
5. 写在最后的一点体会
这次分析55chat的登录加密算法,表面上看是破解了一个RSA加MD5的组合,实际上就是一套移动端常见的安全设计模板:RSA保护核心敏感数据,签名保护请求完整性,随机数防重放。很多聊天应用、电商应用、金融App的登录接口都沿用了类似思路。所以弄懂这一个案例,以后遇到别的App登录加密,至少能少走一半弯路。
我个人在实操中最深的感受是:逆向分析拼的不是纯技术,而是耐心和细心。拿到一个加密请求,先别急着反编译,先把抓包数据研究透。密文长度、字段名称、参数变化规律,这些外部特征能提供大量线索。然后带着线索去代码里找证据,比自己扎进代码海洋瞎翻高效得多。
最后再分享一个小技巧:分析完算法后,一定要写一个可以直接复现的脚本,然后反复修改请求参数,观察服务端的错误响应。有些App服务端会返回“参数错误”“签名错误”“时间戳过期”这类具体提示,这些提示能反过来校验你对算法的理解。我就是通过不断比较返回信息,确认了自己的拼接顺序是否准确。
希望这篇记录对你有用。如果你在分析别的App登录加密时遇到了奇怪的现象,欢迎交流,但记住只做合法范围内的安全研究。