周六比完的半决赛,回来之后我没有急着整理截图,而是把 MediaDrive 和 easy_time 这两道题重新在本机跑了一遍。很多人觉得“复现”就是照着别人的 writeup 敲几个 curl,把 flag 重新打出来一遍。我不太认同这种复现方式,真正有价值的复现是还原题目背后的漏洞形成原因:为什么这个点会出问题、防御方在哪里漏了一步、如果换一个防御姿势是否还能打进去。这两道题刚好代表了两个非常典型的信任边界信任错误,一个是“文件路径”上的,一个是“时间”上的。
如果你以后要参加类似线下赛,或者想在靶场里练手,这篇复盘应该能给你一些能落地的思路。我会尽量把分析过程、踩坑点和最终的复现命令都写清楚,少讲空话。
1. 复现这两道题之前,先确定环境和依赖
1.1 两道题的考点定位
MediaDrive 从名字上看像是媒体驱动类漏洞,很多人第一反应是 PWN 或者内核驱动题。实际进到容器里一看,发现它是一个带媒体文件上传、转存、下载功能的 Web 服务。这类题目在 CTF 里经常被归入 Web,但它考的不是 SQL 注入那种常规漏洞,而是“上传文件之后,服务端对文件内容的信任半径到底应该画在哪”。
easy_time 则明显和“时间”有关。半决赛现场容易踩的坑是把它当成一个纯密码学题目,去找什么强随机数、签名算法漏洞,结果绕了一圈。认真看它的逻辑后会发现,真正的突破点在于服务端用了可预测的时间参数去做签名种子,导致密钥或者 token 可以在非常小的枚举空间里被还原。
我整理了一张简单的对照表,方便你快速理解这两题在复现时需要关注什么:
| 题目 | 入口特点 | 实际考点 | 复现难度 |
|---|---|---|---|
| MediaDrive | 媒体文件上传和下载接口 | 压缩包解压后的符号链接处理 | 中 |
| easy_time | 返回时间戳、验证码等接口 | 可预测时间种子导致的伪造绕过 | 中偏低 |
1.2 复现环境准备
我做复现时没有直接用主办方原封不动的容器,而是先做了两件事:把现场导出的镜像文件拉到本地,再在隔离的 docker 网络里启动一份等价靶机。下面命令是通用的:
docker save -o mediadrive.tar <original_image_name> mkdir -p media_rootfs sudo tar -xf mediadrive.tar -C media_rootfs为什么要先导出镜像?因为线下赛的动态靶场往往会在每个队伍身后拉起多个实例,容器里有些路径会动态变化,如果只凭 wp 里的 URL 路径去复现,大概率会因为目录差异失败。先把容器完整导出来,才能看清它的启动命令、挂载目录和 flag 文件位置。
另外一个经验是先检查 Dockerfile 或镜像元数据,不要急着看代码:
docker history <image_name> --no-trunc docker inspect <image_name> --format '{{json .Mounts}}'这些信息能告诉你容器启动时是不是挂载了额外目录,入口进程是不是被 supervisor 包裹。MediaDrive 和 easy_time 在复现时卡住我的地方,都跟“实际运行目录和源码目录不一致”有关。先解决环境问题,后面分析漏洞才不会做无用功。
2. MediaDrive 复现:压缩包就是天然的“信任边界”突破口
2.1 先看业务链路
MediaDrive 提供的核心功能是媒体文件入库和下载。它的调用关系很清楚:
- 用户通过 POST 接口上传一个媒体文件。
- 服务端根据文件扩展名判断类型。
- 如果是单个媒体文件,直接进入存储目录。
- 如果是压缩包格式,服务端会解压并逐个扫描内部文件,提取元数据和缩略图。
- 下载接口根据数据库里保存的相对路径读取文件并返回。
问题出在第 4 步。大多数人在复现时会下意识去翻download函数,先找路径穿越,比如能不能用../../flag去绕过目录。这就把简单题做复杂了。
2.2 真正的漏洞点:解压后的符号链接被保留
这个服务在解压 tar 和 zip 时,只检查了最外层 HTTP 上传文件的扩展名,没有对压缩包内部条目做安全校验。尤其关键的是,它没检查内部文件是不是符号链接。
我当时写了一个验证脚本。先在本地构造一个指向/flag的符号链接,再用 tar 打包上传:
mkdir -p exp_dir ln -s /flag exp_dir/poc tar -cf poc.tar -C exp_dir poc然后直接把 poc.tar 上传到题目接口:
curl -X POST -F 'file=@poc.tar' http://127.0.0.1:8080/api/media/upload服务端返回了内部文件的 ID,我再用下载接口去访问这个文件:
curl -L http://127.0.0.1:8080/api/media/download?id=<returned_id>返回内容就是/flag的内容。原因在于上传时符号链接被完整保留在存储目录里,下载时服务端拿着数据库里的相对路径直接open()读取文件,而open()在解析路径时会跟随符号链接,最终指向了容器外的路径。
这里有个容易被忽略的细节:你直接上传一个符号链接文件在 HTTP 场景下是不太好操作的,因为 multipart 表单上传的文件内容是一个普通文件,不会保留符号链接属性。但如果你把它塞进 tar 或者 zip,解压逻辑就会按归档文件里的条目去创建真实文件,于是符号链接属性得以保留。
2.3 为什么路径穿越防护没能拦住
很多复现 writeup 里会把这道题写成一个简单的路径穿越,其实不完全准确。路径穿越类漏洞的根源是用户输入了包含../的字符串,而服务端没有做归一化。MediaDrive 的问题更隐蔽,用户上传的并不是一个路径字符串,而是一个文件系统对象。服务端在处理这个对象时,没有意识到它会在服务器上“创建出”一个新的路径语义。
如果防御方只是在下载接口做了前缀校验,比如判断最终路径是否以MEDIA_ROOT开头,这类符号链接依然能绕过。因为前缀校验比较的是路径字符串,而符号链接把它指向了另一个绝对路径,字符串形式上仍然在允许目录内。
一个常见的假修复写法是这样的:
def safe_download(file_name): path = os.path.join(MEDIA_ROOT, file_name) path = os.path.abspath(path) if not path.startswith(MEDIA_ROOT): return forbidden() return open(path).read()看起来好像没问题,但若file_name是数据库里记录的一个符号链接文件名,abspath只会做..和.的清理,不会展开符号链接。最后open(path)还是会把符号链接当作跳板,读到/flag。
真正有效的防御需要在两个层面补:
- 解压归档文件时,拒绝任何符号链接条目。
- 下载读取时,使用
O_NOFOLLOW标识打开文件,避免跟随符号链接。
Python 里可以这样检查 tar 包:
import tarfile with tarfile.open("poc.tar") as tar: for member in tar.getmembers(): if member.issym() or member.islnk(): raise ValueError("symlink is not allowed") tar.extractall(path=MEDIA_ROOT)如果实际环境需要兼容一些合法符号链接,至少也要在下载侧限定真实路径:
import os real_path = os.path.realpath(os.path.join(MEDIA_ROOT, file_name)) if not real_path.startswith(os.path.realpath(MEDIA_ROOT)): return forbidden()2.4 MediaDrive 复现过程中的几个坑
第一个坑是 tar 包格式。如果你在 Windows 上用系统自带的压缩功能打包,出来的 zip 可能不包含符号链接属性,复现自然失效。最好在 Linux 或者 macOS 上打包,并且确认压缩包内部用tar -tvf能看到poc -> /flag这样的显示。
第二个坑是下载接口的重定向。部分实现里下载接口先返回一个临时的 CDN 地址,再用 302 跳转,如果 curl 不加-L就会只看到跳转响应,误以为利用失败。
第三个坑是数据库对文件名的处理。有的服务会把原始文件名改成 UUID,所以你不要只上传一个符号链接文件,还要确保该文件的文件名或者 ID 被记录到数据库,这样下载时才能取到。复现时先观察上传接口返回的 JSON 中是否有file_id字段,如果有就优先用 ID 发起下载请求。
3. easy_time 复现:把“时间”变成密钥的伪随机陷阱
3.1 从服务行为反推核心逻辑
easy_time 这道题在现场没有直接提供源码,我只能通过接口行为去猜。它启动后暴露了三个接口:
- 获取当前服务器时间。
- 获取一个“临时通行码”。
- 用通行码换取管理员才能看到的 flag。
一开始我以为是需要在极短时间内抢时间戳,于是写了很多并发的脚本去碰运气。后来认真看返回数据,发现通行码并不是当前秒的简单哈希,而是与某一小段时间窗口内的种子有关。
为了把题目的核心漏洞还原得更容易看,我写了一个最小白话版本:
import hashlib import time import requests def generate_code(ts): raw = f"{ts}:easy_time_salt".encode() return hashlib.md5(raw).hexdigest()正常情况下,客户端想要拿到通行码,必须知道服务器生成时使用的时间戳。如果这个时间戳不是毫秒级随机,而是用秒级 Unix 时间,那爆破空间就会小很多。
关键是服务端还会在响应里附带当前时间,这就相当于把随机种子暴露了一大半。你只需要做一个窗口枚举,把服务器当前时间的前后几十秒全部跑一遍。
3.2 绕过签名与重放攻击
我的复现脚本思路很简单。先正常请求一次时间接口,拿到服务器时间server_ts,然后通过 HTTP 响应头的 Date 字段再次校准,排除网络延迟带来的时间偏移。
import hashlib import requests import time target = "http://127.0.0.1:8080" flag_url = target + "/api/flag" def make_code(ts: int) -> str: return hashlib.md5(f"{ts}:easy_time_salt".encode()).hexdigest() def try_offset(offset): req_ts = int(time.time()) + offset code = make_code(req_ts) r = requests.get(flag_url, params={"code": code}, timeout=2) return r.status_code, r.text for offset in range(-30, 31): status, body = try_offset(offset) if status == 200 and "flag" in body.lower(): print("[+] offset =", offset) print(body) break为什么优先枚举前后 30 秒?因为网络和容器调用的延迟通常不会超过几秒,加上服务端时钟和本机时钟会有一定偏移,前后各 30 秒已经足够覆盖大多数场景。如果题目把时间因子精确到毫秒,爆破空间会大一些,但也不难,只是从 60 种变成 60000 种,用多线程一样可以秒级跑完。
3.3 这个漏洞给我们什么提醒
easy_time 的本质不是算法复杂,而是开发者错误地把“时间”当成了不可预测的随机源。现实中很多系统也犯同样的问题:重置密码的验证码种子用time(),签名密钥直接用启动时间,或者把时间戳放入序列化数据但不绑定签名。
一旦时间因子参与安全校验,建议至少做三件事:
- 使用加密安全的随机数生成器,而不是时间或
random模块。 - 签名和防重放字段应该使用不可预测的一次性随机数,同时绑定业务 ID 和过期时间。
- 时间戳只能作为过期判断条件,不能作为密钥或种子本身。
复现 easy_time 时,我印象最深的一点是:题目敢叫这个名,可能就是在暗示开发者真的会把 time 直接拿来做安全参数。比赛时候如果能把“它到底生成了什么种子”这一个问题想清楚,这道题基本上属于白给。
4. 实际复现中的问题与排查技巧实录
4.1 题目复现时的常见问题清单
我把这次复现两台机器过程中遇到的问题整理成一个速查表,方便以后遇到类似环境直接对照:
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| MediaDrive 上传后 ID 存在,但下载返回 404 | 数据库中的文件路径被二次拼接,需要观察下载接口 URL 结构 | 对比上传响应和下载请求的路径字段,必要时用浏览器手动构造一次 |
| 符号链接文件上传后变成了普通空文件 | 打包时没有保留链接属性或 Windows 下打包 | 使用 Linux 打包,并用tar -tvf检查 |
| easy_time 偏移枚举全部失败 | 服务端时间精确到毫秒或种子加了启动随机值 | 抓取多次时间接口计算延迟,扩大到毫秒级枚举,或检查 token 里是否包含 base64 时间 |
| 复现时本地容器和现场 flag 路径不一致 | 现场动态环境使用随机挂载路径 | 查看docker inspect和启动脚本,手动修改为/flag |
| 下载接口总是返回 302 | 文件走了临时重定向地址 | curl 加-L,或读取响应头中的 Location 再请求一次 |
4.2 时间窗口类题目最容易踩的坑
对于 easy_time 这类题目,我最开始用本机时间直接跑,结果失败了很多次。原因是本地宿主机时间是 CST 的东八区,而容器内部使用 UTC,两者相差 8 小时。换算成秒偏差很大,如果不校准,无论怎么枚举都找不到正确的时间戳。
校准方法很简单,看 HTTP 响应头里的Date字段,这个字段统一是 GMT 时间。如果你拿到服务器返回的时间,尽量先把它转成 Unix 时间戳再参与枚举,不要直接用本机当前时间。如果想做得更稳,可以在跑枚举前连续请求 3 次时间接口,取中位数作为基准,把网络抖动降到最低。
另外要注意,很多靶场服务不是单进程,而是用 gunicorn 或者 uwsgi 起了多个 worker。如果每个 worker 启动时生成一次随机种子,那不同 worker 对应的时间可能不同。碰到这种情况,就要多发几次请求,观察响应里的 token 是否会出现两种看似不同的模式,然后用多个时间窗口分别枚举。
4.3 复现结束后如何验证自己的判断
复现不等于看到 flag 就结束。我建议你多做一步:在靶机上临时修改漏洞代码,比如把 tar 解压逻辑里的符号链接检查加上,或者把时间种子改成加密安全的随机源,再重新跑一遍攻击脚本。如果攻击脚本失效,说明你的漏洞定位是准的。
这一步虽然看起来有点像做实验,实际上非常有用。它能帮你确认之前构造的攻击真的是命中在漏洞点上,而不是歪打正着靠一个错误环境拿到了结果。我在复现 MediaDrive 时,最开始以为利用的是路径穿越,后来把符号链接从 tar 包里单独拿出来直接上传,发现并不能触发,这才确认关键点是“归档文件解压时保留了符号链接”。
5. 一点个人经验
这两道题放在半决赛里,多少有点考察选手“能不能快速把不可信数据隔离出来”的意思。MediaDrive 提醒我,所有涉及归档文件解压的功能都必须在代码层面重新审视一遍,不能只靠文件名后缀判断安全。easy_time 则提醒我,安全随机数的使用不是“只要看起来随机就行”,时间、进程 ID、内存地址这类可观测信息一旦进入密钥生成链条,往往就意味着可预测。
如果你只是照着别人的 wp 复现,可能十分钟就打完了。但如果你愿意把每个防御绕过点拆出来,自己动手写一个最小复现环境,再尝试加固和验证,那收获会比单纯看 wp 多得多。赛后我保留了两套完整的本地靶场配置和攻击脚本,后续做文件上传治理和弱随机数代码审计时,它们都是很好的测试样例。