一.前言
今天我们讲unidbg的下篇,也就是unidbg基础的最后一个部分,我们上节课也有补环境,比如补java环境,补安卓环境,这节课我们讲的肯定比这些都要难,我会给出一个之前讲过的案例,然后会讲一个全新的案例,pdd,这个里面的环境就更加难了,让我们接着往下看吧
二.B站sign参数
2.1 回顾sign
不记得的可以去往期回顾一下,这里给出地址
APP逆向 day18某站逆向 part3-CSDN博客文章浏览阅读1.2k次,点赞23次,收藏19次。因为之前被封,所以很久才更新,今天这个主要是后面的两个hook脚本很重要,app逆向是真的难,真的难找,很大一部分比的就是大家的hook代码。https://blog.csdn.net/weixin_74178589/article/details/140632798?spm=1001.2014.3001.5502我这里给出几张重要的截图
我们找到这个this.b就是sign值
this.b是signedQuery初始化传入的第二个参数
在这里传媒如一个map对象最后tostring
最后执行s在c中进行加密
加密的so文件时libbili.so
2.2 分析
1 so文件和方法
libbili.so
static native SignedQuery s(SortedMap<String, String> sortedMap);
2 入参和返回值
入参:sortedMap---》hook得到数据--》包裹--》传给unidbg
返回值:SignedQuery类型---》app自己定义的类型
3 hook 参数和返回值
入参:
map= {actual_played_time=0, aid=884507902, appkey=1d8b6e7d45233436, auto_play=0, build=6240300, c_locale=zh-Hans_CN, channel=xxl_gdt_wm_253, cid=233035308, epid=0, epid_status=, from=2, from_spmid=main.ugc-video-detail.0.0, last_play_progress_time=0, list_play_time=0, max_play_progress_time=0, mid=0, miniplayer_play_time=0, mobi_app=android, network_type=1, paused_time=0, platform=android, play_status=0, play_type=1, played_time=0, quality=32, s_locale=zh-Hans_CN, session=907a218ff7237664b2910757062ce5a40d1f08fc, sid=0, spmid=main.ugc-video-detail.0.0, start_ts=0, statistics={"appId":1,"platform":3,"version":"6.24.0","abtest":""}, sub_type=0, total_time=0, type=3, user_status=0, video_duration=674}
返回值= actual_played_time=0&aid=884507902&appkey=1d8b6e7d45233436&auto_play=0&build=6240300&c_locale=zh-Hans_CN&channel=xxl_gdt_wm_253&cid=233035308&epid=0&epid_status=&from=2&from_spmid=main.ugc-video-detail.0.0&last_play_progress_time=0&list_play_time=0&max_play_progress_time=0&mid=0&miniplayer_play_time=0&mobi_app=android&network_type=1&paused_time=0&platform=android&play_status=0&play_type=1&played_time=0&quality=32&s_locale=zh-Hans_CN&session=907a218ff7237664b2910757062ce5a40d1f08fc&sid=0&spmid=main.ugc-video-detail.0.0&start_ts=0&statistics=%7B%22appId%22%3A1%2C%22platform%22%3A3%2C%22version%22%3A%226.24.0%22%2C%22abtest%22%3A%22%22%7D&sub_type=0&total_time=0&ts=1716387614&type=3&user_status=0&video_duration=674&sign=ed5a33857d99b8442a3fe182aa214eed
4 拼凑出SortedMap--》包裹后---》传给unidbg
包裹方式一:ProxyDvmObject.createObject(vm,map)
包裹方式一:vm.resolveClass("类").newObject(map)
5 返回值:SignedQuery类型
app自定义类型---》可以DvmObject<?>类型泛指---》unidbg中所有类型,父类都是DvmObject---》StringObject也是DvmObject---》拿到具体的值DvmObject对象.getValue()
6 是否需要不环境?libbili.so中的s方法
-so内部一定执行了 c调用java
-c中是没有SignedQuery类的--》返回值是SignedQuery的对象
-于是:c内部一定调用了java7 所以在c内部调用java,完成SignQuery的初始化--》必须传入两个参数
而第二个参数,就是sign的值
public SignedQuery(String str, String str2) {
this.a = str;
this.b = str2;
}8 分析:so内部--》传入sortedMap,通过某种加密方式加密---》通过c调用java,实例化得到了SignedQuery对象,第一个参数是:sortedMap转成了字符串,第二个参数就是 sign 的加密结果
9 使用unidbg运行时,一定需要补环境--》SignedQuery--》两种方案破解sign
-1 在需要补SignedQuery环境时,打印出第二个参数,其实就是sign
-2 完整补SignedQuery,自己把返回的SignedQuery对象,调用toString,返回整个字符串
这里我会讲第二种,讲第二种的时候也能讲到第一种,那我们先编写大致逻辑框架,除sign函数以外的直接c就好了
2.3 unidbg运行
我们直接把unidbg初始化写好,然后运行发现不报错,那么我们现在就要开始编写sign部分的代码了
编写sign中的内容
这里主要是 SignedQuery是apk内部定义的,我们这里没有,所以用一个 DvmObject<?>来接受,这个是所有返回值的父类,然后最后返回 String res=singedQuery.getValue().toString();调用他内部的tostring方法,这样就能跑出最后结果
这里给出编写sign的代码
public void sign(){// 0 构造SortedMapSortedMap<String, String> map=new TreeMap<String, String>();// 这不全--》我们目标是把sign跑出来,所以暂时传这一点点map.put("actual_played_time", "0");map.put("aid", "884507902");map.put("appkey", "1d8b6e7d45233436");map.put("auto_play", "0");map.put("ts", "1647952932");// 1 找到java类中jni的类 native方法,找的时候是固定写法DvmClass LibBili = vm.resolveClass("com/bilibili/nativelibrary/LibBili");// 这个返回值是apk内部的,包名类名前面加个LString method = "s(Ljava/util/SortedMap;)Lcom/bilibili/nativelibrary/SignedQuery;";//3 执行方法// 之前我们写的返回值都是StringObject 或者ByteArray// 但是这个前面分析他的返回值类型是signedQuery 而这个是apk内部定义的类,我们此时就可以使用DvmObject<?> ,这个是unidbg中所有返回值的父类DvmObject<?> singedQuery = LibBili.callStaticJniMethodObject(emulator,method,ProxyDvmObject.createObject(vm, map));// 4 打印结果// singedQuery.getValue()--->SignedQuery 的对象-->有个toString方法--->返回字符串--》带sign的字符串,这样结果就是字符串类型了String res=singedQuery.getValue().toString();System.out.println(res);}
2.4 补环境
运行肯定会报错,这里我们就需要补环境
这个就是传入一个字典,判断是否为空
我们有两种操作,一种直接返回True(因为我们知道传入的字典不为空),另一种就是主动调用
那我们又来补这个,这个不就是字典根据键来取值嘛,应该是字符串类型的,只是他用了object来泛指
这里我们知道是string类型,所以可以用方式二来写,如果不知道的话,可以直接用第一种方便的来
我们运行看看
这个不就是调用了java中自己定义的方法吗
这个我就带着大家一步步来写
我们按照他的逻辑来,是不是补成这样,但是少了 SignedQuery类,还有里面的r方法,那我们去源代码里面扣一个来
把这个r方法扣进来,发现报错的有 TextUtils.isEmpty, b, ContainerUtils.FIELD_DELIMITER
很明显b是里面定义的方法,那TextUtils.isEmpty是什么呢,他是安卓中的判断是否为空,可以去chatgpt问一下,这里是不是可以直接调用string中的内置方法isEmpty()来替换呢ContainerUtils.FIELD_DELIMITER我们双击点进去发现是"&",也可以直接替换
b方法直接c就行了
我们把那些修改,再把b给扣进去,发现又需要c,我们再扣,这后面我就不说了,流程都一样,接着扣,修改就行了,补完之后,我们再运行
那我们就接着补,我们补之前先来分析一下这个代码,这个不就是SignedQuery类实例化的时候传入两个参数嘛,而这个第二个参数不就是我们要的sign值嘛,当时我们说了可以完整补完,也可以部分补,那我们来演示一下
这样不就是了吗,这里异常报错是因为我们没有补完,因为我们最后返回的对象是个空嘛,然后上面最开始的又打印了最后的字符串,所以报错了
那我们现在完整的补
发现我们打印的结果是这个鬼样子,这个是为啥呢,这个不就是打印java中类的地址吗,只有调用的,这个不就是没有调用toString嘛,还记得我们当是不是通过toString来定位加密位置的,所以我们抠代码要把toString带上,那个相当于重写
这样就出结果啦
这里给出完整补环境代码
//放到sign内部@Overridepublic boolean callBooleanMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) {if(signature.equals("java/util/Map->isEmpty()Z")){//这个就是判断是不是这个字典是不是空嘛//方式一,直接返回True,因为我们已经知道这个字典不是空//return true;//方式二 主动调用,取出传入的对象Map m=(Map)dvmObject.getValue();// 打印这个map看一下,就是我们拼凑出来,传入的return m.isEmpty();}return super.callBooleanMethod(vm, dvmObject, signature, varArg);}@Overridepublic DvmObject<?> callObjectMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) {if(signature.equals("java/util/Map->get(Ljava/lang/Object;)Ljava/lang/Object;")){// 报错是:map根据key去取值--》c调用java--》map.get('actual_played_time')// 方式一:使用Object泛指:// Map m =(Map) dvmObject.getValue();// //取出参数:取第0个参数--》我们这个只有一个参数//Object key=varArg.getObjectArg(0).getValue();// Object value=m.get(key);// return ProxyDvmObject.createObject(vm,value);//方式二,知道了具体是string类型Map m =(Map) dvmObject.getValue();//取出参数:取第0个参数--》我们这个只有一个参数String key=(String)varArg.getObjectArg(0).getValue();String value=(String)m.get(key);return new StringObject(vm,value);}return super.callObjectMethod(vm, dvmObject, signature, varArg);}@Overridepublic DvmObject<?> callStaticObjectMethod(BaseVM vm, DvmClass dvmClass, String signature, VarArg varArg) {if(signature.equals("com/bilibili/nativelibrary/SignedQuery->r(Ljava/util/Map;)Ljava/lang/String;")){// SignedQuery对象--》调用r方法,传入map,返回字符串// 首先拿到参数 varArg.getObjectArgMap m =(Map) varArg.getObjectArg(0).getValue();// 然后我们取对象就不能用dvmObject,因为这个里面没有SignedQuery,apk定义的,得我们主动创建String res=SignedQuery.r(m);return new StringObject(vm,res);}return super.callStaticObjectMethod(vm, dvmClass, signature, varArg);}@Overridepublic DvmObject<?> newObject(BaseVM vm, DvmClass dvmClass, String signature, VarArg varArg) {if(signature.equals("com/bilibili/nativelibrary/SignedQuery-><init>(Ljava/lang/String;Ljava/lang/String;)V")){// 方式一:打印sign 第二个参数就是// String sign=(String) varArg.getObjectArg(1).getValue();// System.out.println(sign);// return vm.resolveClass("com/bilibili/nativelibrary/SignedQuery").newObject(null);// 方式二:补齐,实例化得到对象String v1=(String) varArg.getObjectArg(0).getValue();String sign=(String) varArg.getObjectArg(1).getValue();SignedQuery sq=new SignedQuery(v1,sign);return vm.resolveClass("com/bilibili/nativelibrary/SignedQuery").newObject(sq);}return super.newObject(vm, dvmClass, signature, varArg);}//定义类,单独放
class SignedQuery{public final String a;public final String b;private static final char[] f15567c = "0123456789ABCDEF".toCharArray();public SignedQuery(String str, String str2) {this.a = str;this.b = str2;}static String r(Map<String, String> map) {if (!(map instanceof SortedMap)) {map = new TreeMap(map);}StringBuilder sb = new StringBuilder(256);for (Map.Entry<String, String> entry : map.entrySet()) {String key = entry.getKey();if (!key.isEmpty()) {sb.append(b(key));sb.append("=");String value = entry.getValue();sb.append(value == null ? "" : b(value));sb.append("&");}}int length = sb.length();if (length > 0) {sb.deleteCharAt(length - 1);}if (length == 0) {return null;}return sb.toString();}static String b(String str) {return c(str, null);}static String c(String str, String str2) {StringBuilder sb = null;if (str == null) {return null;}int length = str.length();int i = 0;while (i < length) {int i2 = i;while (i2 < length && a(str.charAt(i2), str2)) {i2++;}if (i2 == length) {if (i == 0) {return str;}sb.append((CharSequence) str, i, length);return sb.toString();}if (sb == null) {sb = new StringBuilder();}if (i2 > i) {sb.append((CharSequence) str, i, i2);}i = i2 + 1;while (i < length && !a(str.charAt(i), str2)) {i++;}try {byte[] bytes = str.substring(i2, i).getBytes("UTF-8");int length2 = bytes.length;for (int i3 = 0; i3 < length2; i3++) {sb.append('%');sb.append(f15567c[(bytes[i3] & 240) >> 4]);sb.append(f15567c[bytes[i3] & 15]);}} catch (UnsupportedEncodingException e) {throw new AssertionError(e);}}return sb == null ? str : sb.toString();}private static boolean a(char c3, String str) {return (c3 >= 'A' && c3 <= 'Z') || (c3 >= 'a' && c3 <= 'z') || !((c3 < '0' || c3 > '9') && "-_.~".indexOf(c3) == -1 && (str == null || str.indexOf(c3) == -1));}public String toString() {String str = this.a;if (str == null) {return "";}if (this.b == null) {return str;}return this.a + "&sign=" + this.b;}}
三.PDD anti-token破解
3.1 目标
1.目标
破解搜索功能请求头中的 anti-token
2.版本选择:v6.32.0
1 通过hook查找方法在那个so文件中--》之前学过
-动态注册脚本,静态注册脚本
3.补环境:app内部的参数值,系统参数值---》通过frida-hook得到--》搜索得到写死4.使用SocksDroid转发再进行抓包
3.2 抓包和反编译搜索
我们发现有时候配置了 SocksDroid也抓不到,那我们在无网络的时候进入,然后有网络了立马抓,这样就能抓到了
我们要破的就是这个吗,那我们现在来发编译搜索
我们直接搜索anti-token
发现很多地方,我这里直接告诉大家是第四个,我们点进去
发现f就是那个,那我们点进去看看
发现f是是一个接口,那我们是不是要找到这个接口的具体实现类,那我们查找这个接口的用例
这里我告诉大家是最后一个,我们点进去
我们找到这个f的实现类在这里,我们再点进去
我们再点进去
发现在这里,这不就是jni的方法吗,但是这里没有给出哪个so文件,我们之前给大家说过hook静态注册和动态注册,不知道大家记不记得,但是在hook之前我们先hook确定传入的参数,第一个参数是context,我们先不管,看看另一个参数是啥
发现参数就是时间戳
那我们接下来就hook一下是静态注册和动态注册来判断so文件了,我直接告诉大家是动态注册,大家也可以去试试
hook完长这样 说明是在libpdd_secure.so里面,那我们把这两个都c到unidbg里面去
3.3 unidbg跑
我们编写好主要的初始化函数和sign方法
我们运行一下
发现报错,那我们开始补环境
3.4 补环境
3.4.1 补腾讯日志库
我们在刚才报错截图里可以看到tencent,那我们百度搜索一下,这个是腾讯日志库,那我们就补
他这个是日志,而且是void,我们直接return; 就好了
3.4.2 补手机权限
我们搜索发现 这个是获取手机权限的
1 报错是: android/content/Context->checkSelfPermission(Ljava/lang/String;)I
2 安卓开发者--》在Manifest文件中添加权限
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
3 用户使用app,会弹窗提醒4 ContextCompat.checkSelfPermission()方法检测授权状态,返回的结果为PackageManager中的两个常量:PERMISSION_GRANTED(已授权)和PERMISSION_DENIED(未授权)
public static final int PERMISSION_DENIED = -1; # 未授权
public static final int PERMISSION_GRANTED = 0; # 授权
5 获取这个返回值的方式:
获取方式一:我们可以通过hook拿到
获取方式二:我们直接搜索写死-1或0
获取方式三:我们自己写安卓应该,看自己手机的状态
那我们直接返回0,其实要返回-1的,后面出问题了再解释
3.4.3 getSystemService
再次运行
1 android/content/Context->getSystemService(Ljava/lang/String;)Ljava/lang/Object;
-获得SystemService,后期为了拿到:sim卡状态,网络状态,运行商,电池。。。
2 安卓中对应
/*
* SIM的状态信息:
* SIM_STATE_UNKNOWN 未知状态 0
* SIM_STATE_ABSENT 没插卡 1
* SIM_STATE_PIN_REQUIRED 锁定状态,需要用户的PIN码解锁 2
* SIM_STATE_PUK_REQUIRED 锁定状态,需要用户的PUK码解锁 3
* SIM_STATE_NETWORK_LOCKED 锁定状态,需要网络的PIN码解锁 4
* SIM_STATE_READY 就绪状态 5
*/
我们再运行
发现这个错和补环境没关系,那是因为安卓11之后不允许获取手机权限, 所以得返回-1
改完之后再运行
这里我就不和前面一样说为什么了,大家百度搜一下就知道啦,然后补系统方法,我就一次补好,我和大家说一下补app自己的方法
3.4.4 补app自己的方法
我们发现报这个错误,一是可以去app中找逻辑,二是可以直接hook,发现是个固定值,这里就不给出hook脚本了
3.4.5 补系统方法isDebuggerConnected
isDebuggerConnected是否有调试器挂载到程序上
运行
3.4.6 补异常
后面的报错我就先过,自己搜搜就知道了
3.4.7 补replace
补到发现报这个错,这个就是replace,要动脑子,我教大家补
3.4.8 补文件操作
发现报这个错,这个是文件的,我一次给大家补好
这些都补好之后,正常出值了
3.4.9 补环境代码
代码如下
@Overridepublic void callStaticVoidMethodV(BaseVM vm, DvmClass dvmClass, String signature, VaList vaList) {if(signature.equals("com/tencent/mars/xlog/PLog->i(Ljava/lang/String;Ljava/lang/String;)V")){return;}super.callStaticVoidMethodV(vm, dvmClass, signature, vaList);}@Overridepublic int callIntMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) {if(signature.equals("android/content/Context->checkSelfPermission(Ljava/lang/String;)I")){String s=(String) varArg.getObjectArg(0).getValue();// System.out.println(s); // android.permission.READ_PHONE_STATEreturn -1;}if(signature.equals("android/telephony/TelephonyManager->getSimState()I")){return 5; // 表示没插卡}if (signature.equals("android/telephony/TelephonyManager->getNetworkType()I")) {// 网络类型:https://codeleading.com/article/33471321733/return 13;}if (signature.equals("android/telephony/TelephonyManager->getDataState()I")) {return 0;}if (signature.equals("android/telephony/TelephonyManager->getDataActivity()I")) {return 4;}return super.callIntMethod(vm, dvmObject, signature, varArg);}// 补:callObjectMethod@Overridepublic DvmObject<?> callObjectMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) {if(signature.equals("android/content/Context->getSystemService(Ljava/lang/String;)Ljava/lang/Object;")){// 补个null---》后续,在c中调用 SystemService 获取手机状态信息--》再缺什么就继续补:sim卡信息,运行上信息。。。return vm.resolveClass("android/telephony/TelephonyManager").newObject(null);//不能用 ProxyDvmObject.createObject(vm,null) 补}if(signature.equals("android/telephony/TelephonyManager->getSimOperatorName()Ljava/lang/String;")){// 拿运营商:getSimOperatorNamereturn new StringObject(vm, "中国电信");}if(signature.equals("android/telephony/TelephonyManager->getSimCountryIso()Ljava/lang/String;")){// 拿地区return new StringObject(vm, "cn");}if (signature.equals("android/telephony/TelephonyManager->getNetworkOperator()Ljava/lang/String;")) {//https://blog.csdn.net/ztp800201/article/details/44198031/return new StringObject(vm, "46003");}if (signature.equals("android/telephony/TelephonyManager->getNetworkOperatorName()Ljava/lang/String;")) {//https://blog.csdn.net/Myfittinglife/article/details/118685804return new StringObject(vm, "中国电信");}if (signature.equals("android/telephony/TelephonyManager->getNetworkCountryIso()Ljava/lang/String;")) {// 获取国家代码return new StringObject(vm, "cn");}if (signature.equals("java/lang/Throwable->getStackTrace()[Ljava/lang/StackTraceElement;")) {return new ArrayObject(vm.resolveClass("java/lang/StackTraceElement").newObject(null));}if (signature.equals("java/lang/StackTraceElement->getClassName()Ljava/lang/String;")) {return new StringObject(vm, "");}if (signature.equals("java/io/ByteArrayOutputStream->toByteArray()[B")) {ByteArrayOutputStream obj = (ByteArrayOutputStream) dvmObject.getValue();return new ByteArray(vm, obj.toByteArray());}return super.callObjectMethod(vm, dvmObject, signature, varArg);}@Overridepublic DvmObject<?> callStaticObjectMethodV(BaseVM vm, DvmClass dvmClass, String signature, VaList vaList) {if (signature.equals("com/xunmeng/pinduoduo/secure/EU->gad()Ljava/lang/String;")) {return new StringObject(vm, "7202111111112f2");}return super.callStaticObjectMethodV(vm, dvmClass, signature, vaList);}@Overridepublic boolean callStaticBooleanMethod(BaseVM vm, DvmClass dvmClass, String signature, VarArg varArg) {if (signature.equals("android/os/Debug->isDebuggerConnected()Z")) {return false;}return super.callStaticBooleanMethod(vm, dvmClass, signature, varArg);}@Overridepublic DvmObject<?> newObject(BaseVM vm, DvmClass dvmClass, String signature, VarArg varArg) {if (signature.equals("java/lang/Throwable-><init>()V")) {Throwable t=new Throwable(); // 可以new出来,可以传nullreturn vm.resolveClass("java/lang/Throwable").newObject(t);}//补文件if (signature.equals("java/io/ByteArrayOutputStream-><init>()V")) {ByteArrayOutputStream obj = new ByteArrayOutputStream();return vm.resolveClass("java/io/ByteArrayOutputStream").newObject(obj);}if (signature.equals("java/util/zip/GZIPOutputStream-><init>(Ljava/io/OutputStream;)V")) {try {OutputStream chunk = (OutputStream) varArg.getObjectArg(0).getValue();GZIPOutputStream obj = new GZIPOutputStream(chunk);return vm.resolveClass("java/util/zip/GZIPOutputStream").newObject(obj);} catch (Exception e) {System.out.println("写入错误1" + e);}}return super.newObject(vm, dvmClass, signature, varArg);}public DvmObject<?> callObjectMethodV(BaseVM vm, DvmObject<?> dvmObject, String signature, VaList vaList) {if (signature.equals("java/lang/String->replaceAll(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;")) {String origin = (String) dvmObject.getValue();String a0 = (String) vaList.getObjectArg(0).getValue();String a1 = (String) vaList.getObjectArg(1).getValue();String result = origin.replaceAll(a0, a1);return new StringObject(vm, result);}return super.callObjectMethodV(vm, dvmObject, signature, vaList);}public void callVoidMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) {if (signature.equals("java/util/zip/GZIPOutputStream->write([B)V")) {GZIPOutputStream obj = (GZIPOutputStream) dvmObject.getValue();byte[] chunk = (byte[]) varArg.getObjectArg(0).getValue();try {obj.write(chunk);} catch (Exception e) {}return;}if (signature.equals("java/util/zip/GZIPOutputStream->finish()V")) {GZIPOutputStream obj = (GZIPOutputStream) dvmObject.getValue();try {obj.finish();} catch (Exception e) {}return;}if (signature.equals("java/util/zip/GZIPOutputStream->close()V")) {GZIPOutputStream obj = (GZIPOutputStream) dvmObject.getValue();try {obj.close();} catch (Exception e) {}return;}super.callVoidMethod(vm, dvmObject, signature, varArg);}
四.总结
今天的内容很多,主要pdd的环境是真的难,难所以值钱呀,我至少写了10个小时,感谢点赞关注加收藏,发现补环境是不是很恼火,没办法,纯算更难,加油补吧!
补充
有需要源码的看我主页签名名字私