app的登录破解 frida jadx

今天收到了一个APP让我研究一下登录 登录已经研究完成 下面则是我的整体思路

为了安全考虑这个app我就不说是那个了 我就说整体的思路
仅供交流学习 严谨非法使用

开始进行抓包

手机使用代理连接charles
之后开始点击app登录 进行抓包

我用的是这个app代理
开始代理

下面则是我抓到的包 

抓包

抓包之后j进行改包
也就是去掉form中的随机一个参数进行请求发送 这一步的目的就是去除掉没用的参数
这样的话就可以在逆向的时候减少工作量下面我告诉大家如何该报

改包点击
在这里插入图片描述

按照上面的步骤进行改包 然后发送请求看是否能够成功 如果不能成功的话这个参数是不能去除的

将app进行反编译

这里我用的是jadx
反编译成功之后 如下 注意看箭头标记的位置 如果包很多并没有乱码或者包少初步可以判断是没有加固
如果初步判断没有进行加壳那么就可以进行搜索这里有两个搜索方案 搜索url 也就是发请求的那个 login.ashx搜索关键字 也就是form中的  而我搜索的是关键字

注意看这里

我 搜索到了第一个

在这里插入图片描述

看起来是个常量  
按照开发的逻辑来说常量是一个经常使用并且不变的 那么就是他了 咱们翻翻这一页的代码
很遗憾 并不是 继续看下一个 也就上面图中的最后一个

在这里插入图片描述

最后一个让我找到了可能是 因为有好多我发现的参数 也就是请求的参数里面看起来都有

在这里插入图片描述

既然找到了 那么就一个个进行破解

首先是
KEY_APP_ID这个是个常量的Key 值话也是个常量 那么好 第一个参数已经破解完成
channelid这个key 并不是个常量 这时候可以用frida进行调用 

开始用frida

先进行注入检测 也就是随便一个函数看看是否有检测 
很幸运 这个app并没有任何检测

开始接下来的一步开始进行破译

上面说到了 channelid这个值getChannelId 是这个函数产生的 那么我就开始用frida检测这个值看看他的参数是什么
import frida
import sysrdev = frida.get_remote_device()
pid = rdev.spawn(["xxxx"])
session = rdev.attach(pid)
scr = """
Java.perform(function(){    var AppUtils = Java.use("xxxx.util.AppUtils")AppUtils.getChannelId.implementation = function(c){var res = this.getChannelId(c)console.log(res,"getChannelId")return res} 
})
"""script = session.create_script(scr)def on_message(message, data):print(message, data)script.on("message", on_message)
script.load()
rdev.resume(pid)
sys.stdin.read()
我的经验是多hook几次看看是否是同一个值 如果是的话那么就直接用就好了 
这里我多试了几次值是一样的 那么我就可以直接用了好 接下来就开始破译下一个KEY_APP_VERSION这个看起来是个版本号 
按照上面的代码 继续使用getCannelId这个hook脚本继续开始hook  还是建议多hook几次
好 我发现还是一样的 那么好! 那还是继续用接下来就是下一个参数udid这个get_uuid 还是用上面的代码进行hook(记得改函数和包 xxx 哪里) 这个参数 我还是按照习惯多来了几次 发现每次都是不一样的 好那么深入进行探究!
 public static String getUDID(Context context) {return SecurityUtil.encode3Des(context, getIMEI(context) + HiAnalyticsConstant.REPORT_VAL_SEPARATOR + System.nanoTime() + HiAnalyticsConstant.REPORT_VAL_SEPARATOR + SPUtils.getDeviceId());}
这个是代码我发现了有时间生成 那确实每次都会不一样
好 接下来继续深层次研究除了时间的每个参数
getIMEI  多hook 几次看看是不是值是一样的
REPORT_VAL_SEPARATOR  这个是个常量 
getDeviceId	 多hook 几次看看是不是值是一样的根据验证 上面的值每次都是一样的! 好 接下来那么就继续下一步 用python进行组装
def make_uuid(imei,report_val_separator,nano_time,getDeviceId,
):make_str = imei + report_val_separator + str(nano_time) + report_val_separator + getDeviceIdreturn make_struuid = make_uuid(imei="xxxx",report_val_separator="xxxx",nano_time=time.time_ns(),getDeviceId="xxxx",
)
很好那么看起来
context, getIMEI(context) + HiAnalyticsConstant.REPORT_VAL_SEPARATOR + System.nanoTime() + HiAnalyticsConstant.REPORT_VAL_SEPARATOR + SPUtils.getDeviceId()encode3Des 这个第二个参数已经破译好了 这次开始破译 encode3Des 
  public static String encode3Des(Context context, String str) {String desKey = AHAPIHelper.getDesKey(context);byte[] bArr = null;if (TextUtils.isEmpty(desKey)) {return null;}try {SecretKey generateSecret = SecretKeyFactory.getInstance("desede").generateSecret(new DESedeKeySpec(desKey.getBytes()));Cipher instance = Cipher.getInstance("desede/CBC/PKCS5Padding");instance.init(1, generateSecret, new IvParameterSpec(iv.getBytes()));bArr = instance.doFinal(str.getBytes("UTF-8"));} catch (Exception unused) {}return encode(bArr).toString();}
这段代码看起来就是个加密  3DES(Triple DES)加密,也称为 DESede
那么好 代码里面也没什么难的地方  那么就改成Python吧
from Crypto.Cipher import DES3
from Crypto.Util.Padding import pad
import base64def encode_3des(des_key, data, iv):if len(des_key) != 24:raise ValueError("The DES key must be 24 bytes long for 3DES.")# 确保密钥长度为 24 字节des_key = des_key.encode('utf-8')[:24]cipher = DES3.new(des_key, DES3.MODE_CBC, iv.encode('utf-8'))# 对输入数据进行 paddingpadded_data = pad(data.encode('utf-8'), DES3.block_size)# 加密数据encrypted_data = cipher.encrypt(padded_data)# 返回加密后的数据,并进行 base64 编码return base64.b64encode(encrypted_data).decode('utf-8')# 示例调用
des_key = "your_24_byte_key_here"
iv = "your_8_byte_iv_here"  # IV 长度为 8 字节
data = "The data to encrypt"
encoded_data = encode_3des(des_key, data, iv)
print(f"Encrypted data: {encoded_data}")
其中des_key需要拿到

发现是个so文件

看起来是个so文件按照我的经验来说继续hook这个 多hook几次看看值是不是一样的  经验看 很多都是死值 除了大型app  很好 这个是个死值那俺么我就得到了 des_key
IV现在还差一IV  但是他这个IV是常量private static final String iv = "appapich";很好很好 UUID 我已经完成
import frida
import sysrdev = frida.get_remote_device()
pid = rdev.spawn(["xxxx"])
session = rdev.attach(pid)
scr = """
Java.perform(function(){    var AppUtils = Java.use("xxxx.util.AppUtils")AppUtils.getChannelId.implementation = function(c){var res = this.getChannelId(c)console.log(res,"getChannelId")return res} 
})
接下来看下一个参数userkey 这个在请求中并没有发现这个值 如果下面没有引用的话 那么就不管checkNullParams(treeMap); 这个干了什么 去看看
private static void checkNullParams(Map<String, String> map) {for (String str : map.keySet()) {if (map.get(str) == null) {map.put(str, "");}}}
这段 Java 代码的目的是检查给定的 Map<String, String> 中的每个键值对,
如果某个值是 null,则将该值替换为空字符串 ""
接下来看这个代码
String signByType = SignManager.INSTANCE.signByType(i, treeMap);
还是进行hook下面的代码
public final String signByType(@SignType int i, TreeMap<String, String> paramMap) {Intrinsics.checkNotNullParameter(paramMap, "paramMap");StringBuilder sb = new StringBuilder();String str = KEY_V1;if (i != 0) {if (i == 1) {str = KEY_V2;} else if (i == 2) {str = KEY_SHARE;} else if (i == 3) {str = KEY_AUTOHOME;}}sb.append(str);for (String str2 : paramMap.keySet()) {sb.append(str2);sb.append(paramMap.get(str2));}sb.append(str);String encodeMD5 = SecurityUtil.encodeMD5(sb.toString());if (encodeMD5 != null) {Locale ROOT = Locale.ROOT;Intrinsics.checkNotNullExpressionValue(ROOT, "ROOT");String upperCase = encodeMD5.toUpperCase(ROOT);Intrinsics.checkNotNullExpressionValue(upperCase, "this as java.lang.String).toUpperCase(locale)");if (upperCase != null) {return upperCase;}}return "";}
这个下面进行kook
import frida
import sysrdev = frida.get_remote_device()
pid = rdev.spawn(["xxxx"])
session = rdev.attach(pid)
scr = """
Java.perform(function(){    var AppUtils = Java.use("xxxx.util.AppUtils")AppUtils.signByType.implementation = function(i,tree){console.log(i,"getChannelId i")console.log(tree,"getChannelId tree")var res = this.signByType(i,tree)console.log(res,"getChannelId")return res} 
})
接下来也是一行行查看Intrinsics.checkNotNullParameter(paramMap, "paramMap");StringBuilder sb = new StringBuilder();String str = KEY_V1;if (i != 0) {if (i == 1) {str = KEY_V2;} else if (i == 2) {str = KEY_SHARE;} else if (i == 3) {str = KEY_AUTOHOME;}}sb.append(str);for (String str2 : paramMap.keySet()) {sb.append(str2);sb.append(paramMap.get(str2));}sb.append(str);上面就是按照i进行了是那个i进行了拼接 没什么可看的 那就按照他的做下面就是按照MD5进行了加密String encodeMD5 = SecurityUtil.encodeMD5(sb.toString());
Locale ROOT = Locale.ROOT;
Intrinsics.checkNotNullExpressionValue(ROOT, "ROOT");
String upperCase = encodeMD5.toUpperCase(ROOT);
Intrinsics.checkNotNullExpressionValue(upperCase, "this as java.lang.String).toUpperCase(locale)");
这个代码就是可以理解成转换成大写  从这里开看 已经完成了大部分的参数 下面是我的python实现
def encode_md5(s):# 创建 MD5 哈希对象md5 = hashlib.md5()# 更新哈希对象md5.update(s.encode('utf-8'))# 获取十六进制格式的哈希值return md5.hexdigest()def make_uuid(imei,report_val_separator,nano_time,getDeviceId,
):make_str = imei + report_val_separator + str(nano_time) + report_val_separator + getDeviceIdreturn make_struuid = make_uuid(imei="x",report_val_separator="x",nano_time=time.time_ns(),getDeviceId="x",
)
print(uuid)def make_3DES(desKey, data):from Crypto.Cipher import DES3from Crypto.Util.Padding import padfrom Crypto.Random import get_random_bytesimport base64# 你提供的 IV 值,或者可以动态生成iv = b"appapich"  # 或者使用一个更安全的随机IVif len(desKey) != 24:raise ValueError("The DES key must be 24 bytes long for 3DES.")# 确保 key 的长度是 24 字节desKey = desKey.encode('utf-8')[:24]cipher = DES3.new(desKey, DES3.MODE_CBC, iv)# 对输入数据进行 paddingpadded_data = pad(data.encode('utf-8'), DES3.block_size)# 加密数据encrypted_data = cipher.encrypt(padded_data)# 返回加密后的数据,并进行 base64 编码return base64.b64encode(encrypted_data).decode('utf-8')desKey = "xxxxxxxx"  # 用你自己的 desKey 替换
encoded_data = make_3DES(desKey[0:24], uuid)def sign_type(param_map):import hashlib# 密钥定义 (替换成相应的密钥)KEY_V1 = "W@oC!AH_6Ew1f6%8"KEY_V2 = "W@oC!AH_6Ew1f6%8"KEY_SHARE = "W@oC!AH_6Ew1f6%8"KEY_AUTOHOME = "W@oC!AH_6Ew1f6%8"def sign_by_type(i, param_map):# 参数检查if not isinstance(param_map, dict):raise ValueError("param_map must be a dictionary")# 根据 i 选择密钥if i == 0:key = KEY_V1elif i == 1:key = KEY_V2elif i == 2:key = KEY_SHAREelif i == 3:key = KEY_AUTOHOMEelse:raise ValueError("Invalid value for 'i'")# 拼接字符串sb = keyfor key_str, value_str in param_map.items():sb += key_str + value_strsb += key# 计算 MD5md5_result = hashlib.md5(sb.encode('utf-8')).hexdigest().upper()return md5_result# 示例用法i = 1  # 用你提供的类型值signed_result = sign_by_type(i, param_map)return signed_result_sign = sign_type(param_map={'_appid': 'xxxx','appversion': 'xxxx','channelid': 'xxxx','pwd': '96e79218965eb72c92a549dd5a330112','signkey': '','type': '','udid': encoded_data,'username': '15633624055'
}
)
完成到现在还差一个那就是密码加密
那么就开始查看密码加密
这个时候就不能搜索pwd  经过上面的排查 应该是每个请求都会带这些参数看起来就像中间件一样 那么就搜索url  
第一个是常量 第二个是引用SecurityUtil.encodeMD5(str3)就是普通的MD5加密 
public static final String LOGIN_URL = "/tradercloud/sealed/login/login.ashx";
    public static void loginByPassword(String str, String str2, String str3, String str4, String str5, ResponseCallback<UserBean> responseCallback) {HttpUtil.Builder builder = new HttpUtil.Builder();builder.tag(str).method(HttpUtil.Method.POST).signType(1).url(LOGIN_URL).param("username", str2).param("type", str4).param("signkey", str5).param("pwd", SecurityUtil.encodeMD5(str3));doRequest(builder, responseCallback, new TypeToken<BaseResult<UserBean>>() { // from class: com.che168.autotradercloud.user.model.UserModel.5}.getType());}

结束

下面就是我的完成整合后的python代码 
已经完成了登录加密破解 涉密参数我用的X 
import timeimport requestsdef start(pwd,_appid,channelid,appversion,_sign,signkey,type_,udid,username):import requestsurl = 'x'headers = {'Host': 'x','Cache-Control': 'public, max-age=0','Traceid': 'x','Content-Type': 'application/x-www-form-urlencoded','User-Agent': 'okhttp/3.14.9',}data = {'pwd': pwd,'_appid': _appid,'channelid': channelid,'appversion': appversion,'_sign': _sign,'signkey': signkey,'type': type_,'udid': udid,'username': username,}# 禁用代理proxies = {"http": None,"https": None,}try:response = requests.post(url, headers=headers, data=data, proxies=proxies)print(response.text)  # 打印响应内容except requests.exceptions.RequestException as e:print(f"请求失败: {e}")import hashlibdef encode_md5(s):# 创建 MD5 哈希对象md5 = hashlib.md5()# 更新哈希对象md5.update(s.encode('utf-8'))# 获取十六进制格式的哈希值return md5.hexdigest()def make_uuid(imei,report_val_separator,nano_time,getDeviceId,
):make_str = imei + report_val_separator + str(nano_time) + report_val_separator + getDeviceIdreturn make_struuid = make_uuid(imei="x",report_val_separator="x",nano_time=time.time_ns(),getDeviceId="x",
)
print(uuid)def make_3DES(desKey, data):from Crypto.Cipher import DES3from Crypto.Util.Padding import padfrom Crypto.Random import get_random_bytesimport base64# 你提供的 IV 值,或者可以动态生成iv = b"appapich"  # 或者使用一个更安全的随机IVif len(desKey) != 24:raise ValueError("The DES key must be 24 bytes long for 3DES.")# 确保 key 的长度是 24 字节desKey = desKey.encode('utf-8')[:24]cipher = DES3.new(desKey, DES3.MODE_CBC, iv)# 对输入数据进行 paddingpadded_data = pad(data.encode('utf-8'), DES3.block_size)# 加密数据encrypted_data = cipher.encrypt(padded_data)# 返回加密后的数据,并进行 base64 编码return base64.b64encode(encrypted_data).decode('utf-8')desKey = "appapiche168comappapiche168comap"  # 用你自己的 desKey 替换
encoded_data = make_3DES(desKey[0:24], uuid)def sign_type(param_map):import hashlib# 密钥定义 (替换成相应的密钥)KEY_V1 = "x"KEY_V2 = "x"KEY_SHARE = "x"KEY_AUTOHOME = "x"def sign_by_type(i, param_map):# 参数检查if not isinstance(param_map, dict):raise ValueError("param_map must be a dictionary")# 根据 i 选择密钥if i == 0:key = KEY_V1elif i == 1:key = KEY_V2elif i == 2:key = KEY_SHAREelif i == 3:key = KEY_AUTOHOMEelse:raise ValueError("Invalid value for 'i'")# 拼接字符串sb = keyfor key_str, value_str in param_map.items():sb += key_str + value_strsb += key# 计算 MD5md5_result = hashlib.md5(sb.encode('utf-8')).hexdigest().upper()return md5_result# 示例用法i = 1  # 用你提供的类型值signed_result = sign_by_type(i, param_map)return signed_result_sign = sign_type(param_map={'_appid': 'x','appversion': 'x','channelid': 'x','pwd': 'x','signkey': '','type': '','udid': encoded_data,'username': 'x'
}
)start(pwd=encode_md5("x"),_appid="x",channelid="x",appversion="x",udid=encoded_data,_sign=_sign,signkey="",type_="",username="x",)# 示例用法

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.xdnf.cn/news/3392.html

如若内容造成侵权/违法违规/事实不符,请联系一条长河网进行投诉反馈,一经查实,立即删除!

相关文章

【IEEE/CCF-C类】1区顶刊变水刊?发文量暴涨1600+,光速审稿,圆你顶刊梦!

&#x1f525; &#x1f525; &#x1f525; &#x1f525; 本期小编解析的是一本由IEEE旗下多个学会联合出版的计算机领域的TOP期刊《IEEE Internet of Things Journal》&#xff0c;该期刊自2014年创刊&#xff0c;专注于物联网&#xff08;IoT&#xff09;领域的研究…

django高校学生信息管理系统-计算机毕业设计源码02553

django高校学生信息管理系统 摘 要 本研究旨在设计和实现基于Django框架的高校学生信息管理系统&#xff0c;涵盖了系统用户、学生信息管理、教师信息管理、课程分类管理、开课信息管理、选课信息管理、课表信息管理、成绩信息管理、系统管理、网站公告管理和校园资讯等多个功能…

特殊矩阵的压缩存储

一维数组的存储结构 ElemType arr[10]; 各数组元素大小相同&#xff0c;且物理上连续存放。 数组元素a[i]的存放地址 LOC i * sizeof(ElemType)。&#xff08;LOC为起始地址&#xff09; 二维数组的存储结构 ElemType b[2][4];二维数组也具有随机存取的特性&#xff08;需…

中立性DEA交叉效率评价方法

今天推出中立性DEA模型的计算工具 参考文献&#xff1a;《中立性DEA交叉效率评价方法》袁剑波&#xff0c;吴立辉&#xff0c;魏思 中立性DEA交叉效率评价方法 在数据包络分析&#xff08;DEA&#xff09;对决策单元效率评价的方法中&#xff0c;对抗性DEA交叉效率方法把所有…

【Visual Studio】解决 CC++ 控制台程序 printf 函数输出中文和换行符显示异常

问题描述 C&C 控制台程序 printf 函数输出中文和换行符 \n 显示异常。 #include <stdio.h>int main() {int choice;printf("菜单:\n");printf("1. 选项一\n");printf("2. 选项二\n");printf("3. 选项三\n");printf("…

【dvwa靶场:XSS系列】XSS (Stored)低-中-高级别,通关啦

更改name的文本数量限制大小&#xff0c; 其他我们只在name中进行操作 【除了低级可以在message中进行操作】 一、低级low <script>alert("假客套")</script> 二、中级middle 过滤了小写&#xff0c;咱们可以大写 <Script>alert("假客套…

【Linux】深入理解进程控制:从创建到终止和进程等待

文章目录 进程创建fork函数如何用fork函数创建子进程写实拷贝 进程终止错误信息exit_exit 进程等待waitwaitpid 总结 进程创建 fork函数 fork 函数是 Unix/Linux 系统中用于创建新进程的系统调用。调用 fork 后&#xff0c;当前进程&#xff08;父进程&#xff09;会被复制&a…

【java】对象的内存存储

目录 对象在内存中的分配设计到的内存结构(理论)类中对象的内存解析创建类的一个对象&#xff0c;属性赋值创建类的多个对象&#xff0c;属性赋值 对象在内存中的分配设计到的内存结构(理论) 栈&#xff1a;方法内定义的变量&#xff0c;存储在栈中 堆&#xff1a;new出来的结…

【wrl2stl】WRL文件转STL文件-Python

之前有一篇博客写了Avizo自动化批量导出wrl文件&#xff1a;【Avizo&Python】离散颗粒的分割、网格化与单颗粒批量自动保存wrl文件_avizo python-CSDN博客 还有一篇写了wrl转为xyz格式文件&#xff1a; Wrl文件转XYZ文件-Python_python 打开wrl三维模型-CSDN博客 在这篇…

【Linux系统编程】第三十九弹---探索信号处理的奥秘:阻塞信号与sigset_t的深入剖析及实战

✨个人主页&#xff1a; 熬夜学编程的小林 &#x1f497;系列专栏&#xff1a; 【C语言详解】 【数据结构详解】【C详解】【Linux系统编程】 目录 1、信号处理 2、阻塞信号 2.1、信号其他相关常见概念 2.2、在内核中的表示 2.3、sigset_t 2.4、信号集操作函数 3、完整…

零基础Apifox测试FastAPI接口入门

文章目录 一、FastAPI部分二、Apifox部分1、安装Apifox2、创建接口3、更改测试环境4、发送请求 一、FastAPI部分 python使用fastapi编写接口内容&#xff08;文件名&#xff1a;text.py&#xff09;&#xff1a; from fastapi import FastAPI import uvicornapp FastAPI()ap…

Linux终端退出程序后,TCP地址仍被占用

报错如下&#xff1a; Error on binding: Address already in use 这是一个正在运行的服务器&#xff0c;运行在linux的终端。上一次我使用CtrlZ退出这个程序&#xff0c;再次./my_server想运行这个程序时&#xff0c;出现这个报错。这是由两点原因&#xff1a; 1、守护进程或…

嵌入式通信协议:IIC简明学习笔记

IIC学习笔记 IIC特点 1.适合 小数据场合使用&#xff0c;传输距离短。 2.只能有一个主机。 3.标准IIC速度为100kHZ&#xff0c;高速IIC一般可达400kHZ以上。 4.SCL和SDA都需要接上拉电阻&#xff08;大小由速度和容性负载决定&#xff0c;一般在3.3k-10k之间&#xff09;。 5…

基于anaconda的python3.6安装opencv4.1.15

opencv-python一些新版本由于部分函数涉及专利问题&#xff0c;如sift和surf&#xff0c;有些功能不能很好地被使用&#xff0c;所以最好使用opencv-python 3.4.1.15版本的。 下载地址分别为&#xff1a; 1、https://pypi.tuna.tsinghua.edu.cn/simple/opencv-python/ 查找…

【自制操作系统】0x01MBR

环境 ubuntu 20.04 gcc 9.4.0&#xff08;加载硬盘程序之前都是&#xff0c;最后可能会切换到 gcc 4.4&#xff09; bochs 2.7 bochs 配置 bochs 安装之前文章记录过&#xff0c;现在记录一下本次使用的bochs配置 bochsrc #第一步&#xff0c;首先设置 Bochs 在运行过程中…

SpringBoot接入星火认知大模型

文章目录 准备工作整体思路接入大模型服务端和大模型连接客户端和服务端的连接测试 准备工作 到讯飞星火大模型上根据官方的提示申请tokens 申请成功后可以获得对应的secret&#xff0c;key还有之前创建的应用的appId&#xff0c;这些就是我们要用到的信息 搭建项目 整体思…

ssm056基于Java语言校园快递代取系统的设计与实现+jsp(论文+源码)_kaic

毕 业 设 计&#xff08;论 文&#xff09; 题目&#xff1a;校园快递代取系统设计与实现 摘 要 现代经济快节奏发展以及不断完善升级的信息化技术&#xff0c;让传统数据信息的管理升级为软件存储&#xff0c;归纳&#xff0c;集中处理数据信息的管理方式。本校园快递代取系统…

简单语音信号识别的MATLAB仿真

简单语音信号识别的MATLAB仿真 摘要&#xff1a; 隐马尔可夫模型&#xff08;HMM&#xff09;作为描述语音信号的一种统计模型&#xff0c;在现代语音处理中获得了广泛应用。本文概述了基于HMM的语音识别技术&#xff0c;阐述了预处理&#xff0c;特征提取以及训练&#xff0c…

童年玩具:两款线绳陀螺

1,2图是过去用来安装明线电线的瓷夹。现在应该找不到了。过去安装电线后&#xff0c;家里留下了一些&#xff0c;拿来做线陀螺非常好。 因为它非常重&#xff0c;旋转起来很有力&#xff0c;那声音呼呼响。 3,4图是现在都能看到的一个圆木片&#xff0c;两个孔&#xff0c;穿绳…

AntFlow一款开源免费且自主可控的仿钉钉工作流引擎

在现代企业管理中&#xff0c;流程审批的高效性直接影响到工作的流畅度与生产力。最近&#xff0c;我发现了一个非常有趣的项目——AntFlow。这个项目不仅提供了一个灵活且可定制的工作流平台&#xff0c;还能让用户以可视化的方式创建和管理审批流程。 如果你寻找一个快速集成…