软链接攻击专题——从ZIP软链接到Docker环境变量泄露
前言
软链接(Symbolic Link)在 Linux 中是一种”快捷方式”——它本身是一个特殊文件,内容是目标文件的路径字符串。当程序打开软链接时,内核会自动跟随到目标文件。这个特性在 CTF 中经常被利用:ZIP 压缩包可以保留软链接属性,解压后软链接指向敏感文件(如 /proc/self/environ、/flag),下载即可读取。
本文从硬/软链接的基础区别讲起,结合两道真实 CTF 题目,完整拆解软链接攻击的原理与利用链。
一、硬链接与软链接基础
1.1 什么是 inode
Linux 文件系统中,每个文件对应一个 inode(索引节点),存储文件的元数据(大小、权限、数据块位置等)。文件名和 inode 是分离的——文件名只是一个指向 inode 的标签。
1 | 文件名 "a.txt" → inode #12345 → 数据块 |
1.2 硬链接(Hard Link)
硬链接直接指向文件数据的 同一个 inode。多个文件名可以指向同一个 inode,它们是平等的——删除其中任意一个,只要还有其他硬链接存在,数据就不会被释放。
1 | ln /path/to/original hardlink_name # 创建硬链接 |
硬链接的三个限制:
- 不能跨文件系统(不同分区/磁盘的 inode 体系不同)
- 不能链接目录(防止形成循环)
- 删除所有硬链接后数据才真正释放
1.3 软链接(Symbolic Link / Soft Link)
软链接是一个独立的新文件,拥有自己的 inode。它的数据内容是目标文件的路径字符串。可以理解为 Windows 中的快捷方式。
1 | ln -s /home/james/transactions.txt trans.txt # 创建软链接 |
创建后:cat trans.txt → 内核读取 trans.txt 的内容(即路径字符串 /home/james/transactions.txt)→ 自动跟随到目标文件 → 返回目标文件的内容。
1.4 对比总结
| 硬链接 (Hard Link) | 软链接 (Symbolic Link) | |
|---|---|---|
| 本质 | 同一个文件的多个别名,共享 inode | 独立文件,内容是目标路径字符串 |
| inode | 与源文件相同 | 与源文件不同 |
| 跨文件系统 | 不支持 | 支持 |
| 链接目录 | 不支持 | 支持 |
| 源文件删除后 | 数据仍然存在(只要还有硬链接指向它) | 软链接变成”断链”(dangling link),无法访问 |
| 文件大小 | 不占用额外空间(只增加一个目录项) | 占用少量空间(存储路径字符串) |
ls -l 显示 |
普通文件,链接数 > 1 | 带 → 箭头,l 类型标识 |
1.5 常用命令速查
1 | # 创建硬链接 |
二、前置案例:base64 解码差异导致的命令注入
这题虽然核心考点不是软链接,但利用了 Python 和 Shell 对 base64 编码的不同容错行为,是命令注入中非常经典的 bypass 技巧。
2.1 题目代码
1 | import base64 |
2.2 漏洞分析
WAF 检查的流程:
1 | 用户输入 base64 → Python b64decode → 正则校验是否为合法 IP → 拼接 shell 命令 → sh 执行 |
关键在于:Python 和 Shell 对 base64 的容错行为不同。
- Python
base64.b64decode():遇到无法解码的字符时,默认静默忽略或报错(取决于实现和填充字符的处理),对于=后面的内容直接丢弃。 - **Shell
base64 -d**:GNU coreutils 的 base64 会忽略非法字符,但会处理分号;等特殊字符的上下文。
2.3 攻击 Payload
输入 base64 字符串:
1 | SGVsbG8gV29ybGQh==;cat /flag |
Python 端(WAF 层)
base64.b64decode("SGVsbG8gV29ybGQh==;cat /flag") → 解码得到 Hello World!
- Python 的
b64decode只认合法的 base64 字符,;cat /flag部分在解码过程中被忽略或导致解码为合法 IP 形式(实际解码结果为1.1.1.1类似的短字符串,因为 Python 对 base64 padding 后的垃圾数据有宽松处理) - 正则
^\d+\.\d+\.\d+\.\d+$匹配通过 - 所有 WAF 检查通过 ✓
Shell 端(执行层)
1 | echo "ping -c 1 $(echo 'SGVsbG8gV29ybGQh==;cat /flag' | base64 -d)" | sh |
Shell 的 base64 -d 同样解码 SGVsbG8gV29ybGQh== 部分 → Hello World!,但 Shell 在处理 ; 时会将其作为命令分隔符:
1 | # 实际执行 |
2.4 总结
| 层 | 看到的内容 | 行为 |
|---|---|---|
Python b64decode |
SGVsbG8gV29ybGQh== → Hello World! |
忽略 padding 后的垃圾,只校验 IP 格式 |
Shell base64 -d |
同样解码出有效部分 | ; 被 Shell 解析为命令分隔符,执行后面的任意命令 |
核心:利用两个环境对 base64 输入容错的一致性 + Shell 对 ; 的解析,实现了 WAF 绕过和命令注入。
三、核心案例:online_unzipper —— ZIP 软链接攻击
题目源码为 Flask 应用,提供注册登录、ZIP 上传解压、文件下载功能。攻击链:ZIP 软链接 → 读取
/proc/self/environ→ 泄露FLASK_SECRET_KEY→ 伪造 admin session → 命令注入拿 flag。
3.1 源码分析
关键路由一览
| 路由 | 功能 | 关键点 |
|---|---|---|
/register |
注册用户 | 角色固定为 user |
/login |
登录 | session 中写入 username 和 role |
/upload |
上传 ZIP | admin 用户可自定义目录名 |
/download/<folder> |
列出文件 | — |
/download/<folder>/<filename> |
下载文件 | 会跟随软链接 |
upload 函数(核心)
1 |
|
漏洞点:
os.system(f"unzip -o {zip_path} -d {target_dir}")—unzip默认会保留 ZIP 包内的软链接/download/<folder>/<filename>读取文件时会跟随软链接
3.2 攻击流程
1 | ┌─────────────────────────────────────────────────────────────────┐ |
3.3 Step 1:制造恶意 ZIP
1 | # 创建用到的工作目录 |
为什么选
/proc/self/environ?
Docker 容器中,环境变量通常包含敏感信息。/proc/self/environ是当前进程的环境变量文件,Flask 的FLASK_SECRET_KEY常通过环境变量传入,内容格式为KEY=VALUE\x00KEY2=VALUE2\x00...。
3.4 Step 2-3:上传并下载
用户上传 malicious.zip:
1 | # 服务器执行 |
然后访问 /download/<folder>/config.txt:
1 | # download_file 函数 |
3.5 Step 4:提取 FLASK_SECRET_KEY
下载到的文件内容类似:
1 | HOSTNAME=482ef4a72e45\x00PYTHON_PIP_VERSION=19.1.1\x00SHLVL=1\x00 |
提取 FLASK_SECRET_KEY 的值。
3.6 Step 5:伪造 admin session
使用 flask-unsign 工具:
1 | # 解码当前普通用户的 session,确认格式 |
3.7 Step 6-7:命令注入拿 flag
以 admin 身份上传,dirname 参数可以自定义。源码中 os.system(f"unzip -o {zip_path} -d {target_dir}") 没有对 dirname 做过滤:
1 | # dirname 注入 payload |
完整路径拼接后:
1 | unzip -o /app/uploads/; mv /flag* uploads/xxxx//upload.zip -d /app/uploads/; mv /flag* uploads/xxxx/ |
; 截断 unzip 命令,执行 mv 将 flag 移动到可下载的目录,然后通过 /download/xxxx/flag 下载即可。
3.8 完整攻击脚本
1 | #!/usr/bin/env python3 |
3.9 另一种思路:软链接指向 /flag
如果题目简单直接,也可以让软链接指向 /flag 或 /flag.txt:
1 | ln -s /flag flag_link |
上传后下载 flag_link 文件即可直接读取 flag。
四、软链接攻击的延伸场景
4.1 /proc 伪文件系统中的敏感文件
/proc 是 Linux 的进程信息伪文件系统,包含大量有用信息:
| 文件 | 内容 | 用途 |
|---|---|---|
/proc/self/environ |
当前进程环境变量 | 泄露 SECRET_KEY、数据库密码等 |
/proc/self/cmdline |
当前进程启动命令 | 获取应用路径和参数 |
/proc/self/cwd |
当前工作目录的软链接 | 获取应用根目录 |
/proc/self/fd/<n> |
打开的文件描述符 | 通过 fd 读取被打开但无权限的文件 |
/proc/self/maps |
内存映射 | 泄露 ASLR 基址 |
/proc/1/environ |
PID 1 的环境变量 | 某些情况下 PID 1 有更多敏感变量 |
/proc/net/arp |
ARP 缓存 | 内网扫描 |
/proc/net/tcp |
TCP 连接信息 | 内网信息收集 |
4.2 fd 软链接骚操作
如果应用打开过一个文件后没有关闭,即使后来删除了文件,仍可通过 /proc/self/fd/<N> 读取:
1 | # 假设应用在启动时读取过 /flag,fd=3 |
4.3 Zip Slip + 软链接组合拳
ZIP 包内的文件路径如果包含 ../,解压时可能写入任意目录(Zip Slip)。结合软链接:
1 | # 创建一个软链接指向敏感文件 |
4.4 目录软链接——文件写入劫持
如果服务器解压 ZIP 后允许再次上传,可以先用一个 ZIP 创建一个目录软链接,再上传包含恶意内容的 ZIP:
1 | # 第一个 ZIP:创建目录软链接 |
4.5 unzip 目录-文件混淆 + 双 ZIP 组合写 shell
前面的攻击都是通过软链接读取敏感文件。unzip 还有一个更隐蔽的机制可以实现写入——往软链接指向的目录写文件。
漏洞原理:
unzip 在处理目录时有三个关键行为:
- 如果压缩包中是一个目录,而目标地点存在同名的文件
- unzip 会尝试将该目录中的文件内容解压到那个同名文件中去
- 如果这个文件恰好是个软链接,就会解压到软链接指向的位置
这意味着:攻击者可以通过两个 ZIP 包接力——第一个 ZIP 创建指向目标目录的软链接,第二个 ZIP 以该软链接名为目录写入恶意文件,最终文件落地到软链接指向的真实目录。
操作流程:
1 | # Step 1: 创建软链接,指向目标目录 |
利用方式:
| 场景 | 软链接目标 | 效果 |
|---|---|---|
| 写 shell | /var/www/html |
通过第二个 ZIP 写入 webshell 到 web 目录 |
| 拿密钥 | /proc/self/environ |
自动 unzip 网站返回解压结果时回显环境变量中的密钥 |
| 覆盖配置 | /etc/cron.d 或 /app/config |
写入恶意 cron 任务或篡改应用配置 |
部分网站提供”上传 ZIP → 自动解压 → 返回解压结果”的功能,软链接指向
/proc/self/environ后,解压结果中就会直接回显环境变量里的FLASK_SECRET_KEY、DATABASE_URL等敏感信息。
4.6 tar 的路径保留与解压控制
tar 包同样会存放文件路径信息,方便解压后直接将文件还原到对应目录:
1 | # 打包时保留绝对路径 |
防御端应当注意:永远不要用 -C / 解压用户上传的 tar 包,否则包内的绝对路径文件会直接覆盖系统对应位置。
4.7 Python tarfile 的软链接陷阱
Python tarfile 模块解压 tar 包时同样会保留软链接(extractall 默认行为)。如果应用用 tarfile 代替 unzip:
1 | import tarfile |
构造方式:
1 | ln -s /flag flag_link |
五、应对 --symlinks 受限的情况 —— Python 原生构造
有些环境中 zip 命令不可用或 --symlinks 不被支持,可以用纯 Python 构造含软链接的 ZIP:
1 | import io |
注意: 这个 ZIP 在前述
online_unzipper题目中同样有效。Python 写的 symlink ZIP 和zip --symlinks的产物是等价的。
六、防御措施
| 层面 | 措施 | 说明 |
|---|---|---|
| unzip 参数 | unzip -j |
丢弃目录结构(junk paths),但副作用是打平所有文件 |
| unzip 参数 | unzip -DD |
不解压软链接,而是创建普通文件(内容为路径字符串) |
| Python zipfile | 解压前检查每个条目的属性 | 检测 external_attr >> 16 的高位是否为 0120000(symlink) |
| Python tarfile | 使用 tarfile.open(mode='r:gz', dereference=True) |
跟随软链接读取原文件内容(但这意味着 symlink 指向的本地文件会被打进 tar,通常不是我们想要的保护) |
| 防御性解压 | 自定义解压函数 | 检查所有条目,拒绝或忽略软链接 |
| 沙箱隔离 | 限制解压进程的权限 | 用 seccomp / AppArmor / Docker 限制对 /proc 等目录的访问 |
| 敏感信息保护 | 不要把 SECRET_KEY 放在环境变量 | 使用 Vault/secrets manager,或通过挂载文件传入 |
| 输入校验 | 对 ZIP 包内文件名做严格的白名单 | 拒绝包含 ../、以 / 开头等的路径名 |
Python 安全解压示例
1 | import os |