大神论坛

找回密码
快速注册
查看: 481 | 回复: 0

[原创] 喵上漫画 app 签名参数逆向分析 附源码+文件

主题

帖子

2

积分

初入江湖

UID
587
积分
2
精华
威望
4 点
违规
大神币
68 枚
注册时间
2023-09-16 15:08
发表于 2023-10-20 22:26
本帖最后由 清风私语 于 2023-10-20 22:26 编辑

一. 起因

很久之前为了看擅长捉弄的高木同学,问别人要到了这个漫画软件,但是有广告...
那么就想办法下载下来吧(以后还能看,嘻嘻)

二. 抓包

先抓个搜索功能开开胃。

靠,有加密数据。好好好,你这么玩是吧。那我就要掏出我的算法助手了。

启动算法助手并尝试获取加密方式和秘钥

坏了,啥也没搜到。

换SimpleHook再试试

出乎意料的是SimpleHook结果中也没有找到有用的数据,但是比算法助手多了几个DES的结果

SimpleHook_DES数据界面

好好好,这样玩的话,我可就只能尝试逆向代码了。

三. 脱壳

  1. 打开MT管理器提取安装包,发现被360加固了捏。还好我有珍惜大佬的FunDex
  2. LSPosed启用模块,选择作用域;FunDex选择软件,一套组合拳打出来,然后打开软件,把每个Tab都点一下确保dex脱的干净一点。
  3. 再打开MT管理器,找到该软件的data目录,就能看到脱出来的dex啦。然后全选、移动、压缩、发送到电脑一气呵成。

四. 分析

直接搜索关键字

用jadx直接打开压缩包,搜索safetyData

好嘛,都在这个两个类里面出现了。
经常和Java打交道的朋友们都看出来了,重点是前三条数据。
那我们先看第二条,emm,像是像,但是有字段对不上,再看第三条,出现在一样的地方。

找到关键逻辑

那么接着来看第一条吧:

这类名,这函数名,太符合我的想象了。
我们回味一下抓到的数据包的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();
}


首先是转为ResponseSafetyInfo类,这不是对应safety字段嘛。
所以变量type2就是14339,token就是YD6YTGLPA。
再看NetSafetyUtils.getEncryptor(type2),追进去

这个encryptoMap肯定有用,看一下啥时定义的

可以看到是在static里面定义的,在根据变量f7372e2 = '14339'和刚才的type2 = 14339,找到对应的NetSafetyUtils.a(),再追

再追

终于看到希望了,再追进去

豁然开朗,加密方法非常明了,非常简单。
此时我们可以看到左边还有很多DES的类,肯定是有用的,我们稍后再看。
先看一下


很显然,秘钥是处理之后的字符串,翻译成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)
            text = text + ('\07' * add).encode('utf-8')
            print(text)
        elif 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": "<word>擅长</word><word>捉弄</word><word>的</word><word>高木</word><word>同学</word>",
        "relationType": 1,
        "relationId": "af0580069e7031e3f03f27fb4ff26cdb",
        "extra": "{"authorName":"山本崇一朗"}"
    },
    {
        "key": "<word>擅长</word><word>捉弄</word><word>的</word>(<word>原</word>)<word>高木</word><word>同学</word>",
        "relationType": 1,
        "relationId": "9d8b4dad7954734a471cb21452e917e0",
        "extra": "{"authorName":"山本崇一朗"}"
    }
]

很好,解密成功了,这是一个好的开始。

2.获取漫画详情

搜索实现了,接下来就是获取漫画详情了:
刚才结果里有个很重要的数据字段relationId,记一下它的值:af0580069e7031e3f03f27fb4ff26cdb
继续抓包:

我们看到第一条数据包(最下面)的url内就有刚才的relationId。
点进数据包,仔细看一下响应体

这次居然出现了两个token。不慌,我们再看一下safety里面的type字段:14439
按照刚才的方法,最终定位到

这里又处理了一下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.获取图片

链接都出来了,这不就是直接查看了嘛,但是,我们访问链接的话:

靠,看来还有别的参数。继续抓包:

好家伙,我直呼好家伙,怎么这么多参数

那么继续逆向吧,直接搜索最特殊的"sign1"

天助我也,就这一处,点开仔细看看

好好好,参数全在这里了。
先观察相同几个获取图片的数据包参数,把能固定的、能生成的全部写好

# 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层的方法,我以前可从来没有碰过啊。
先把libnativecore.so拿出来到ida看一下吧
直接找Java方法

很幸运啊,是静态注册的函数,似乎逻辑挺简单的嘛,但是我不会啊呜呜呜。
(如果有大佬肯出手写一篇实现算法的代码,那就太好了。)
想起来之前看到的Unidbg教程,不如直接上手试试。

六. Unidbg

先下载一下Unidbg
IDEA,启动!
先把文件放到资源目录,方便读取

接下来就是填写参数,调用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();  
        }  
    }  
}

运行一下看看结果

太好了,太完美了!居然不需要补环境,太爽了。
但是怎么给python调用呢,启动http服务也不是不行,但是打包成jar也可以。

番外:打包成jar

确定之后差不多这个样子

然后菜单栏找到构建-构建工件

然后就能在左侧out文件夹找到生成的文件啦
测试一下jar能不能用,直接终端运行

可以使用,太棒啦

七. 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()

靠,太完美了。
最后合并一下代码成一个类,就可以使用啦!

八. 提问

除了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)

但是我的运行结果却是如图所示的

是哪里有问题呢?官方的issue里面也有人提问,不给我没看明白,呜呜呜

九. 结束

之前开源的时候简单抓个包,连壳都不需要脱,就可以把所有的数据都拿到了。
但是现在软件更新了,有人发issue来问我哈哈哈,顺手逆一下,做个学习记录吧。
这是我第一次写如此详细的逆向教程(花了三个小时在写文档上哈哈哈),希望大家满意,看不懂的直接提问即可。还有我的问题希望大佬能够教教我,先谢谢啦。
最后最后,如果有大佬愿意分析一下这个so,写个python或者java实现sign算法的教程最好啦!

本教程所用到的代码、文件都在下方链接中


注:若转载请注明大神论坛来源(本贴地址)与作者信息。


下方隐藏内容为本帖所有文件或源码下载链接:

游客你好,如果您要查看本帖隐藏链接需要登录才能查看, 请先登录

返回顶部