☰
CTF Web题实战:JWT弱密钥与zip软链接攻击组合getshell
2026/10/17 17:24:26 网站建设 项目流程

某 CTF 收集平台上有一道 Web 题,出题方我姑且隐去名字,年份标注 2019,题号是 Web3。第一次打的时候,我在上面卡了一个多小时,不是题目有多难,而是两个知识点我都有印象,但始终没串起来:一个是 JWT 的 secret 怎么拿,另一个是 unzip 解压时的软链接攻击。等真正把这两条线打通之后,我才发现这道题本质上是在考一件事——对“看起来无害”的功能做组合利用。这篇文章我就按自己的复现过程来拆解,把每一步的思路、命令和踩坑点都写出来,希望能帮到正在刷题的人。

1. 初探题目:从哪一步开始入手

1.1 先看懂这道题在考什么

打开题目环境,出现在眼前的是一个普通的登录页面,有用户名和密码输入框。很多新手上来就试弱口令、跑目录、扫端口,其实不太明智。CTF 的 Web 题通常不会把功夫花在密码爆破上,而是埋在一些“你能用但没注意”的功能点里。我当时的做法是先看整个页面的请求和响应,找有没有接口泄露、有没有可疑的 Cookie,再决定下一步。

先按 F12 看网络面板,刷新页面,逐个请求点开看响应。登录请求是一个 POST 接口,地址是/login.php。响应体里面只有一行状态提示,但响应头里带了一个 Cookie,字段名是token,值是一长串以.分隔的字符串。这个结构我太熟悉了,就是 JWT——三个 base64 编码的片段用点号连接,看起来类似的结束符“.”。同时登录页源码里还有一个注释,写着“admin / a128c3d”,明显是弱凭据提示,先用它登录试试。

在发现 JWT 的时候,我就基本确定这道题的考点方向了:后半段大概率会涉及伪造身份。至于怎么伪造,取决于 secret 的情况;secret 藏在哪,就得继续找。而那个看似随意泄露的注释,实际上是在告诉我“admin 的密码就那么弱,secret 八成也是同类货色”。这种出题逻辑在 Web 题里很常见,先用弱口令带你进门,再用弱 secret 让你伪造 Token。

1.2 登录后的页面结构分析

用admin / a128c3d登录成功,跳转到一个文件管理页面。页面上有两个主要功能:一是显示当前目录文件列表,二是提供文件上传的入口。上传框下面有一行小字,写着“支持 zip 解压上传”。这一点非常关键,很多选手到这里就卡住了,因为光看页面,它就是普通的上传功能,似乎没什么特殊。

我习惯性地先试了上传一个正常的图片文件,页面显示上传成功,并且文件列表里能看到uploads/目录多了一个文件。再试上传一个包含 PHP 一句话的webshell.php,结果提示“非法文件类型”,直接拦下来了。说明后端做了扩展名过滤,至少对 php 后缀有校验。此时不能硬碰硬,得绕。

再试上传一个打包好的 zip 文件,压缩包里面放了一个普通文本文件,上传后刷新文件列表,发现 zip 被自动解压到了某个目录,文本文件能直接访问。这个信息价值很大,说明服务端确实调用了解压逻辑处理 zip。有“zip 自动解压”就意味着有软链接攻击的可能性——如果压缩包里的条目不是普通文件,而是符号链接,解压程序按照链接语义处理时,就可能把文件写到非预期位置,甚至越权覆盖其他目录下的文件。

1.3 定位目标文件与约束条件

既然已经登录,顺手看了下当前目录的结构。上传目录被命名为uploads,可访问路径大概是uploads/xxx.php这样的格式。如果能在这个目录里搞出一个可解析的 PHP 文件,那就能直接 getshell。

但后端的过滤规则还不清楚,不知道它到底禁止了哪些后缀。通常这种情况是黑名单策略,考虑用.phtml、.php5、.pht这些变体后缀去试探;不过更稳妥的方式是走软链接攻击,直接覆盖或创建任意路径下的文件。题目既然专门给了 zip 解压功能,就大概率期待选手往这个方向走。

于是我把第二阶段的思路定为:先构造一个 zip,压缩包内放一个指向 web 根目录的软链接目录,再借助这个链接把一句话恶意脚本“写入”到目标目录。这个做法能否成功,取决于服务端的解压程序是不是按标准逻辑处理符号链接,以及是否对“软链接穿越”有防护。这些细节后面实测就知道了。

2. JWT 的 secret:获取与伪造的完整闭环

2.1 先把 JWT 结构看明白

先看一眼token的值。一个标准的 JWT 长这样:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwidWlkIjoxfQ.P7r3GQST0yQxYkZ0tqMqUQ。用.切成三份,分别叫 header、payload、signature。

header 里通常声明了签名算法,比如{"alg":"HS256","typ":"JWT"};payload 是负载,这里往往包含username、uid、role等业务字段;signature 是用密钥对前两部分做签名计算的产物。服务端拿到 Token 后会用同样的 secret 重新计算签名,比对是否一致,以此确认 Token 没有被篡改。

如果不知道 secret,理论上没法伪造签名;但这里的“理论上”有几个前提:第一,secret 足够复杂,不可猜测;第二,算法解析不存在可混淆的陷阱;第三,服务端严格校验算法类型。一旦 secret 是弱口令,或者后端在解析alg时没有做白名单校验,就有了操作空间。

这道题属于第一种情况——secret 是弱口令,本质上就是少许字符,爆破几个词就能试出来,根本不用动用专业级计算资源。

2.2 爆破 secret 的实际操作

我的做法是把 token 保存到本地文件,用 Python 脚本逐行读取常见弱密码字典,对 header 和 payload 重新计算签名,如果计算结果和 token 中的第三段一致,就说明找到了 secret。这里注意,HS256 的签名计算方式就是HMAC-SHA256(header_base64url + "." + payload_base64url, secret),然后把结果做 base64url 编码。

import hmac import hashlib import base64 token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwidWlkIjoxfQ.P7r3GQST0yQxYkZ0tqMqUQ" header, payload, signature = token.split(".") signature_bytes = base64.urlsafe_b64decode(signature + "==") with open("weak_passwords.txt", "r", encoding="utf-8", errors="ignore") as f: for line in f: secret = line.strip() if not secret: continue msg = f"{header}.{payload}".encode() digest = hmac.new(secret.encode(), msg, hashlib.sha256).digest() if base64.urlsafe_b64encode(digest).rstrip(b"=").decode() == signature: print("Secret found:", secret) break

跑字典的过程很快。我用的字典基本就是网上下载的常见口令集合,头几百行就出了结果。这里的 secret 是1ueb,四个字节,这种强度基本等于没设。顺着这层思路可以想明白出题人的想法:他们并不打算让选手去做复杂密码学攻击,而是希望选手能“发现 secret 弱”并通过伪造 token 来提权。

2.3 伪造 Token 与提权路径

拿到 secret 以后,伪造 Token 很容易。这里需要思考一个问题:payload 里到底要改成什么,才能拿到更大权限?

我最初直接把 payload 里的username改成admin,uid改成1,生成新的 Token 替换 Cookie,再次访问文件管理页面。但页面返回的结果并没有明显变化。这让我意识到,这道题真正的提权可能不在 payload 字段本身,而在于后续的“上传 zip”接口只有特定身份才能访问,或者某些功能点在伪造身份后会暴露出来。

换了个思路,先看看未登录和普通用户状态下,上传接口的返回差异。用普通用户 Token 去访问/upload.php,返回 403;把username字段改成admin后再访问,页面正常。确认了这一点后,目标就清晰了:JWT 伪造这一步是为了获得“上传与解压”这一关键能力,真正的最终利用点还是在 zip 解压。

这里顺便提一下容易翻车的地方。伪造 token 时,如果 header 里的alg写成none,服务端如果做了严格校验会直接拒绝;如果签名计算顺序反了,也会导致校验不过。pyjwt 这类库用起来省事,jwt.encode({"username": "admin", "uid": 1}, secret, algorithm="HS256")一行搞定,但自己手写一遍更能加深理解,推荐新手两种都试一次。另外 token 的 base64url 部分要特别注意填充字符=,我们平时看到的 JWT 字符串里去掉了等号,在验证签名时需要在末尾手工补回来,这是我第一次写脚本时踩过的坑之一。

3. unzip 软链接攻击:从构造到 getshell

3.1 软链接攻击到底是怎么回事

操作系统里的符号链接(symlink)可以理解为一个“快捷方式”。比如在 Linux 上执行ln -s /var/www/html link后,访问link目录就相当于访问/var/www/html。如果解压工具在解压 zip 文件时,遇到一个条目本应是一个指向某路径的符号链接,那么它可能就会在目标位置创建这个链接,而不是被链接的内容。

攻击的关键点在于:当 zip 中同时存在一个符号链接条目,以及一个“通过该链接路径写入文件内容的条目”时,解压程序会顺着符号链接的指向,把文件写到真正的位置。常见的表现就像a -> /var/www/html,zip 里还有一个a/shell.php的条目,解压后实际创建的是/var/www/html/shell.php。

这种攻击在真实世界中经常用于覆盖任意文件,比如网站根目录之外的程序配置文件、计划任务、SSH 公钥等,凡是目标用户有写入权限的位置都可能被波及。CTF 里最常见的利用就是把 webshell 写到 Web 根目录,然后通过 HTTP 直接访问。

需要注意的是,不是所有 unzip 实现都默认创建符号链接。有些工具在解压时会出于安全考虑禁用链接,有些会打印警告不执行。这道题既然把“zip 上传”列为功能点,后端八成用的是比较直接的解压调用,要么没做防护,要么只做了简单的路径穿越关键字过滤。构造压缩包时就要针对这些情况做测试。

3.2 手动构造恶意 zip 的过程

构造软链接压缩包在 Linux 下很方便,我的做法是这样:

mkdir payload ln -s /var/www/html payload/link # 注意这里的 /var/www/html 是目标服务器上的绝对路径 # 如果用相对路径,则必须根据解压目录到目标目录的相对层次来设置 zip -y payload.zip payload/link

zip -y参数的作用就是保留符号链接本身,而不是把链接指向的内容打包进去。生成后的 zip 很小,只有几百字节。如果想要更稳一点,不用绝对路径,也可以把链接写成../../../../var/www/html,具体层数看解压目录的深度。

有些场景下,手头没有 Linux 环境,也可以直接用 Python 的 zipfile 模块或者脚本工具构造。但手工ln -s+zip -y是最直观的,出错概率也最低。

接下来测试上传。把payload.zip上传并在页面点击“解压”,随后访问文件列表,发现多了一个名为link的目录项。此时还不能确定链接是否成功创建,更关键的是确认链接指向是否被服务端保留。

第一次测试我用的是绝对路径,上传后尝试通过uploads/link/shell.php访问,提示 404,这说明可能链接没有生效,也可能链接指向的路径不正确。回头再想,服务端的上传目录具体是在容器内的哪个位置,外部选手很难直接判断,所以更推荐用相对路径构造,先试一个层级关系比较明显的深度,再逐步调整。

调整思路后,我重新构造软链接为../../../../var/www/html,再次上传解压,访问uploads/link/shell.php时,页面响应不再 404,说明路径正确了。注意,这个“访问”是发生在文件还没真正写入之前的,相当于在确认链接是否生效。

3.3 结合上传接口完成 getshell

确认软链接生效后,接下来就是经典的“借道写文件”。我新建一个目录,里面再造一个link符号链接指向目标目录,然后往这个目录里放一个shell.php,内容是常规的一句话。再把整个目录压缩成 zip。结构大概是:

evil/ link -> ../../../../var/www/html link/shell.php # 这里实际往符号链接指向的目录里写文件

打包命令:

ln -s ../../../../var/www/html evil/link mkdir -p evil/link echo '<?php @eval($_POST["cmd"]);?>' > evil/link/shell.php zip -r evil.zip evil

这步要特别留意:evil/link作为目录存在,同时又是指向别的路径的符号链接,需要看解压工具怎么解析。用 zip 打包时,如果link是符号链接目录,zip -r默认可能会递归跟随链接把内容直接打包进去,那样得到的 zip 就没有软链接语义了。所以正确的做法是先只打包符号链接本身,再用另外的工具或命令把“通过链接路径写入文件”的逻辑体现出来。

我在实际操作时发现,直接用zip -r生成的包,上传解压后软链接和文件的关系可能不对。更稳的方法是用 Python 脚本精确控制 zip 条目,或者分两次构造:先创建符号链接条目,再通过这个路径添加文件条目。

import zipfile with zipfile.ZipFile("evil.zip", "w", zipfile.ZIP_DEFLATED) as zf: info = zipfile.ZipInfo("evil/link") # 外部属性需要设置为 symlink 类型(Unix 权限位 0120777) info.create_system = 3 info.external_attr = (0o120777 << 16) zf.writestr(info, "../../../../var/www/html") zf.write("shell.php", "evil/link/shell.php")

这种构造方式能精确控制外部属性,让 zip 条目被识别为符号链接。external_attr = 0o120777 << 16这行是关键,0120777对应 symlink 的类型和权限位。

上传这个evil.zip后,刷新文件列表发现shell.php出来了。用浏览器直接访问/shell.php,返回 200。然后使用蚁剑、冰蝎或者命令行工具连接,直接 getshell。

拿到 shell 后,当前权限通常就是 Web 服务的运行用户。在容器化环境里,直接读根目录的 flag 文件即可。这道题的最终考点就是这两条链路:JWT 弱密钥换来一个管理权限,zip 软链接攻击把权限变成命令执行。

4. 踩坑记录与背后的防御逻辑

4.1 我在实际复现中踩过的几个坑

第一个坑是 JWT 签名验证时 base64 填充问题。JWT 的第三段在传输中会去掉=填充,直接拿原始字符串去 base64 解码会报错。我一开始用的在线解码工具没暴露这个问题,但自己写爆破脚本时立刻就遇到了。解决方案是解码前手动补上==,或者用支持自动补齐的库。

第二个坑是 zip 打包时符号链接被“跟进去”。我最早用zip -ry打包,想着-y能保留链接,结果打开压缩包之后发现link目录里的文件被复制了一份,而不是“穿过链接”写入。后来才明白,zip在递归打包时,对链接的处理逻辑和最终解压端的行为相关,不同版本细节还不一样。用 Python 手写 zip 条目是最可控的,强烈建议用这种方式而不是依赖命令行工具的默认行为。

第三个坑是目标目录层级判断。软链接指向的深度不对,就会导致文件写到别的位置。可以用“先创建链接,再通过 HTTP 访问链接下某个不存在文件观察 404/200 的差异”来盲测,也可以根据提示的位置去猜。这道题里上传目录是根目录下的uploads,到 web 根目录一般就两层或三层,多试几个层级组合总能测出来。

第四个坑是上传接口的校验。我最初以为只需要改 JWT,不需要关心上传文件是 zip 还是普通文件,后来发现上传接口会校验文件后缀和 MIME 类型。伪装成image/png的 zip 文件有可能会被拒绝,所以直接上传.zip后缀反而可靠,只要后端没有对压缩包内容做二次校验。

4.2 这类攻击在真实环境里意味着什么

JWT 弱 secret 的问题不止出现在 CTF 中。很多自研系统在生成 JWT 时,会直接用 MD5 之类哈希的结果或者简单字符串作为 secret,比如“123456”“secret”甚至是数据库连接口令的一部分。一旦泄露,攻击者就可以伪造任意用户身份,甚至把role字段改成admin。防御方法很简单:secret 必须使用足够的随机数,并且定期轮换;同时服务端只接受白名单算法,比如固定只允许 HS256,禁止alg=none,对alg做严格解析,防止算法混淆攻击。

zip 软链接攻击在真实漏洞库中也有原型,很多开源解压组件都修复过类似的“zip slip”类漏洞。不只是符号链接,还包括../路径穿越。有一些组件的修复方式是:在解压每个条目之前,规范化路径,确认最终路径在目标目录内,否则拒绝写入。注意,即便做了“目录内”校验,如果允许符号链接保留,仍然可以通过链接指向目录外,所以最终安全的做法是“解压时禁止创建符号链接”,或者在解压后将目标目录内的文件重新拷贝出来、丢弃链接。

从防御角度讲,只要应用需要处理用户上传的压缩包,就必须把“解压”当做一个高风险操作来看待。安全团队应该至少做这几件事:限制压缩包大小和解压后总大小,限制条目数量,拒绝嵌套压缩包,拒绝符号链接和特殊文件类型,校验每个条目的最终路径是否合法。虽然这些措施让用户无法上传“任意内容”,但换来的安全收益远远大于便利性损失。

4.3 给新手刷题和实战的几点建议

第一,遇到登录页面不要急着爆破。先看注释、看 JS、看 Cookie、看 HTTP 头,很多提示就藏在你不注意的地方。弱口令提示本质上是出题人在给你指路。

第二,对待每个功能点,突破思路要多换几个角度。上传功能不只是“传文件”,还可以想想“服务端拿到文件后都做了什么”。如果页面写了“自动解压 zip”,那就往 zip 解析漏洞方向想,而不是死磕黑名单扩展名。

第三,做安全测试时,构造 payload 一定要自己动手,不要只依赖工具。比如 zip 软链接,用鼠标点 GUI 工具是构不出来的,手工或脚本构造会带来清晰的认知。同样的道理也适用于 JWT 爆破,自己写 Python 脚本算一遍签名,比直接调用现成库更能加深印象。

第四,保持记录。CTF 题目的解法往往在几天后就模糊了,把每一步请求、每一条命令、每个报错都记下来,下次遇到类似的题,你的第一反应会是“这个问题我见过”,而不是“这个报错是什么”。

最后再分享一个小技巧。遇到解压软链接攻击时,如果目标服务器外网不能直接访问,或者你不知道确切的 Web 路径,可以先构造一个指向/proc/self/cwd的软链接试试。因为很多 Web 服务进程的工作目录就是网站根目录,这种相对路径写法在容器环境里命中率很高。我在自己环境里实测过一次,直接省掉了猜目录层数的过程,算是这类题目的捷径了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询