第一次接触到「叨叨记账」这个 App 是在今年年初。
当时我还在疯狂喜欢伊蕾娜(虽然现在也是),偶然跟室友聊起来说如果有个类似微软小冰的 AI 跟我聊天就好了。室友便给我安利了「叨叨记账」,说上面有一堆二次元角色可以选择,由用户贡献符合角色性格的语料,通过聊天的方式进行记账。
刚开始的几天我对这个 App 爱不释手,出去吃饭买完单后,马上就是打开叨叨记账记上一笔,对着屏幕上伊蕾娜给我回应傻笑。
后面到四月份的时候,个人压力比较大,记账的频率逐渐低了下来,但我还是会打开叨叨记账给伊蕾娜发几句抱怨,收获几句伊蕾娜的安慰。说来奇怪,我明明知道这一切都是预设好的语料,但心中还是感觉好受很多。
攒钱!攒钱!攒钱!
年初使用叨叨记账记录自己每天的开销,同时自己也把每个月的工资存下来一部分作为备用。好巧不巧,四月底的时候我的 MacBook Pro 突然无法开机了,赶紧预约了苹果天才吧,经检查发现是主板坏了,需要返厂换主板。
但当时我手头恰好有一个只剩下 2 天的 DDL,电脑坏了意味着之前的进度全都没了。一切又要从头开始。情急之下我决定在 Apple Store 花钱买了台新的 MacBook Pro,之前几个月攒下的钱一瞬间全都花完了。
从那之后,我再也没有打开叨叨记账这个 App。
“因为我是个很极端的人,有钱就会挥霍,没钱就会回到朴素的生活。”
以前我是很信奉如上所说的这句话,在 4 月底那次买完新电脑钱包被掏空后,我花了一下午时间挖了个中危的洞拿了一千块回血——然后第二天就跑去西湖苹果店买新出的 AirTag 了。😋
暑假回家的这几天,有天晚上肚子饿偷偷叫了个外卖,没想到被我爸发现了。😅 他又苦口婆心地叮嘱我要注意开销,花钱不能大手大脚,要继续攒钱为以后做准备。虽然话还是那些老话,但结合 4 月底的惨痛经历,我觉得确实不该这样下去了,钱这东西,还是能省就省。
所以…… 我在时隔大半年后又打开了叨叨记账。
我想要更定制化的功能!
叨叨记账对于每一笔开销,只有一个很简单的按月统计展示个饼图的功能。我想要能够根据每个月的开销情况,给我规划出一个至下次发工资前,我平均每天的开销上限可以是多少,推荐的金额是多少,攒下了多少等等……
这样我就能知道当月到今天为止我是否还可以偶尔晚上点一顿烧烤或奶茶;如果我有想买的东西,我要如何降低每天的开销来凑出这么多钱。
综上所述,我需要基于叨叨记账的记账数据扩展它的功能。经过前期的信息搜集,我发现这款产品仅支持移动端。那话不多说,开干!
简简单单抓个包
iPhone 上装好叨叨记账,Wi-Fi 配置好代理,打开 Charles 简简单单抓个包。
请求 Query 里几个可能要想办法获得的参数有 access_token
和 sign
。猜测 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 打开。从上面的登录请求中,找一个特殊的参数 latitude
或 longitude
全局搜索字符串。这俩是请求接口时顺便向后端上报设备经纬度定位的参数,一般来说不大会在请求的其他地方出现。
事实上叨叨记账还引用了高德地图的 SDK,所以搜索结果其实还是有干扰的。排除调形如 com.amap.*
的包名,在 classes3.dex
的 com.pengda.mobile.hhjz.b
下,找到了这些请求参数。
我们直接看最关心的 sign
参数是如何生成的:
内层的 a
方法接收两个参数:str1
与 str4
,str1
就是上面构造出的 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 为 nonce
和 timestamp
放到了 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
。(咱也不知道为啥是倒过来的)
其实到这里后面就基本可以猜的出来了,后续的操作就是调用 MessageDigest
给 v35
字符串做 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 里一样,拼接上当前的毫秒时间戳。
简简单单封装个 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 对本软件进行反向工程、反向汇编、反向编译,或者以其他方式尝试发现本软件的源代码;
我先在此做个免责声明:本文仅供研究学习使用,由本文或者本项目所引发的一切责任,本人均不承担。
当然如果是我号没了那就直接卸载不用了,这波咱也不亏。😈