代码分享论坛https://lim.app
三月份的时候,接触了一下可转债打新,中了三次。感觉操作简便,收益率可观,于是便想着搞一个自动化打新工具。但是苦于没有合适的API接口,自己还需要上课,想着手动申购一下也不麻烦。五月份可转债数量很少,自己漏掉几个,很难受,于是决定逆向一下同花顺app,搞一个自动化申购的接口出来,于是便有了这篇文章。目前只有自动化申购的接口,卖出,撤单等各类股票交易接口暂时未完成,别问,问就是敏捷开发。测试了几天,都没有什么问题。这个话题不知道能不能聊,先发出来试试。/狗头
JEB、GDA、xposed、frida、fiddler、wireshark、DDMS、pycharm、雷电模拟器、同花顺appV10.02.12
通过直接解包逆向app,发现其dex并不是核心dex,而是类似于一个加载器,利用so接口获取odex并完成加载。 通过启动时发送的MonitorInfoReceive数据包,可以发现软件odex以及dex在本地目录有保存。 在相应目录下果然找到了相关文件: 将文件复制到电脑后,发现dex文件异常,而odex正常,直接将odex中dex.035(dex文件的开头标识符)字符前面的字段删除,得到dex文件,正常打开。此处我没有深入研究app加载的流程以及dex文件显示异常的原因,对比太麻烦了==实际工作中,大部分情况下这样拿到dex文件已经够用了,但是部分函数反编译的时候会有问题,对比采用frida hook拿到的dex文件不存在问题。因此最后采用的是通过frida拿到的dex文件。 该app一共有7个dex,分开研究肯定很麻烦,这么多dex在混淆的情况下需要合并分析才行。这儿安利JEB,对multidex支持很好,希望GDA也能够增加这个功能。但是JEB对各种跳转显示的不好,各种goto,翻译一部分代码的时候,N多goto差点儿让我猝死。 至此,已经拿到了核心dex,并且通过JEB能够合并分析。
我逆向分析的时候喜欢先搞定日志函数,然后通过hook日志函数来寻找突破点。在这个案例中,直接看JEB字符串部分,找到疑似日志的部分,跳转过去,定位就行。这部分没什么可谈的,日志部分是最简单的。最后定位于frr类以及frq类,采用frida进行hook。
var frr = Java.use('frr');
frr.a.overload('java.lang.String', 'java.lang.String').implementation = function (arg1, arg2) {
send(arg1 + " : " + arg2);
};
frr.b.implementation = function (arg1, arg2) {
send(arg1 + " bbb: " + arg2);
};
frr.c.implementation = function (arg1, arg2) {
send(arg1 + " : " + arg2);
};
frr.d.implementation = function (arg1, arg2) {
send(arg1 + " : " + arg2);
};
frr.e.implementation = function (arg1, arg2) {
send(arg1 + " : " + arg2);
};
var frq = Java.use('frq');
frq.a.overload('java.lang.String', 'java.lang.String').implementation = function (arg1, arg2) {
this.a(arg1, arg2);
send(arg1 + " : " + arg2);
};
frq.a.overload('java.lang.String', 'java.lang.String', 'boolean').implementation = function (arg1, arg2, arg3) {
this.a(arg1, arg2, arg3);
send(arg1 + " : " + arg2);
};
frq.b.overload('java.lang.String', 'java.lang.String').implementation = function (arg1, arg2) {
this.b(arg1, arg2);
send(arg1 + " : " + arg2);
};
frq.c.overload('java.lang.String', 'java.lang.String').implementation = function (arg1, arg2) {
this.c(arg1, arg2);
send(arg1 + " : " + arg2);
};
frq.d.overload('java.lang.String', 'java.lang.String').implementation = function (arg1, arg2) {
this.d(arg1, arg2);
send(arg1 + " : " + arg2);
};
frq.e.overload('java.lang.String', 'java.lang.String').implementation = function (arg1, arg2) {
this.e(arg1, arg2);
send(arg1 + " : " + arg2);
};
通过日志没有看到多少有用的东西,但是可以了解到同花顺app采用TCP通信,并且可以拿到IP地址和服务器端口,方便下一步的抓包。
通常我分析app会fiddler和wireshark全部开启,方便抓包分析。 同花顺app整体协议采用TCP通信协议,很多不重要的数据包并没有加密,直接采用的明文方式。这个给出了可乘之机。 首次启动app会发送一条注册设备的数据包,服务器返回passport.dat数据包作为日后连接登录的凭证。以后的连接数据包全部采用passport.dat。 值得一提的是,此处发送的时候,采用的宽字符。python实现部分:
str_info = 'ScreenWidth=720'
str_info += '\r\nScreenHeight=1280'
str_info += '\r\nsmallestWidth=0dp'
str_info += '\r\ndensity=1.0'
str_info += '\r\nrealdata=true'
str_info += '\r\ntime2012=1'
str_info += '\r\nAppletVersion=' + constants.APPLET_VERSION
str_info += '\r\nsvnver=' + constants.SVN_VER
str_info += '\r\nTestVersion=' + constants.TEST_VERSION
str_info += '\r\nBranchName=' + constants.BRANCH_NAME
str_info += '\r\nFunClientSupport=0111111111100011111111'
str_info += '\r\napp=android'
str_info += '\r\nfor=ths_am_gphone_login'
str_info += '\r\nprogid=500'
str_info += '\r\nnet=1'
str_info += '\r\nqsid=800'
str_info += '\r\nsourceid=' + constants.SOURCE_ID
str_info += '\r\nspcode=' + constants.SP_CODE
str_info += '\r\nchannelid=' + constants.SOURCE_ID
str_info += '\r\ntype=' + constants.TYPE
str_info += '\r\nudid=' + constants.UDID
str_info += '\r\nimei=' + constants.IMEI
str_info += '\r\nsim=' + constants.UDID
str_info += '\r\nimsi=' + constants.IMSI
str_info += '\r\nmacA=' + constants.MAC
str_info += '\r\nsdk=22'
str_info += '\r\nsdkn=5.1.1'
str_info += '\r\nCA=4'
str_info += '\r\ndev=' + constants.DEV
str_info += '\r\n'
data = b''
for i in range(len(str_info)):
data += int.to_bytes(ord(str_info[i]), 2, byteorder='little', signed=False)
data = int.to_bytes(len(str_info), 2, byteorder='little', signed=False) + data
# data长度不够8的倍数则用00补齐
data += b"\x00\x00\x00\x00\x00\x00\x00"
data = data[0:int(len(data) / 8) * 8]
数据包组包完毕,直接在前面加上头部分就行,后续统一讲头部分。
其实这部分没有什么好讲的,都是明文,只有一个RSA加密,直接搜索字符串,找到RSA的公钥就ok,单纯的体力活。直接给出这两部分的python实现部分:
# 获取验证码
reqpage = str(random.randint(10000, 99999))
enc_account = base64.b64encode(rsa_utils.rsa_encrypt(account.encode('utf-8')))
enc_account = parse.quote(enc_account)
url = 'verify?reqtype=wlh_thsreg_modify&mobile_login=1&qsid=800®flag&udid=' + constants.UDID + '&encoding=GBK&mobile=' + enc_account + '&rsa_version=default_4&foreign=1&foreign_country=86'
str_info = '[frame]'
str_info += '\r\nid=4222'
str_info += '\r\npageList=' + reqpage
str_info += '\r\nreqPage=' + reqpage
str_info += '\r\nreqPageCount=1'
str_info += '\r\n[' + reqpage + ']'
str_info += '\r\nid=1101'
str_info += '\r\nhost=auth'
str_info += '\r\nurl=' + url
str_info += '\r\n'
# 验证验证码,采用密码登录也是一样的,不过几个参数变化
reqpage = str(random.randint(10000, 99999))
enc_account = base64.b64encode(rsa_utils.rsa_encrypt(account.encode('utf-8')))
enc_account = str(enc_account, 'utf-8')
enc_password = base64.b64encode(rsa_utils.rsa_encrypt(password.encode('utf-8')))
enc_password = str(enc_password, 'utf-8')
str_info = '[frame]'
str_info += '\r\nid=2054'
str_info += '\r\npageList=' + reqpage
str_info += '\r\nreqPage=' + reqpage
str_info += '\r\nreqPageCount=1'
str_info += '\r\n[' + reqpage + ']'
str_info += '\r\nid=1001'
str_info += '\r\ncrypt=2'
str_info += '\r\nctrlcount=2'
str_info += '\r\nctrlid_0=34338'
str_info += '\r\nctrlvalue_0=' + enc_account
str_info += '\r\nctrlid_1=34339'
str_info += '\r\nctrlvalue_1=' + enc_password
str_info += '\r\nreqctrl=4304'
str_info += '\r\nloginmode=1'
if not isSMS:
str_info += '\r\nloginType=3\r\n'
else:
str_info += '\r\nforeign=1'
str_info += '\r\nforeign_country=86'
str_info += '\r\nloginType=7\r\n'
和券商相关的协议部分全部采用DES加密,DES密钥由客户端生成,512位RSA密钥加密过后在券商登录阶段发送至服务器。这部分不再是简单的上述的文本,而是类似于TLV的结构体。其中T为一个字节,L为双字节,V根据L的值来确定。 这部分定位比较困难,因此需要采用DDMS来找到关键位置。寻找过程比较枯燥无味,这部分正如之前网友讲的,没什么营养。个人也没怎么记录,因此直接讲解一下该部分组包方法。
qssj = wtid + "#" + qsid + "#" + dtkltype + "#1#"
reqpage = str(random.randint(10000, 99999))
data = b'\x13\x02' + b'\x00\x01\x00\x30\x01\x01\x00\x30'
data += int.to_bytes(2, 1, byteorder='little', signed=False)
data += int.to_bytes(len(account), 2, byteorder='little', signed=False)
data += str.encode(account)
data += int.to_bytes(3, 1, byteorder='little', signed=False)
data += int.to_bytes(len(password), 2, byteorder='little', signed=False)
data += str.encode(password)
data += int.to_bytes(4, 1, byteorder='little', signed=False)
data += int.to_bytes(len(txmm), 2, byteorder='little', signed=False)
data += str.encode(txmm)
data += int.to_bytes(5, 1, byteorder='little', signed=False)
data += int.to_bytes(0, 2, byteorder='little', signed=False)
data += int.to_bytes(6, 1, byteorder='little', signed=False)
data += int.to_bytes(len(qssj), 2, byteorder='little', signed=False)
data += str.encode(qssj)
data += int.to_bytes(7, 1, byteorder='little', signed=False)
data += int.to_bytes(len(reqpage), 2, byteorder='little', signed=False)
data += str.encode(reqpage)
data += int.to_bytes(8, 1, byteorder='little', signed=False)
data += int.to_bytes(1, 2, byteorder='little', signed=False)
data += int.to_bytes(49, 1, byteorder='little', signed=False)
data += int.to_bytes(9, 1, byteorder='little', signed=False)
HD_INFO = 'HDInfo=' + constants.HD_INFO
data += int.to_bytes(len(HD_INFO), 2, byteorder='little', signed=False)
data += str.encode(HD_INFO)
# 这部分数据直接固定
data += b'\x0a\x00\x00\x0b\x00\x00\x0c\x00\x00\x0d\x01\x00\x30\x0e\x00\x00\x0f\x00\x00\x10\x00\x00\x11\x00\x00' \
b'\x12\x00\x00'
需要发送的数据构造完毕后,在其前面添加包序号以及两字节的包头等内容后补齐至8的倍数。补齐后,数据采用随机生成的16字节密钥完成DES加密,密钥通过RSA(该处RSA加密与上述不同)加密后放在之前加密好的密文之前。
enc_data = Des.des(data, globals()['server_key'], True)
enc_key = constants.RSA_KEY_HEADER
enc_key += globals()['qs_login_header']
enc_key += globals()['server_key']
enc_key = rsa_utils.rsa_encrypt_key(enc_key)
enc_key_length = len(enc_key)
data = enc_key
data += enc_data
登录完成后,来到了申购可转债环节。这部分可转债数据构造完成后,就可以采用上述生成的16字节密钥进行加密了。
reqpage = random.randint(10000, 99999).__str__()
str_info = '[frame]'
str_info += '\r\nid=2682'
str_info += '\r\npageList=' + reqpage
str_info += '\r\nreqPage=' + reqpage
str_info += '\r\nreqPageCount=1'
str_info += '\r\nqsid=' + globals()['qsid']
str_info += '\r\nwtaccount=' + globals()['wtaccount']
str_info += '\r\nwttype=' + globals()['dtkltype']
str_info += '\r\n[' + reqpage + ']'
str_info += '\r\nid=1820'
str_info += '\r\nreqctrl=2001'
str_info += '\nctrlid_0=36641'
str_info += '\nctrlvalue_0=1'
str_info += '\nctrlid_1=36615'
str_info += '\nctrlvalue_1=' + quantity
str_info += '\nctrlid_2=2102'
str_info += '\nctrlvalue_2=' + code
str_info += '\nctrlid_3=2127'
str_info += '\nctrlvalue_3=' + price
str_info += '\nctrlcount=4'
str_info += '\r\nHDInfo=' + constants.HD_INFO
包头部分主要包含了当前数据包的长度以及类型之类的信息。
full_data = b''
full_data += int.to_bytes(data_header.headLength, 2, byteorder='little', signed=False)
full_data += int.to_bytes(data_header.id, 4, byteorder='little', signed=False)
full_data += int.to_bytes(data_header.type, 4, byteorder='little', signed=False)
full_data += int.to_bytes(data_header.pageId, 2, byteorder='little', signed=False)
full_data += int.to_bytes(data_header.dataLength, 4, byteorder='little', signed=False)
full_data += int.to_bytes(data_header.frameId, 4, byteorder='little', signed=False)
full_data += int.to_bytes(data_header.textLength, 4, byteorder='little', signed=False)
full_data += int.to_bytes(data_header.sessionType, 4, byteorder='little', signed=False)
full_data += data
讲到现在,对app通讯部分就了解差不多了。其实该APP逆向过程中,主要就是体力活==只是单纯的分享源码,不写点儿啥的话对不起自己这么多天。所以随便写点儿东西,该源码我也没有完善,所以希望通过我的一点儿笔记给有心思研究该app的同志一点儿小小的启发。 该app在逆向的过程中,最麻烦的莫过于服务器返回数据的解析了,其他还好说,关键是主要StuffCurveStruct以及StuffTableStruct两种格式数据的处理。StuffCurveStruct应该包含的是股票的详细信息, 这部分暂未完成解析, 感觉用处不大。StuffTableStruct包含了除股票信息之类, 比如个人持仓之类的信息,这部分花费了比较大的精力去搞定。 目前大家如果还想开发新的接口的话,直接通过frida hook发送的数据康康就行,返回数据我应该已经解析的差不多了。