一. 起因
很久之前为了看擅长捉弄的高木同学,问别人要到了这个漫画软件,但是有广告...
那么就想办法下载下来吧(以后还能看,嘻嘻)
二. 抓包
先抓个搜索功能开开胃。
搜索漫画界面.jpg (119.6 KB, 下载次数: 1)
下载附件
搜索漫画界面
2023-10-6 12:05 上传
搜索漫画_数据包图片.jpg (276.91 KB, 下载次数: 1)
下载附件
搜索漫画_数据包图片
2023-10-6 12:05 上传
靠,有加密数据。好好好,你这么玩是吧。那我就要掏出我的算法助手了。
启动算法助手并尝试获取加密方式和秘钥
算法助手搜索界面.jpg (48.27 KB, 下载次数: 0)
下载附件
算法助手搜索界面
2023-10-6 12:17 上传
坏了,啥也没搜到。
换SimpleHook再试试
出乎意料的是SimpleHook结果中也没有找到有用的数据,但是比算法助手多了几个DES的结果
SimpleHook搜索界面.jpg (167.46 KB, 下载次数: 1)
下载附件
SimpleHook搜索界面
2023-10-6 12:17 上传
SimpleHook_DES数据界面.jpg (340.22 KB, 下载次数: 0)
下载附件
SimpleHook_DES数据界面
2023-10-6 12:17 上传
好好好,这样玩的话,我可就只能尝试逆向代码了。
三. 脱壳
[ol]
MT管理器处理dex.jpg (154.27 KB, 下载次数: 1)
下载附件
2023-10-6 12:18 上传
[/ol]
四. 分析
直接搜索关键字
用jadx直接打开压缩包,搜索safetyData
Jadx搜索界面.png (148.31 KB, 下载次数: 1)
下载附件
Jadx搜索界面
2023-10-6 12:18 上传
好嘛,都在这个两个类里面出现了。
经常和Java打交道的朋友们都看出来了,重点是前三条数据。
那我们先看第二条,emm,像是像,但是有字段对不上,再看第三条,出现在一样的地方。
数据类界面.png (110.58 KB, 下载次数: 1)
下载附件
数据类界面
2023-10-6 12:18 上传
找到关键逻辑
那么接着来看第一条吧:
关键逻辑.png (77.2 KB, 下载次数: 1)
下载附件
关键逻辑
2023-10-6 12:19 上传
这类名,这函数名,太符合我的想象了。
我们回味一下抓到的数据包的json格式。
然后我们立马就看到了重点的几行
ResponseSafetyInfo cast = (cls2 != null ? cls2 : ResponseSafetyInfo.class).cast(b8);
String type2 = cast.getType();
String[] token = cast.getToken();
INetSafety encryptor = NetSafetyUtils.getEncryptor(type2);
if (encryptor != null && token != null && token.length != 0) {
Long valueOf = Long.valueOf(System.currentTimeMillis());
c8 = encryptor.decrypt(c8, token);
System.currentTimeMillis();
valueOf.longValue();
}
safety类界面.png (36.94 KB, 下载次数: 0)
下载附件
safety类界面
2023-10-6 12:19 上传
首先是转为ResponseSafetyInfo类,这不是对应safety字段嘛。
所以变量type2就是14339,token就是YD6YTGLPA。
再看NetSafetyUtils.getEncryptor(type2),追进去
NetSafetyUtils界面.png (22.46 KB, 下载次数: 0)
下载附件
NetSafetyUtils界面
2023-10-6 12:19 上传
这个encryptoMap肯定有用,看一下啥时定义的
encryptoMap界面.png (71.67 KB, 下载次数: 0)
下载附件
encryptoMap界面
2023-10-6 12:20 上传
可以看到是在static里面定义的,在根据变量f7372e2 = '14339'和刚才的type2 = 14339,找到对应的NetSafetyUtils.a(),再追
NetSafetyUtils.a界面.png (11.53 KB, 下载次数: 0)
下载附件
NetSafetyUtils.a界面
2023-10-6 12:20 上传
再追
NetSafetyUtils.a具体实现.png (12.13 KB, 下载次数: 0)
下载附件
NetSafetyUtils.a具体实现
2023-10-6 12:20 上传
终于看到希望了,再追进去
DES1界面.png (93.05 KB, 下载次数: 0)
下载附件
2023-10-6 12:21 上传
豁然开朗,加密方法非常明了,非常简单。
此时我们可以看到左边还有很多DES的类,肯定是有用的,我们稍后再看。
先看一下
new DESHelper(new StringBuilder(strArr[0].substring(1)).reverse().toString()).decrypt(str);
DESHelper界面.png (71.15 KB, 下载次数: 0)
下载附件
DESHelper界面
2023-10-6 12:27 上传
很显然,秘钥是处理之后的字符串,翻译成python就是[-1:0:-1]。
五. 实现
从网上找一个DES加解密的类
from Crypto.Cipher import DES
from binascii import b2a_base64, a2b_base64
class PrpCrypt(object):
def __init__(self, key):
self.key = key.encode('utf-8')
self.mode = DES.MODE_ECB
def changeKey(self, key):
self.key = key.encode('utf-8')
def encrypt(self, text):
text = text.encode('utf-8')
cryptor = DES.new(self.key, self.mode)
length = 16
count = len(text)
if count length:
add = (length - (count % length))
text = text + ('\07' * add).encode('utf-8')
print(text)
self.ciphertext = cryptor.encrypt(text)
return b2a_base64(self.ciphertext)
def decrypt(self, text):
cryptor = DES.new(self.key, self.mode)
plain_text = cryptor.decrypt(a2b_base64(text))
return plain_text.decode("utf-8").rstrip("\01").rstrip("\03")
1.搜索功能
发个请求测试一下
import requests
headers = {
'Cache-Control': 'public,max-age=60',
'Content-Type': 'application/json; charset=UTF-8',
'Host': '43.248.116.78:20256',
'Connection': 'Keep-Alive',
'User-Agent': 'okhttp/4.10.0',
}
data = {
'key': '擅长捉弄的高木同学',
'appChannel': 'normal',
'appKey': 'com.aster.zhbj',
'appVersion': 'v1.10.5',
'clientTime': 1696328622216,
'deviceBrand': 'Xiaomi',
'deviceType': '22041211AC',
'ipAddr': '192.168.0.221',
'netType': 'WIFI',
'platform': 0,
'sign': '',
'systemVersion': '12',
'userId': '1526xxxxxxxc8f70abce2',
'uuid': 'df82xxxxxxx65d407e64ddf',
'versionCode': 48
}
response = requests.post(
'http://43.248.116.78:20256/api/novel/search/associate', headers=headers, json=data).json()
# 秘钥处理方法14239,14339
token = response['safety']["token"][0][-1:0:-1]
cry = PrpCrypt(token) # 初始化密钥
decode = cry.decrypt(response["safetyData"])
decode = json.loads(decode)
print(decode)
看看输出结果(部分)
[
{
"key": "擅长捉弄的高木同学",
"relationType": 1,
"relationId": "af0580069e7031e3f03f27fb4ff26cdb",
"extra": "{"authorName":"山本崇一朗"}"
},
{
"key": "擅长捉弄的(原)高木同学",
"relationType": 1,
"relationId": "9d8b4dad7954734a471cb21452e917e0",
"extra": "{"authorName":"山本崇一朗"}"
}
]
很好,解密成功了,这是一个好的开始。
2.获取漫画详情
搜索实现了,接下来就是获取漫画详情了:
刚才结果里有个很重要的数据字段relationId,记一下它的值:af0580069e7031e3f03f27fb4ff26cdb
继续抓包:
漫画详情界面.jpg (200.34 KB, 下载次数: 0)
下载附件
漫画详情界面
2023-10-6 12:21 上传
漫画详情_抓包记录界面.jpg (234.2 KB, 下载次数: 0)
下载附件
漫画详情_抓包记录界面
2023-10-6 12:21 上传
我们看到第一条数据包(最下面)的url内就有刚才的relationId。
点进数据包,仔细看一下响应体
漫画详情_数据包.jpg (281.26 KB, 下载次数: 0)
下载附件
漫画详情_数据包
2023-10-6 12:22 上传
这次居然出现了两个token。不慌,我们再看一下safety里面的type字段:14439
按照刚才的方法,最终定位到
DES2界面.png (61.76 KB, 下载次数: 0)
下载附件
DES2界面
2023-10-6 12:22 上传
这里又处理了一下token,用python实现就是
response = requests.get("http://43.248.116.76:20131/api/novel/book/info/9d8b4dad7954734a471cb21452e917e0.json", headers=headers, data=data)
token = response.json()['safety']["token"]
token = token[0][::2] + token[1][1::2]
cry = PrpCrypt(token)
decode = cry.decrypt(response.json()["safetyData"]).encode(
'utf-8').decode('unicode_escape')
print(decode)
运行看一下结果(部分)
{
"bookId": "af0580069e7031e3f03f27fb4ff26cdb",
"bookName": "擅长捉弄的高木同学",
"authorName": "山本崇一朗",
"categoryName": "恋爱",
"intro": "【此漫画的翻译由版权方提供】因为被对方捉弄所以要想尽办法捉弄回来,这不是理所当然的嘛!气定神闲地捉弄人的高木同学和总是计划失败被捉弄到满面通红的西片,在班上邻座的两人似乎有更多机会互相搞小动作,可是真的仅仅只是想要捉弄对方而已吗?这是擅长捉弄人的女孩子和傻乎乎被捉弄了之后一本正经想要“报仇”的男孩子,他们之间轻松愉快的故事。不过,好像也不仅仅是这样哦……\n每一次开场读者仿佛就能“看到结局”,但还是会让人忍不住看下去。大家一起为西片加油吧!",
"tags": [
"搞笑",
"校园",
"恋爱",
"都市"
]
}
3.获取章节详情
res = requests.get(
f"http://43.248.116.76:20131/api/novel/book/chapters/af0580069e7031e3f03f27fb4ff26cdb.json", headers=headers).json()
token = res['safety']["token"][0][-1:0:-1]
cry = PrpCrypt(token)
result = cry.decrypt(res['safetyData']).replace("\x08", "")
#.encode().decode('unicode_escape')
print(json.loads(result))
在运行查看结果(部分)
{
"chapters": [
{
"chapterId": "YWYwNTgwMDY5ZTcwMzFlM2YwM2YyN2ZiNGZmMjZjZGJfcGx1dG9fNGU3ODViMDFiMWNkNjE5ZWNiZDQwNmQwODc4OTU1YTc=",
"chapterName": "001 橡皮擦",
"chapterSort": 1,
"nextChapterId": "YWYwNTgwMDY5ZTcwMzFlM2YwM2YyN2ZiNGZmMjZjZGJfcGx1dG9fYWYyNGEwMTgwOGU0ZjI0M2VlM2NhZDhmNGEwNzk1MTg=",
},
{
"chapterId": "YWYwNTgwMDY5ZTcwMzFlM2YwM2YyN2ZiNGZmMjZjZGJfcGx1dG9fYWYyNGEwMTgwOGU0ZjI0M2VlM2NhZDhmNGEwNzk1MTg=",
"chapterName": "002 泳池",
"chapterSort": 2,
"nextChapterId": "YWYwNTgwMDY5ZTcwMzFlM2YwM2YyN2ZiNGZmMjZjZGJfcGx1dG9fMmE3NWZiNjhhZjg0NTUxYzRhZTlmNDNmMjg1YzE2ZjU=",
}
]
}
4.获取章节图片列表
继续抓包,发现这里的token只有一个元素,那么密钥的处理方式就和搜索功能是一样的了。
res = requests.get( f"http://43.248.116.76:20131/api/novel/book/chapters/images/af0580069e7031e3f03f27fb4ff26cdb/YWYwNTgwMDY5ZTcwMzFlM2YwM2YyN2ZiNGZmMjZjZGJfcGx1dG9fNGU3ODViMDFiMWNkNjE5ZWNiZDQwNmQwODc4OTU1YTc=.json").json()
token = res['safety']["token"][0][-1:0:-1]
cry = PrpCrypt(token)
data = json.loads(cry.decrypt(res['safetyData']))["items"]
print(data)
再看一下结果(部分)
[
{
"url": "http://43.248.116.102:30133/img8/af0580069e7031e3f03f27fb4ff26cdb/4e785b01b1cd619ecbd406d0878955a7/1?t=1689571599606"
},
{
"url": "http://43.248.116.106:30131/img6/af0580069e7031e3f03f27fb4ff26cdb/4e785b01b1cd619ecbd406d0878955a7/2?t=1689571599606"
}
]
5.获取图片
链接都出来了,这不就是直接查看了嘛,但是,我们访问链接的话:
请求图片错误界面.png (22.92 KB, 下载次数: 0)
下载附件
请求图片错误界面
2023-10-6 12:22 上传
靠,看来还有别的参数。继续抓包:
图片请求参数界面.jpg (251.64 KB, 下载次数: 0)
下载附件
图片请求参数界面
2023-10-6 12:22 上传
好家伙,我直呼好家伙,怎么这么多参数
好家伙.jpg (766 Bytes, 下载次数: 0)
下载附件
好家伙
2023-10-6 12:23 上传
那么继续逆向吧,直接搜索最特殊的"sign1"
Jadx搜索界面2.png (38.43 KB, 下载次数: 0)
下载附件
Jadx搜索界面2
2023-10-6 12:23 上传
天助我也,就这一处,点开仔细看看
请求参数界面.png (107.98 KB, 下载次数: 0)
下载附件
请求参数界面
2023-10-6 12:23 上传
好好好,参数全在这里了。
先观察相同几个获取图片的数据包参数,把能固定的、能生成的全部写好
# 13位时间戳
rtime = str(int(time.time()*1000))
headers = {
'swidth': '1440',
# 上次观看时间
'rtime': rtime,
'swidth': '1080',
'stime': rtime,
'ecount': '1',
# 跟随设备固定
'psign': 'xxxx',
# 抓包自己找
'userId': 'xxxxx',
'deviceId': 'xxxx',
'version': 'v1.10.5',
'systemVersion': '12',
'appChannel': 'normal',
'ipAddr': '10.69.8.234',
'versionCode': '48',
'appKey': 'com.aster.zhbj',
'time': str(int(time.time()) // 60),
'timeUnix': rtime,
'ptime': rtime,
'sheight': '2931',
'User-Agent': 'okhttp/4.9.3',
}
接下来就看sign和sign1是怎么生成的了
aVar5.a("sign", this.nt.getShortSign(deviceUUID + '|' + valueOf5 + '|' + valueOf7 + '|' + valueOf8 + '|' + appKey));
StringBuilder sb = new StringBuilder();
sb.append(valueOf3);
sb.append('|');
sb.append(userId);
sb.append('|');
sb.append(valueOf4);
aVar5.a("sign1", this.nt.getSign(sb.toString()));
先根据参数对应关系转成python
signStr = f"{deviceId}|{headers['timeUnix']}|{count}|{size}|com.aster.zhbj")
sign1Str = f"{rtime}|{headers['userId']}|{headers['ptime']}")
那么字符串有了,来看看怎么加密呢:
this.nt.getSign(sb.toString())按住ctrl追进去
Native方法界面.png (34.39 KB, 下载次数: 0)
下载附件
Native方法界面
2023-10-6 12:24 上传
当我看到这里的时候我慌了,native层的方法,我以前可从来没有碰过啊。
先把libnativecore.so拿出来到ida看一下吧
直接找Java方法
ida搜索java.png (118.53 KB, 下载次数: 0)
下载附件
ida搜索java
2023-10-6 12:24 上传
ida可疑的算法.png (185.83 KB, 下载次数: 0)
下载附件
ida可疑的算法
2023-10-6 12:24 上传
很幸运啊,是静态注册的函数,似乎逻辑挺简单的嘛,但是我不会啊呜呜呜。
(如果有大佬肯出手写一篇实现算法的代码,那就太好了。)
想起来之前看到的Unidbg教程,不如直接上手试试。
六. Unidbg
先下载一下Unidbg
IDEA,启动!
先把文件放到资源目录,方便读取
so文件存放位置.png (22.03 KB, 下载次数: 0)
下载附件
so文件存放位置
2023-10-6 12:24 上传
接下来就是填写参数,调用so方法了
直接把方法签名取出来:getShortSign(Ljava/lang/String)Ljava/lang/String;
然后
package com.aster.zhbj;
import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.DalvikModule;
import com.github.unidbg.linux.android.dvm.DvmClass;
import com.github.unidbg.linux.android.dvm.StringObject;
import com.github.unidbg.linux.android.dvm.VM;
import com.github.unidbg.linux.android.dvm.jni.ProxyClassFactory;
import com.github.unidbg.memory.Memory;
import java.io.*;
public class Signutil {
private final AndroidEmulator emulator;
private final DvmClass cSignUtil;
private final VM vm;
// so文件路径(自动处理了中文路径)
public final static String soPath = System.getProperty("user.dir") + File.separator + "libnativecore.so";
public Signutil() {
emulator = AndroidEmulatorBuilder.for64Bit()
.setProcessName("com.aster.zhbj")
.build();
Memory memory = emulator.getMemory();
memory.setLibraryResolver(new AndroidResolver(23));
vm = emulator.createDalvikVM();
vm.setDvmClassFactory(new ProxyClassFactory());
vm.setVerbose(false);
DalvikModule dm = vm.loadLibrary(new File(soPath), false);
cSignUtil = vm.resolveClass("com/aster/nativecore/NativeLib");
dm.callJNI_OnLoad(emulator);
// emulator.traceCode();
}
public void destroy() throws IOException {
emulator.close();
}
public String getShortSign(String p1) {
String methodSign = "getShortSign(Ljava/lang/String)Ljava/lang/String;";
StringObject obj = cSignUtil.callStaticJniMethodObject(emulator, methodSign, p1);
return obj.getValue();
}
public String getSign(String p1) {
String methodSign = "getSign(Ljava/lang/String)Ljava/lang/String;";
StringObject obj = cSignUtil.callStaticJniMethodObject(emulator, methodSign, p1);
return obj.getValue();
}
public static void main(String[] args) {
Signutil signutil = new Signutil();
String shortSign = signutil.getShortSign("df82f15xxxe64ddf|1696520079967|0|0|com.aster.zhbj");
System.out.println("sign=" + shortSign);
String sign = signutil.getSign("1673426429892|d9b758xxxfb88d|1669337826366");
System.out.println("sign1=" + sign);
try {
signutil.destroy();
} catch (IOException e) {
e.printStackTrace();
}
}
}
运行一下看看结果
测试输出结果.png (140.07 KB, 下载次数: 0)
下载附件
测试输出结果
2023-10-6 12:25 上传
太好了,太完美了!居然不需要补环境,太爽了。
但是怎么给python调用呢,启动http服务也不是不行,但是打包成jar也可以。
番外:打包成jar
创建工件1.png (46.03 KB, 下载次数: 0)
下载附件
创建工件1
2023-10-6 12:26 上传
创建工件2.png (33.58 KB, 下载次数: 0)
下载附件
创建工件2
2023-10-6 12:26 上传
确定之后差不多这个样子
创建工件3.png (109.33 KB, 下载次数: 0)
下载附件
/创建工件3
2023-10-6 12:26 上传
然后菜单栏找到构建-构建工件
创建工件4.png (13.7 KB, 下载次数: 0)
下载附件
创建工件4
2023-10-6 12:26 上传
然后就能在左侧out文件夹找到生成的文件啦
测试一下jar能不能用,直接终端运行
测试jar.png (142.65 KB, 下载次数: 0)
下载附件
2023-10-6 12:25 上传
可以使用,太棒啦
七. Python调用
随时bing了一下python怎么调用jar文件
直接使用jpype
import jpype
import os
path = os.path.dirname(os.path.abspath(__file__))
# jar包存放地址
jarpath = path + "\\unidbg-android.jar"
jpype.startJVM(jpype.getDefaultJVMPath(), "-ea",
"-Djava.class.path={}".format(jarpath), convertStrings=False)
# 找到调用方法
jpype.addClassPath(jarpath)
SignutilClass = jpype.JClass("com.aster.zhbj.Signutil")
sign = SignutilClass()
print(sign.getShortSign("df82xxxxx4ddf|1696520079967|0|0|com.aster.zhbj"))
print(sign.getSign("1673426429892|d9b758xxxxxxfb88d|1669337826366"))
jpype.shutdownJVM()
jpype测试输出.png (49.96 KB, 下载次数: 0)
下载附件
jpype测试输出
2023-10-6 12:26 上传
靠,太完美了。
最后合并一下代码成一个类,就可以使用啦!
八. 提问
除了Unidbg之外还有一个ExAndroidNativeEmu可以调用so
代码如下
from androidemu.emulator import Emulator
from androidemu.java.java_class_def import JavaClassDef
from androidemu.java.java_method_def import java_method_def
from androidemu.java.classes.string import String
from androidemu.const.emu_const import ARCH_ARM64
from unicorn.arm_const import *
from unicorn.arm_const import UC_ARM_REG_R0
import posixpath
class MiaoShangEmulator(metaclass=JavaClassDef, jvm_name='com/aster/nativecore/NativeLib'):
def __init__(self):
pass
@staticmethod
@java_method_def(name='getShortSign', signature='(Ljava/lang/String;)Ljava/lang/String;', native=True)
def getShortSign():
pass
@staticmethod
@java_method_def(name='getSign', signature='(Ljava/lang/String;)Ljava/lang/String;', native=True)
def getSign():
pass
# Initialize emulator
emulator = Emulator(
vfp_inst_set=True,
vfs_root=posixpath.join(posixpath.dirname(__file__), "vfs"),
arch=ARCH_ARM64)
# 加载SO
lib_module = emulator.load_library("libnativecore.so")
emulator.java_classloader.add_class(MiaoShangEmulator)
# 准备参数
param = "1673426429892|152691xxxxc8f70abce2|1669337826366"
x = emulator.call_symbol(lib_module, 'Java_com_aster_nativecore_NativeLib_getSign',
emulator.java_vm.jni_env.address_ptr, 0x00,
String(param))
print(x)
但是我的运行结果却是如图所示的
ExAndroidNativeEmu奇怪的输出.png (28.83 KB, 下载次数: 0)
下载附件
ExAndroidNativeEmu奇怪的输出
2023-10-6 12:27 上传
是哪里有问题呢?官方的issue里面也有人提问,不给我没看明白,呜呜呜
九. 结束
之前开源的时候简单抓个包,连壳都不需要脱,就可以把所有的数据都拿到了。
但是现在软件更新了,有人发issue来问我哈哈哈,顺手逆一下,做个学习记录吧。
这是我第一次写如此详细的逆向教程(花了三个小时在写文档上哈哈哈),希望大家满意,看不懂的直接提问即可。还有我的问题希望大佬能够教教我,先谢谢啦。
最后最后,如果有大佬愿意分析一下这个so,写个python或者java实现sign算法的教程最好啦!
本教程所用到的代码、文件都在这里:项目传送门