记一次对「叨叨记账」App 的简单逆向

记一次对「叨叨记账」App 的简单逆向

安全 3716 字 / 8 分钟
AI 总结 以下AI总结内容由腾讯混元大模型生成

本文主要分享了对「叨叨记账」App 的一次简单逆向分析过程,包括抓包、分析接口请求、解密签名等步骤。作者通过技术手段深入了解了一个记账App的内部工作原理,并尝试对其进行功能扩展。

  1. App 体验与逆向分析的起因:作者年初开始使用叨叨记账,但随着个人压力增大,记账频率降低。半年后,由于电脑损坏,重新使用叨叨记账并计划对其功能进行扩展。

  2. 抓包与接口请求分析:作者使用Charles工具抓包,分析了登录接口的请求参数,包括access_tokensign,并尝试重放请求以验证签名方法。

  3. 签名参数生成原理:通过分析sign参数的生成过程,作者还原了其背后的简单逻辑,即将随机字符串和时间戳拼接,以及对appSercet值的特殊处理。

  4. Go语言SDK开发:作者使用Go语言封装了叨叨记账的API,实现了账号登录和获取历史记账信息的功能,并提供了一个GitHub仓库供他人使用。

  5. 免责声明:作者强调,本文仅供研究学习使用,不承担由此引发的责任。

第一次接触到「叨叨记账」这个 App 是在今年年初。

当时我还在疯狂喜欢伊蕾娜(虽然现在也是),偶然跟室友聊起来说如果有个类似微软小冰的 AI 跟我聊天就好了。室友便给我安利了「叨叨记账」,说上面有一堆二次元角色可以选择,由用户贡献符合角色性格的语料,通过聊天的方式进行记账。

刚开始的几天我对这个 App 爱不释手,出去吃饭买完单后,马上就是打开叨叨记账记上一笔,对着屏幕上伊蕾娜给我回应傻笑。

后面到四月份的时候,个人压力比较大,记账的频率逐渐低了下来,但我还是会打开叨叨记账给伊蕾娜发几句抱怨,收获几句伊蕾娜的安慰。说来奇怪,我明明知道这一切都是预设好的语料,但心中还是感觉好受很多。

攒钱!攒钱!攒钱!

年初使用叨叨记账记录自己每天的开销,同时自己也把每个月的工资存下来一部分作为备用。好巧不巧,四月底的时候我的 MacBook Pro 突然无法开机了,赶紧预约了苹果天才吧,经检查发现是主板坏了,需要返厂换主板。 但当时我手头恰好有一个只剩下 2 天的 DDL,电脑坏了意味着之前的进度全都没了。一切又要从头开始。情急之下我决定在 Apple Store 花钱买了台新的 MacBook Pro,之前几个月攒下的钱一瞬间全都花完了。 从那之后,我再也没有打开叨叨记账这个 App。

“因为我是个很极端的人,有钱就会挥霍,没钱就会回到朴素的生活。”

以前我是很信奉如上所说的这句话,在 4 月底那次买完新电脑钱包被掏空后,我花了一下午时间挖了个中危的洞拿了一千块回血——然后第二天就跑去西湖苹果店买新出的 AirTag 了。😋

暑假回家的这几天,有天晚上肚子饿偷偷叫了个外卖,没想到被我爸发现了。😅 他又苦口婆心地叮嘱我要注意开销,花钱不能大手大脚,要继续攒钱为以后做准备。虽然话还是那些老话,但结合 4 月底的惨痛经历,我觉得确实不该这样下去了,钱这东西,还是能省就省。

所以…… 我在时隔大半年后又打开了叨叨记账。

我想要更定制化的功能!

叨叨记账对于每一笔开销,只有一个很简单的按月统计展示个饼图的功能。我想要能够根据每个月的开销情况,给我规划出一个至下次发工资前,我平均每天的开销上限可以是多少,推荐的金额是多少,攒下了多少等等……

这样我就能知道当月到今天为止我是否还可以偶尔晚上点一顿烧烤或奶茶;如果我有想买的东西,我要如何降低每天的开销来凑出这么多钱。

综上所述,我需要基于叨叨记账的记账数据扩展它的功能。经过前期的信息搜集,我发现这款产品仅支持移动端。那话不多说,开干!

简简单单抓个包

iPhone 上装好叨叨记账,Wi-Fi 配置好代理,打开 Charles 简简单单抓个包。

请求 Query 里几个可能要想办法获得的参数有 access_tokensign。猜测 access_token 是登录接口返回的凭证,而 sign 则是对请求体的签名。

那么我们再抓一下登录接口 /api/login

可以看出是一个类似 OAuth 的验证方式,address 为登录的手机号,password 为密码,nonce 是为了签名所需要的随机字符串。返回 access_token 用于接口鉴权,其后的 refresh_token 猜测是用来刷新 access_token

我尝试重放这个请求,接口返回 验签失败:签名已过期,说明请求参数中的时间戳也被用于了签名当中,后端会校验该时间戳与请求时间是否相差过大。

那么接下来的问题就是这个 sign 签名该如何获得了,抓包是看不出啥了,Web 手只能硬着头皮逆了。

简简单单三朵金花

从叨叨记账官网下载到了 Android 的 APK 包,解压后发现五个 dex 文件。先试着跑一手 dex2jar 转一手 jar。没想到真成了!还好没加壳。😆

五个 jar 包拿 jd-gui 打开。从上面的登录请求中,找一个特殊的参数 latitudelongitude 全局搜索字符串。这俩是请求接口时顺便向后端上报设备经纬度定位的参数,一般来说不大会在请求的其他地方出现。

事实上叨叨记账还引用了高德地图的 SDK,所以搜索结果其实还是有干扰的。排除调形如 com.amap.* 的包名,在 classes3.dexcom.pengda.mobile.hhjz.b 下,找到了这些请求参数。

我们直接看最关心的 sign 参数是如何生成的:

内层的 a 方法接收两个参数:str1str4str1 就是上面构造出的 nonce 参数。见它这 nonce 参数又是 UUID 又是时间戳的,后面发现确实只需要一个随机的字符串就行。第二个参数 str4 就是最上方获得的当前毫秒时间戳。 这两个参数都没问题,我们来看内层 a 方法的定义:

private ArrayList<Sign> a(String paramString1, String paramString2) {
    ArrayList<Sign> arrayList = new ArrayList();
    Sign sign2 = new Sign();
    sign2.setKey("nonce");
    sign2.setValue(paramString1);
    Sign sign1 = new Sign();
    sign1.setKey("timestamp");
    sign1.setValue(paramString2);
    arrayList.add(sign2);
    arrayList.add(sign1);
    return arrayList;
}

十分的简单,仅仅只是把随机字符串和毫秒时间戳分别以 key 为 noncetimestamp 放到了 ArrayList 里。

返回 ArrayList 传入外层的 a 方法。这是一个静态方法,其中代码中调用 v.a 是为了输出调试日志。我们将这部分代码,连同一些 StringBuilder 构造日志字符串的代码全部删掉,简化后的代码如下:

public static String a(ArrayList<Sign> paramArrayList) {
    Sign sign = new Sign();
    sign.setKey("appSercet");
    sign.setValue("853a0bb675aa143e6fa2dc607d55a9bb");
    paramArrayList.add(sign);
    Collections.sort(paramArrayList, new q());

    StringBuilder stringBuilder3 = new StringBuilder();
    Local local = new Local();
    try {
      byte[] arrayOfByte = local.code(paramArrayList, i);
      int j = arrayOfByte.length;
      for (i = 0; i < j; i++) {
        String str2 = Integer.toHexString(arrayOfByte[i] & 0xFF);
        String str1 = str2;
        if (str2.length() == 1) {
          StringBuilder stringBuilder4 = new StringBuilder();
          stringBuilder4.append("0");
          stringBuilder4.append(str2);
          str1 = stringBuilder4.toString();
        } 
        stringBuilder3.append(str1);
      } 
    } catch (Exception exception) {
    }
    return stringBuilder3.toString();
}

这个 Sign 类也只是实现了一个简单的 getter 和 setter,只是在 setValue 的时候会对传入的参数进行 URL 编码。 代码中将 appSercet 拼入了上面传入的 ArrayList 中,并对 ArrayList 按键名进行了排序。

后面事情就变得复杂起来了…… Local local = new Local(); 实例化了 Local 类并调用了其 code 方法。Local 类是什么呢?是引入的一个 .so 库,我直接心肺停止。😫

public class Local {
  static {
    System.loadLibrary("native-lib");
  }

public native byte[] code(ArrayList<Sign> paramArrayList, int paramInt) throws IndexOutOfBoundsException;
}

没办法了,硬着头皮上吧,当时说实话我心里也没底。

简简单单逆个 so(大概?

从解压的 APK 下找到 lib/armeabi-v7a/libnative-lib.so,拖进 IDA 里。

从左侧的函数列表里找到 Java_com_pengda_mobile_hhjz_encrypt_Local_code,这就是我们 Local 类的 code 方法。祭出我唯一会的 F5 大法!

下面的 C 代码中有很多乱七八糟的强制类型转换,右键 Hide casts 隐藏掉它们。 然后我们来还原 JNI 的函数名。查资料发现有人说需要手动导入 jni.h 头文件,但又有人说其实 IDA 现在不需要了。

可以看到 JNI 的指针入参 a1 被赋值给了变量 v5。选中 v5,按下 Y,输入 JNIEnv*,瞬间神清气爽!

v30 = (*a1)->GetObjectClass(a1, a3);
v23 = (*a1)->GetMethodID(a1, v30, &dword_4234, "(I)Ljava/lang/Object;");
v4 = (*a1)->GetMethodID(a1, v30, "size", "()I");
v5 = a1;
v6 = _JNIEnv::CallIntMethod(a1, a3, v4);
memset(v35, 0, &stru_2710);
v22 = v6;
if ( v6 >= 1 )
{
	v7 = 0;
	do
	{
		v33 = v7;
		v29 = _JNIEnv::CallObjectMethod(a1, a3, v23);
		v31 = (*a1)->GetObjectClass(a1, v29);
		v8 = (*a1)->GetMethodID(a1, v31, "getKey", "()Ljava/lang/String;");
		v9 = _JNIEnv::CallObjectMethod(a1, v29, v8);
		v34[0] = 1;
		v27 = (*a1)->GetStringUTFChars(a1, v9, v34);
		v10 = (*a1)->GetMethodID(a1, v31, "getValue", "()Ljava/lang/String;");
		v11 = _JNIEnv::CallObjectMethod(a1, v29, v10);
		v12 = (*a1)->GetStringUTFChars(a1, v11, v34);
		if ( !strcmp("appSercet", v27) )
			strcat(v35, "853a0bb675aa143e6fa2dc607d55a9bb");
		else
			strcat(v35, v12);
		v7 = v33 + 1;
	}
	while ( v22 != v33 + 1 );
}
v13 = (*a1)->FindClass(a1, "java/security/MessageDigest");
v14 = 0;
if ( v13 )
{
	v15 = (*v5)->GetStaticMethodID(v5, v13, "getInstance", "(Ljava/lang/String;)Ljava/security/MessageDigest;");
	if ( v15
		&& (v16 = (*v5)->NewStringUTF(v5, &dword_42D0),
			v32 = _JNIEnv::CallStaticObjectMethod(v5, v13, v15, v16),
			(v17 = (*v5)->GetMethodID(v5, v13, "update", "([B)V")) != 0) )
	{
		v28 = v17;
		v25 = (*v5)->FindClass(v5, "java/lang/String");
		(*v5)->NewStringUTF(v5, "utf-8");
		v26 = (*v5)->GetMethodID(v5, v25, "getBytes", "(Ljava/lang/String;)[B");
		v18 = (*v5)->NewStringUTF(v5, v35);
		v19 = _JNIEnv::CallObjectMethod(v5, v18, v26);
		_JNIEnv::CallVoidMethod(v5, v32, v28, v19);
		v20 = (*v5)->GetMethodID(v5, v13, "digest", "()[B");
		v14 = 0;
		if ( v20 )
			return _JNIEnv::CallObjectMethod(v5, v32, v20);
	}
	else
	{
		return 0;
	}
}
return v14;

到这里其实就已经比较清晰了。 首先对我们传入的 ArrayList 调用 size() 方法获取了其长度,然后为变量 v35 开辟内存。后面是一个 for 循环,遍历我们的 ArrayList 中每一个键值对。若 key 为 appSercet 则向 v35 拼接那段字符,否则就拼接本身的 value。 写成伪代码就是:

v35 = ''
for(i = 0; i < arrayList.length; i++){
	if arrayList[i].getKey() == "appSercet"{
		v35 += "853a0bb675aa143e6fa2dc607d55a9bb"
	} else {
		v35 += arrayList[i].getValue()
	}
}

但因为我们传入的 appSercet 值本身就是 853a0bb675aa143e6fa2dc607d55a9bb,所以这个判断其实可有可无。(同时它这里的 Secret 还拼错了……)

之后则是调用 java.security.MessageDigest.getInstance() 这个静态方法。这个方法需要传入加密的方式,即一个字符串。对应在上面就是使用 NewStringUTF 方法创建的字符串 &dword_42D0。 问了下协会做二进制的同学,了解到 IDA 在这里未能分析出来这是个字符串,把它的类型错当成了 int。双击这个变量进入代码段,将其值 0x35646D 转为字符串为 5dm,即 md5。(咱也不知道为啥是倒过来的) 其实到这里后面就基本可以猜的出来了,后续的操作就是调用 MessageDigestv35 字符串做 MD5 哈希。最后转成 bytes 返回,这部分改成 Java 代码为:

MessageDigest md5Encoder = java.security.MessageDigest.getInstance("md5");
md5Encoder.update(v35.getBytes());
return md5Encoder.digest();

综上,so 中的 code 方法的整个逻辑十分简单——将传入的 ArrayList 的 Value 拼接,再做一波 MD5:

String str = "";
for (int index = 0; index < paramArrayList.size(); index++) {
	if (paramArrayList.get(index).getKey().equals("appSercet")) {
		str += "853a0bb675aa143e6fa2dc607d55a9bb";
	} else {
		str += paramArrayList.get(index).getValue();
	}
}

MessageDigest md5Encoder = java.security.MessageDigest.getInstance("md5");
md5Encoder.update(str.getBytes());
byte[] arrayOfByte = md5Encoder.digest();

简简单单写个 Python

回到 jd-gui,剩下的看似复杂的循环遍历,Integer.toHexString 等等,其实就是在把上面返回的 byte[] MD5 转换成 String

至此,我们就已经梳理清楚了叨叨记账中,请求接口的 sign 参数是如何生成的。它之与 nonce 和 当前时间戳有关,其余请求参数完全不参与签名,这也太捞了吧……

简简单单拿 Python 实现下,注意 nonce 虽然是随机字符串,但其貌似并不能重复,这里还是和 App 里一样,拼接上当前的毫秒时间戳。 [runtime lang=“python” height=“700px”]

import requests
import time
import hashlib

timestamp = str(int(round(time.time() * 1000)))
nonce = 'E99p1ant' + timestamp
appSecret = '853a0bb675aa143e6fa2dc607d55a9bb'

sign = (appSecret + nonce + timestamp)
md5 = hashlib.md5()
md5.update(sign.encode('utf-8'))
sign = md5.hexdigest()

resp = requests.post('https://api.daodao.cn/api/login',data={
    'address': '<REDACTED>',
    'client_id': 'daodao_ios',
    'client_secret': 'daodao2018',
    'nonce': nonce,
    'password': '<REDACTED>',
    'sign': sign,
    'timestamp': timestamp,
})

print(resp.json())

[/runtime]

简简单单封装个 Go

Python 验证完了,后面就是用 Go 实现了。 我开了个仓库,封装了一个 Go 版本的 SDK。 https://github.com/wuhan005/daodao-api

实现了基本的账号登录、以及获取历史记账信息的接口,够凑合先用着了。😉 如果你足够 open,你甚至可以基于此写一个 badge 服务,将你的每日开销挂在你的 GitHub Profile 上。(我是不敢

这也是我安卓第一次逆 so,以前都是看看 jar 基本就摸清楚整个请求了的。原理上对于各位 re 手来说可能过于容易了,但我还是从中学到了不少东西。

当然,上述行为是绝对违反「叨叨记账」用户协议的:

8.2 软件使用规范 8.2.1 除非法律允许或叨叨记账书面许可,你使用本软件过程中不得从事下列行为: 8.2.1.2 对本软件进行反向工程、反向汇编、反向编译,或者以其他方式尝试发现本软件的源代码;

我先在此做个免责声明:本文仅供研究学习使用,由本文或者本项目所引发的一切责任,本人均不承担。

当然如果是我号没了那就直接卸载不用了,这波咱也不亏。😈

谢谢老板 Thanks♪(・ω・)ノ


喜欢这篇文章?为什么不打赏一下呢?