好友
阅读权限 10
听众
最后登录 1970-1-1
xvguu
发表于 2026-6-23 19:34
本文仅限安全研究与学习交流, 严禁将本文内容用于任何商业或非法用途,本文涉及的代码及资源版权归原权利人所有,侵删。
APK版本——9.3.8,已经是上古时代的apk了,目前app已经停止运营。
所用工具:Fiddler、jadx-gui、DDMS、雷电模拟器9.1(64位)
首先配置模拟器代理(本机IP和Fiddler的默认端口号888)
在模拟器中启动App,选择“RR账号登录”,随机输入账号和密码。
点击登录按钮。Fiddler 共捕获到四个关键请求,如下图所示:
四个包依次为:
公钥获取请求
客户端向 http://login.renren.com/ajax/getEncryptKey 发起 GET 请求,尝试获取 RSA 公钥。由于服务器已停止运营,该接口返回 404 Not Found。
登录请求
POST 至 http://api.m.renren.com/api/client/login,携带完整的登录参数(账号、密码密文、设备指纹、签名等)。
错误日志上报
登录失败后,客户端自动将错误信息(error_code=10002)打包上报至 /api/apierror 接口。
用户行为日志
客户端记录用户操作轨迹(如点击按钮、切换输入框等),并发往 /api/phoneclient/opLog2 用于埋点统计。
重点分析登录请求的数据包:登录请求体采用 application/x-www-form-urlencoded 格式,包含两个核心部分:client_info:经 URL 编码的 JSON 对象,内含设备指纹(IMEI、IMSI、型号、系统版本、屏幕分辨率等)。顶层业务参数:包括 user(账号)、password(密码密文)、call_id(时间戳)、api_key(应用公钥)、sig(签名)等。服务器返回的响应体为 Gzip 压缩格式,解压后实际内容为:{"error_code":10002, "error_msg":"账号或密码输入错误,请检查后重试"}
已知登录调用的方法中有一个方法会发送以上的参数进行请求,因此打开Jadx通过搜索参数找到该方法的实现,从而进行逆向分析,参数有很多,一般搜索不常用且最长的效率比较高,这里搜索`tab_sequence`只有一处调用,很容易就可以定位到登录函数中发送请求代码的部分。
点击搜索结果,调用方法的反编译原型如下:
[Java] 纯文本查看 复制代码
public static void a(final String str, final String str2, int i, String str3, String str4, final Context context, final LoginStatusListener loginStatusListener)
分析a方法,首先调用A函数生成一个jsonObjectA对象
[Java] 纯文本查看 复制代码
JsonObject jsonObjectA = A(false, true);
跟进A方法,该函数是人人网客户端所有网络请求的基础参数构造入口,负责统一注入 `v`(版本号)、`api_key`(应用公钥)、`call_id`(本地时间戳)以及根据登录态动态添加 `session_key`(会话凭证),并通过调用 `g()` 方法以单键值对的形式,将 `iR()` 所采集的全部设备指纹信息以 JSON 字符串打包,统一注入 `client_info` 字段。
回到a方法接着分析,调用完A方法后又接着往jsonObjectA对象输入参数,这里对应了Fiddler中抓到包中的参数。
生成第一个sig。
[Java] 纯文本查看 复制代码
String[] keys = jsonObjectA.getKeys(); // 获取所有参数名
for (String str5 : keys) {
// 把 参数名=参数值 用 & 连成一长串
sb.append(str5).append('=').append(URLEncoder.encode(string)).append('&');
// 如果参数值太长(>50个字符),只截取前50个
}
// 调用 a(strArr, jhS) 生成最终的sig
jsonObjectA.put("sig", a(strArr, jhS));
跟进a(strArr, jhS),分析签名生成算法,算法定义了人人网登录签名的生成规则:将所有 键.值 按字典序升序排列后直接首尾相连,再追加固定密钥(jhS),最后计算 MD5 值。jsonObjectA.put("sig", a(strArr, jhS));调用了一次生成了包中的第一个sig。
查看jhS密钥,定义为一个字符串,调用了setSecretKey函数生成
查看setSecretKey函数,没有什么有用的信息
查看jhS的交叉引用,关注第一个引用,jhS的值是通过string赋值来的
跟踪进赋值的代码出,可以看到string是从 String string = RenrenApplication.getContext().getResources().getString(R.string.secretkey); 中的 R.string.secretkey 中获得,这通常是从APK 内置的 res/values/strings.xml 资源文件中读取到的
点击跟进可以看到secretkey在R.java中的资源ID值
这里在apk中搜索在strings.xml并没有找到,这是因为通过AndResGuard 等资源混淆工具处理后的 APK 中,传统的 res/values/strings.xml 文件已被物理删除,其所有字符串常量均被压缩合并至根目录的 resources.arsc 二进制资源表中 但是在Jadx-GUI 加载 APK 时,会自动解析 resources.arsc,并在右侧的“资源管理器”里虚拟重建 出一个 res/values/strings.xml。
勾选资源,搜索secretkey,在重建后的strings.xml中找到secretkey的具体值,这里就拿到了第一次sig生成时的jhS的密钥值了。
[Java] 纯文本查看 复制代码
<string name="secretkey">ad974a0756d84cec80fcea72fcbfba9f</string>
再次回到最开始的a函数接着分析,public final void response(...) 部分是服务器响应后,App根据响应做不同的反应: 1.看是不是报错 :jsonObject2.getNum("error_code")。如果是 -99 或 -97,就弹窗提示“无法连接网络”。 2.如果是密码错误(10002) :调用 loginStatusListener.b(num, string2, string3),你的手机屏幕上就会弹出“账号或密码输入错误”。 3.如果登录成功 : (1)把账号密码明文存进手机本地数据库(Variables.password = str2) (2)把用户的昵称、头像存进内存(TalkManager.INSTANCE.initUserInfo)。 (3)弹窗说“登录成功”(loginStatusListener.onLoginSuccess())。 a方法的代码最后几行: [Java] 纯文本查看 复制代码
httpRequestWrapper.setUrl(jgH + "/client/login"); // 生成发送的目标的url
httpRequestWrapper.setData(jsonObjectA); // 封装参数给客户端
httpRequestWrapper.setResponse(iNetResponse); // 获取响应
HttpProviderWrapper.getInstance().addRequest(httpRequestWrapper); // 发送请求 到此,a方法已经分析完毕,抓到的包中的除了第二个sig外都能够在方法中找到,接下来找第二个sig的方法实现。这里有两个思路,一是AS\JEB动态调试找到生成第二个sig的方法位置,二是已经找到了sig签名的核心生成函数,即jsonObjectA.put("sig", a(strArr, jhS));中的a(strArr, jhS),追踪到a方法的原型,对a方法进行交叉引用进行分析即可。 这里使用第二个方法,如下图是a方法的交叉引用结果,一共有四处出现了"sig"字符串,挨个分析,第一个调用是上面分析的a方法中用来生成第一个sig,第二个调用在h方法中定义了登录后的“获取乐视云视频Token”接口,第四个调用在toString() 方法中是人人网客户端所有网络请求的通用序列化入口。第三个调用也就是 cba() 方法中是人人网客户端实际用于网络传输的请求序列化入口 ,该方法对参数值进行了 URL 编码。很显然,对四处调用进行分析后,第三个很显然是第二个sig的生成,当然了这里仅是静态分析,有分析错误的可能,也可以动态调试验证一下。
查看cba函数的反编译代码,主要关注最后的sig生成调用:把排好序、截断后的参数数组( strArr )加上密钥( secretKey )搅出 MD5,贴在字符串最后。和第一处调用不同的是第一处的密钥是jhS,第二处的密钥是secretKey。
和分析jhS密钥一样,对secretKey进行交叉引用,交叉引用结果的第一处就是给secretKey进行赋值
回到对jhT的赋值处,也可以看到jhT也是通过string进行赋值为secretkey的值,也就是说第二次sig的密钥secretKey为secretkey的值,和第一次sig的密钥jhS的值相同
这里对数据包以及发送数据包的代码分析已经完毕,但是在这段代码中没有看到真正的登录逻辑,在模拟器中我们是点击登录按钮才触发的上面的一系列的事件,众所周知,按钮需要关注的代码为onClick,对a函数进行交叉引用,可以看到搜索结果有两处是在onClick中调用了a方法。两个onClick不同的是一个在loginB中调用,一个实在loginfree中调用。
这里为什么有两个onClick调用,猜测应该是在登录页面时有两种登录方式对应了两个onClick的调用。 我们这里使用的是“人人账号登录”,为了定位到想分析的onClick,这里使用ddms的方法刨析进行验证。 win+r输入ddms即可打开ddms。 点击 DDMS 工具栏上开始方法分析的按钮。是三个小箭头指向一个红点。在弹出的对话框中,直接点击 OK 使用默认设置即可。在模拟器上快速 点击“登录”按钮。再次快速 点击 DDMS 中那个开始分析的按钮来停止记录。 这时会打开Traceview 工具窗口,展示完整的分析结果。在打开的方法刨析窗口中搜索onClick,成功找到点击登录时调用的onClick的具体位置:com.renren.mobile.android.loginB.register.ui.LoginFromQuickRegisterFragment.onClick 很显然我们的登录方式是在loginB中被使用,追踪分析反编译代码。主要使用了 switch-case 语句,根据被点击视图的 ID(view.getId())来决定执行哪一段代码 逐个按钮进行分析 1.R.id.password_inputtype_change —— 密码可见/不可见切换 fKI 是一个布尔值,记录当前密码是否可见,fMk 是一个 ImageView,用来显示小眼睛图标,fPk 是密码输入框(EditText)。点击后切换密码的显示方式,同时更换图标,最后把光标移到输入框末尾(方便继续输入)。 [Java] 纯文本查看 复制代码
if (this.fKI) {
// 当前是可见状态,改为不可见
this.fMk.setImageResource(R.drawable.intput_passwod_visiable);
this.fPk.setTransformationMethod(HideReturnsTransformationMethod.getInstance());
this.fKI = false;
} else {
// 当前是不可见状态,改为可见
this.fKI = true;
this.fMk.setImageResource(R.drawable.intput_passwod_unvisiable);
this.fPk.setTransformationMethod(PasswordTransformationMethod.getInstance());
}
this.fPk.setSelection(this.fPk.getText().length()); 2. R.id.planb_login_from_publish_forgetpsd_btn —— 忘记密码 创建一个数据包 Bundle,放入“返回按钮文字”、“中间标题”和一个 URL(this.fKr),然后调用 TerminalIAcitvity.a 方法,启动一个 WebView 页面,用于显示忘记密码的网页。 [Java] 纯文本查看 复制代码
Bundle bundle = new Bundle();
bundle.putString("titleLeft", ...);
bundle.putString("titleMiddle", ...);
bundle.putString("url", this.fKr);
TerminalIAcitvity.a(SY(), (Class<?>) BaseWebViewFragment.class, bundle); 3. R.id.planb_login_from_publish_login_btn —— 登录按钮 清空一些缓存设置 [Java] 纯文本查看 复制代码
SettingManager.bwT().nD("");[/size][/font][font=宋体][size=4]获取用户输入的用户名和密码,fPl 是用户名输入框,fPk 是密码输入框,它们被存到静态变量 Variables 中
[/size][/font][font=宋体][size=4][mw_shl_code=java,true]Variables.hJU = this.fPl.getText().toString().trim();
Variables.password = this.fPk.getText().toString().trim();
接下来检查输入是否为空,如果用户名为空,弹出提示“账号不能为空”。如果密码为空,弹出提示“密码不能为空”。 然后检查用户名是否包含中文,Methods.qq 判断是否包含中文字符的方法。如果包含中文,提示“账号不能包含中文”。 [Java] 纯文本查看 复制代码
if (Methods.qq(Variables.hJU)) { ... } 再检查密码是否包含中文,如果密码包含中文,提示“密码不能包含中文”。 密码加密——如果服务器成功下发了RSA公钥,就用 RSA 非对称加密 保护密码。如果获取公钥失败,就降级为 MD5 加密。 [Java] 纯文本查看 复制代码
dPl = RSA.ccz();
this.n = RSA.ccB();
this.e = RSA.ccA();
if (dPl != null) {
// 使用 RSA 公钥加密密码
Variables.password = RSA.D(Variables.password, this.n, this.e);
RSA.lAT = 1;
} else {
// 否则使用 MD5 加密
Variables.password = Md5.toMD5(Variables.password);
RSA.lAT = 2;
} 发起登录请求,显示加载进度,然后调用 ServiceProvider.a 发送登录请求,参数里包含了加密后的密码。 [Asm] 纯文本查看 复制代码
if (用户名和密码非空) {
if (this.fPp != null) {
this.fPp.show(); // 显示一个进度条
}
// 调用网络请求,把用户名、密码等发给服务器
ServiceProvider.a(Variables.hJU, Variables.password, 1, "", dPl, SY(), this.dPt);
} 4.R.id.service_configuration —— 调试配置 启动一个调试管理页面 [Asm] 纯文本查看 复制代码
startActivityForResult(new Intent(SY(), (Class<?>) DebugManagerActivity.class), -1); 5.第三方登录按钮(微信、QQ、微博) 调用同一个方法 oy(),传入不同数字区分平台,进行第三方授权登录 [Asm] 纯文本查看 复制代码
case R.id.third_login_layout_weixin_button:
oy(2); // 微信
break;
case R.id.third_login_layout_qq_button:
oy(1); // QQ
break;
case R.id.third_login_layout_weibo_button:
oy(3); // 微博
break; 至此,登录的核心逻辑也已经分析完毕,这里补充一下查看按钮ID值的方法:打开下载SDK目录下的tools文件夹中的uiautomatorviewer.bat 工具栏主要包含四个按钮(从右向左数): Open Files :打开已保存的 .uix 布局文件和截图。Device Screenshot :连接设备并获取当前屏幕的界面布局(最常用)。Compressed Hierarchy :获取简化版的界面布局,会过滤掉一些纯装饰性的布局节点。Save :保存当前捕获的界面信息,会生成一个截图文件和一个 .uix (XML布局结构) 文件。接下来分析用来加密的算法RSA。算法主要做了两件事,一是从服务器获取 RSA 公钥 (auN() 方法),二是用公钥加密明文密码 (D() 方法)。 一、静态变量(存储加密参数) dPl 就是之前登录代码里判断是否为 null 的那个变量。lAT 用来标记最终使用的是哪种加密方式,便于后续日志或统计。 [Java] 纯文本查看 复制代码
public static String dPl = null; // 公钥字符串(从服务器获取)
public static String e = null; // RSA 加密指数(Exponent)
public static String n = null; // RSA 模数(Modulus)
public static int lAT = 0; // 加密模式标记:1=RSA,2=MD5(降级) 二、auN():从服务器获取 RSA 公钥 向 http://login.renren.com/ajax/getEncryptKey 发起 HTTP GET 请求。读取服务器返回的第一行内容(应为公钥字符串)。如果读取不到,返回 null。 [Java] 纯文本查看 复制代码
static /* synthetic */ String auN() throws IOException {
BufferedReader bufferedReader = new BufferedReader(
new InputStreamReader(
new URL(" http://login.renren.com/ajax/getEncryptKey").openStream(),
"GB2312"
)
);
String line = bufferedReader.readLine();
bufferedReader.close();
if (line != null) {
return line;
}
return null;
} 三、D():RSA 加密核心方法 创建 RSA 加密器(Cipher.getInstance)。用传入的模数 str2(n)和指数 str3(e)构造公钥,初始化加密模式。对明文密码(str.getBytes())进行 RSA 加密,得到加密后的字节数组。将字节数组逐字节转为 16 进制字符串 ,拼接成最终的密文。 [Java] 纯文本查看 复制代码
public static String D(String str, String str2, String str3)
throws BadPaddingException, NoSuchPaddingException,
IllegalBlockSizeException, NoSuchAlgorithmException, InvalidKeyException {
Cipher cipher = Cipher.getInstance(RSAUtil.ALGORITHM_RSA);
cipher.init(1, bu(str2, str3)); // 1 = ENCRYPT_MODE
byte[] bArrDoFinal = cipher.doFinal(str.getBytes());
StringBuffer stringBuffer = new StringBuffer();
for (byte b : bArrDoFinal) {
stringBuffer.append(Integer.toString((b & 255) + 256, 16).substring(1));
}
return stringBuffer.toString();
} bu(str2, str3) 是 new RSAPublicKeySpec(n, e) 的封装,用于从 n 和 e 生成 Java 的 PublicKey 对象。 至此,登录的加密算法也已经分析完毕,采用的是标准 RSA 非对称加密来保护密码传输。 Frida HOOK RSA中的D算法 [Java] 纯文本查看 复制代码
jscode ="""
Java.perform(function () {
var RSA = Java.use('com.renren.mobile.utils.RSA');
// 精确指定重载:D(String, String, String)
var D = RSA.D.overload('java.lang.String', 'java.lang.String', 'java.lang.String');
D.implementation = function (plaintext, n, e) {
console.log("\n[+] Hook D() 被触发!");
console.log("[+] 明文密码: " + plaintext);
console.log("[+] 模数 n: " + n);
console.log("[+] 指数 e: " + e);
// 调用原方法计算密文
var result = this.D(plaintext, n, e);
console.log("[+] RSA 密文: " + result);
console.log("[+] Hook 结束\n");
return result;
};
console.log("[*] RSA.D() Hook 已安装");
});
""" Xposed HOOK RSA中的D算法 [Java] 纯文本查看 复制代码
package com.qianyu.xposedhook;
import de.robv.android.xposed.IXposedHookLoadPackage;
import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.XposedHelpers;
import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam;
public class Module implements IXposedHookLoadPackage {
@Override
public void handleLoadPackage(LoadPackageParam lpparam) throws Throwable {
if (!lpparam.packageName.equals("com.renren.mobile.android")) {
return;
}
XposedBridge.log("目标包名: " + lpparam.packageName);
// Hook RSA.D(String, String, String) —— RSA 加密方法
XposedHelpers.findAndHookMethod(
"com.renren.mobile.utils.RSA", // 类名
lpparam.classLoader, // 类加载器
"D", // 方法名
String.class, // 参数1:明文密码
String.class, // 参数2:模数 n
String.class, // 参数3:指数 e
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log("========== RSA.D() 被调用 ==========");
XposedBridge.log("密码明文: " + param.args[0]);
XposedBridge.log("模数 n : " + param.args[1]);
XposedBridge.log("指数 e : " + param.args[2]);
// 打印调用堆栈(帮助定位调用来源)
XposedBridge.log("调用堆栈:");
StackTraceElement[] stack = new Throwable().getStackTrace();
for (StackTraceElement elem : stack) {
XposedBridge.log(" " + elem.toString());
}
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
// 获取加密后的结果(十六进制字符串)
Object result = param.getResult();
XposedBridge.log("RSA 密文: " + (result != null ? result.toString() : "null"));
XposedBridge.log("========== RSA.D() 结束 ==========");
// 注意:不要修改 result,直接返回原值
}
}
); 编译打包后,安装模块执行后HOOK的结果 链接: https://pan.baidu.com/s/1bigpkMPgi9UqBmnYaKqnRw 提取码: 6md3
免费评分
查看全部评分