前面 23 章,你一直在"找漏洞"。 这一章,我们换一双眼睛——从"攻击者"变成"防御者"。 因为:真正理解防御的人,才能成为更好的攻击者;而企业真正愿意高薪养的,是"能让系统变安全的人"。
24.0 开篇:为什么"会攻"还不够
很多安全爱好者有一个误区:
"我只要会找漏洞,就能做安全。"
但真实的企业场景是:
CTO 问:"你找到了这么多漏洞,那……能帮我修掉吗?"
如果你只会说"这里有 SQL 注入,请修复",而说不出"改成 PreparedStatement,把加号拼接替换成问号占位符,代码在第 X 行"——
你的价值就打了对折。
🔥 本章的核心目标:
让你从"能指出问题",变成"能解决问题"。 因为企业需要的,不是"制造焦虑的人",而是"让系统变安全的人"。
而且,"会防御"反哺"会攻击":
- 你懂了"正确的参数化是怎么写的",你就知道"哪些'假参数化'能被绕过"
- 你懂了"输出编码的正确做法",你就知道"哪些编码方式有缺陷"
24.1 安全开发的总原则(三条)
所有具体规范,都是从这三条原则推出来的。
24.1.1 原则一:永不信任输入(Never Trust Input)
来自"外部"的一切,都不可信。 外部 = 用户、浏览器、第三方系统、甚至"上一跳的内部服务"。
□ 所有输入都要校验(服务端!)
□ 校验要"白名单"(定义"允许什么"),不要"黑名单"
□ 校验要"在最靠近数据入口的地方"做
□ 别假设"前端已经校验过了"——前端可以被绕过
□ 别假设"这个接口只有内部调用"——内网也有风险
24.1.2 原则二:数据与代码分离(Data ≠ Code)
漏洞的本质,常常是"数据"被当成了"代码"来执行。 所以,防御的本质,就是"让数据永远是数据"。
□ SQL:用参数化,让数据不参与 SQL 语句的"结构"
□ HTML:用输出编码,让数据不变成"标签"
□ 命令:用参数化的进程调用,让数据不参与"命令结构"
□ 模板:别让用户输入变成"模板模板"
□ 表达式:禁止用户输入进入 SpEL/OGNL/eval
💡 这就是本书反复强调的"根本解"。 记住这八个字,胜过记住一百个 payload。
24.1.3 原则三:纵深防御(Defense in Depth)
不要指望"单点防护"能防住一切。
□ 校验(输入)+ 编码(输出)+ 权限(访问)+ 隔离(网络)+ 监控(运行时)
□ 就算某一层被绕过,下一层还能兜底
□ 例:防 XSS = 输出编码(主)+ CSP(次)+ HttpOnly(兜底)
□ 例:防注入 = 参数化(主)+ 最小权限(兜底)+ WAF(辅助)
单点防护 = 一个 bug 就全线失守;纵深防御 = 一个 bug 只是"一层被突破"。
24.2 每类漏洞的"正确写法"(核心)
这一节是本章的精华:把漏洞篇的每一类漏洞,对应成"正确的编码方式"。
24.2.1 防 SQL 注入
【错误写法】
Java: "SELECT * FROM users WHERE name='" + name + "'"
PHP: "SELECT * FROM users WHERE name='$name'"
Python:"SELECT * FROM users WHERE name='%s'" % name
【正确写法】
Java:
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM users WHERE name = ?");
ps.setString(1, name);
ps.executeQuery();
PHP (PDO):
$stmt = $pdo->prepare("SELECT * FROM users WHERE name = ?");
$stmt->execute([$name]);
Python:
cur.execute("SELECT * FROM users WHERE name = %s", (name,))
MyBatis:
用 #{name} ← 参数化(安全)
不用那种 dollar 拼接写法 ← 危险
【关键:让数据只出现在"值"的位置,永远不进入"语句结构"】
⚠️ 特别注意"假参数化":
- 表名 / 列名 / ORDER BY 字段,不能用参数化——它们必须是"结构"的一部分。对策:白名单校验。
- IN (?, ?, ?) 需要动态生成占位符,而不是拼字符串。
- LIKE 要写成 LIKE CONCAT('%', ?, '%'),不是拼接 % 和参数。
24.2.2 防 XSS
【核心:按"输出位置"做"输出编码"】
输出到 HTML 文本: HTML 实体编码(尖括号转义)
输出到 HTML 属性: 属性值要加引号 + 编码
输出到 JS: JS 编码(不是 HTML 编码!)
输出到 URL: URL 编码
输出到 CSS: CSS 编码
【错误做法】
□ 只在"输入时"过滤(应该"输出时编码")
□ 用 HTML 编码去处理"要放进 JS 的值"(上下文错了)
□ 自己写正则"替换危险字符"(黑名单,可绕)
【正确做法】
□ 用成熟的模板引擎(默认自动转义)
□ 明确设置"输出上下文"对应的编码方式
□ 需要富文本时,用"白名单 HTML 净化库"(如 DOMPurify)
【兜底】
□ Cookie 加 HttpOnly(就算 XSS,也拿不到会话)
□ CSP(限制能执行的脚本来源)
💡 一句话记牢:"输入校验"防不了 XSS,"输出编码"才能。 因为同一个数据,输出到 HTML 会变成标签,输出到 JS 会变成代码,必须按"目的地"来决定怎么编码。
24.2.3 防 CSRF
□ 用 CSRF Token(服务端生成,绑定会话,校验)
□ 或:用 SameSite Cookie(SameSite=Lax/Strict)
□ 敏感操作"二次验证"(如改密码要求旧密码)
□ 校验 Origin / Referer(辅助)
24.2.4 防 SSRF
【根本解:不信任用户控制的 URL】
□ 用"白名单":只允许访问"预先定义好的"目标(如固定的几个 API)
□ 如果必须"用户给 URL":解析后校验"目标 IP 不在内网/元数据段"
□ 禁止跟随重定向(或每跳都校验)
□ 禁用不常用的协议(file:// gopher:// dict://)
□ 网络层隔离:让应用"没有能力"访问敏感内网(★ 最根本)
□ 云环境:强制 IMDSv2、限制元数据访问
⚠️ "校验域名黑名单"是不可靠的(DNS 重绑定、重定向、@ 绕过、进制绕过……)。根本解是"网络隔离 + 白名单"。
24.2.5 防文件上传 / 路径穿越
【上传】
□ 白名单后缀(而不是黑名单)
□ 校验文件"真实类型"(magic number),不只看扩展名
□ 重命名文件(随机名 + 安全后缀),不保留原名
□ 存储到"Web 根目录之外",或"不可执行"的目录
□ 限制文件大小、限制上传频率
□ 图片:重新编码(去掉藏匿的代码)
【路径穿越】
□ 不用"用户输入"拼路径——用"ID 映射到文件"
□ 必须用路径时:规范化(realpath)后校验"在允许目录内"
□ 白名单校验文件名
24.2.6 防命令注入
【根本解:不用 shell】
Java: new ProcessBuilder("ls", "-l", dir) ← 参数分开传,不拼字符串
不用 Runtime.exec("ls -l " + dir) ← 经 shell,可注入
Python: subprocess.run(["ls", "-l", dir]) ← 列表形式,shell=False
不用 os.system("ls -l " + dir)
【如果非得用 shell】
□ 用库做"参数转义"(shlex.quote)
□ 或白名单校验参数
24.2.7 防反序列化 / XXE
【反序列化】
□ 尽量"不反序列化不可信数据"
□ 用 JSON 替代原生序列化
□ 必须用时:禁用"自动类型识别"(如 Jackson 不开 defaultTyping)
□ 用"白名单"限制能反序列化的类
【XXE】
□ 解析 XML 前,禁用 DTD 和外部实体
Java:
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
Python: 用 defusedxml 替代标准库
PHP: libxml_disable_entity_loader(true)
□ 或者:干脆不用 XML,改用 JSON
24.2.8 防越权(最重要的"业务型"防御)
【核心原则:服务端校验"属主"和"权限",且"基于当前会话的用户"】
【水平越权(IDOR)】
错误:findById(orderId)
正确:findByIdAndUserId(orderId, currentUserId)
← "当前用户 ID"必须来自"服务端会话",不能来自"请求参数"
【垂直越权】
□ 每个"敏感接口"都要显式做"角色校验"
□ 别只靠"前端隐藏菜单"——接口层必须校验
□ 用"统一的鉴权拦截器",避免"某个接口忘了加"
【统一防御】
□ 用"集中式鉴权"(注解 / 拦截器 / 网关),而不是"每个接口手写"
□ 默认"拒绝",显式"放行"(deny by default)
□ 定期审计"哪些接口没有鉴权注解"
🔥 越权是所有漏洞里"最容易被自动化扫描漏掉"的,所以它只能靠"开发者写对" + "测试者认真测"。这是"安全左移"最有价值的地方。
24.2.9 防业务逻辑漏洞
□ 所有"金额 / 数量 / 折扣"都要"服务端重新计算",不信任前端传来的值
□ 关键流程"状态机"要严格校验(不能跳步骤)
□ "一次性资源"(优惠券 / 验证码)要"原子化消费"(防并发重复)
□ 关键操作"限频"(防刷)
□ "边界值"要测试并防御(负数 / 0 / 超大数 / 小数)
💡 业务逻辑漏洞的防御,本质是"把'业务规则'当成'安全规则'来对待"。 很多开发者把它当"功能问题",而不是"安全问题"。
24.3 通用安全配置与基础设施
除了写代码,"配置"也是安全的一大块。(回顾第 17 章)
24.3.1 安全响应头(一次性配好,长期受益)
# Nginx 示例:统一加上安全响应头
add_header Content-Security-Policy "default-src 'self'; script-src 'self'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=()" always;
【每个头防什么】
CSP → XSS(限制脚本来源)
X-Content-Type-Options → MIME 嗅探
X-Frame-Options → 点击劫持
Strict-Transport-Security → 降级攻击
Referrer-Policy → 敏感 URL 外泄
Permissions-Policy → 限制浏览器特性
24.3.2 Cookie 安全属性
Set-Cookie: sessionId=xxx;
HttpOnly; ← JS 读不到(防 XSS 窃取)
Secure; ← 只在 HTTPS 发送
SameSite=Lax; ← 防 CSRF
Path=/;
Max-Age=...
24.3.3 CORS 配置(别图省事全放开)
【错误】
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true ← 危险组合
【正确】
□ 明确列出"允许的来源"白名单
□ 不要"反射"请求的 Origin
□ 只在必要时才带 Credentials
□ 限制方法 / 头
24.3.4 密钥与配置管理(高频事故点)
❌ 别把密钥写进代码 / 提交到 Git
❌ 别把 .env / 配置放在"Web 可访问"的目录
❌ 别在日志里打印密钥 / 密码 / Token
✅ 用"环境变量"或"专门的密钥管理服务"(KMS / Vault)
✅ 给不同环境用不同的密钥
✅ 密钥要"可轮换"
✅ 提交前用"密钥扫描"(gitleaks / trufflehog)
⚠️ "密钥泄露"是最常见、也最致命的失误之一。 而且一旦进了 Git 历史,即使删除,历史里还有——必须"轮换密钥",而不只是"删文件"。
24.4 把安全"嵌入"研发流程(安全左移)
单靠"某个人记得做安全"是不行的。要让"安全"变成"流程的一部分"。(回顾第 17 章的"安全左移")
24.4.1 SDLC 各阶段的安全活动
【需求阶段】
□ 安全需求(如"密码必须加盐哈希存储")
□ 合规要求(等保、GDPR 等)
【设计阶段】
□ 威胁建模(STRIDE:仿冒/篡改/否认/信息泄露/拒绝服务/提权)
□ 数据流图 + 信任边界标注
□ 安全架构评审
【编码阶段】
□ 安全编码规范(本章的"正确写法")
□ IDE 安全插件(实时提示)
□ 代码评审时"顺带看安全"
【测试阶段】
□ SAST(静态扫描:Semgrep/CodeQL/SonarQube)
□ SCA(依赖扫描:Snyk/Trivy)
□ DAST(动态扫描:nuclei/ZAP)
□ 手工渗透测试
【部署阶段】
□ 安全基线检查(配置)
□ 镜像扫描
□ 最小权限部署
【运行阶段】
□ 运行时监控 / 告警
□ 日志审计
□ 定期渗透 / 漏洞管理
□ 应急响应预案
24.4.2 接入 CI/CD(让安全"自动发生")
# 一个简化的"安全检查"流水线(GitHub Actions 示例)
name: security
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ① 密钥扫描
- name: Secret Scan
run: gitleaks detect --source . --no-banner
# ② 依赖扫描
- name: Dependency Scan
run: |
npm audit --audit-level=high || true
trivy fs --severity HIGH,CRITICAL .
# ③ 静态代码扫描
- name: SAST
run: semgrep --config "p/security-audit" --error
💡 目标:让"有高危问题的代码"根本合不进主干。 这叫"安全门禁"。
24.4.3 一个"开发者的安全 Checklist"
给每个功能上线前,问这些问题:
【输入】
□ 所有外部输入都校验了吗?(服务端)
□ 校验是白名单吗?
□ 特殊字符处理对了吗?
【输出】
□ 输出到不同位置,编码对了吗?
□ 有没有"直接把用户输入 echo 出来"?
【数据库】
□ 所有查询都是参数化的吗?
□ 有没有拼接 SQL?
【权限】
□ 这个接口"谁能访问"?校验了吗?
□ 有没有校验"数据属主"?
□ 是"默认拒绝"吗?
【敏感数据】
□ 密码加盐哈希了吗?(bcrypt/argon2)
□ 敏感数据传输 / 存储加密了吗?
□ 日志里有没有敏感信息?
【配置】
□ 调试模式关了吗?
□ 安全响应头加了吗?
□ 密钥没写在代码里吧?
【依赖】
□ 有没有已知高危漏洞?
□ 依赖是最新安全版本吗?
24.4.4 "让开发愿意做安全"的三个关键
① 【降低门槛】给出"可以直接复制的正确写法",而不是"你应该注意安全"
② 【自动化】让工具"自动检查",而不是靠人"记得检查"
③ 【不设障碍】安全不能"拖慢开发"——否则会被绕过
(好的安全,是"让安全的做法"变成"最省事的做法")
🔥 一个深刻的观点:
如果"安全"和"方便"冲突,人会选"方便"。 所以最好的安全设计,是"让安全变得方便"——默认安全(secure by default)、自动化的护栏、开箱即用的安全组件。
24.5 安全架构:比"写对代码"更重要的事
代码写得再对,架构错了,依然不安全。 这一节讲"架构层"的安全思想。
24.5.1 核心思想:把"安全"变成"不可能出错"
【好架构的追求:让"错误"变成"不可能"】
不是"提醒开发者别写漏洞",而是"让漏洞写不出来"
例:
□ 用"参数化查询的 ORM 封装",让开发者"想拼 SQL 都难"
□ 用"统一的输出编码模板",让开发者"忘了编码也不出事"
□ 用"默认拒绝的鉴权网关",让"忘了加鉴权"变成"访问不了"
□ 用"网络隔离",让应用"根本没有能力"访问敏感内网
💡 这叫"pit of success"(成功之坑):让正确做法成为"最自然、最省事"的做法,让错误做法"很别扭"。
24.5.2 隔离:最强大的安全手段
【网络隔离】
□ 数据库 / 缓存 / 内网服务,不暴露公网
□ 应用服务器"最小出网权限"(防 SSRF 打内网 / 数据外传)
□ 微服务之间"按需最小连通"
【进程隔离】
□ 容器 / 沙箱运行不可信代码
□ 以"非 root"运行服务
□ seccomp / AppArmor 限制系统调用
【数据隔离】
□ 多租户数据"逻辑隔离 + 严格校验租户 ID"
□ 敏感数据"单独存储 + 加密"
【权限隔离】
□ "最小权限"原则(每个服务 / 每个用户,只给"够用"的权限)
□ 定期回收"不再需要的权限"
🔥 隔离的思想:
"假设某一道防线一定会被突破,那么'突破之后能做什么',由'隔离'决定。" 纵深防御的最后一道墙,永远是隔离。
24.5.3 安全设计的两条"心法"
【心法一:数据流分析】
拿着数据,从"进入系统"追到"处理完成",
在每一个"跨越信任边界"的地方问:
"这里需不需要校验 / 编码 / 授权?"
【心法二:失效分析(怎么才会被攻破)】
假设"某个组件被攻破了",然后问:
"攻击者能拿到什么?能扩散到哪?"
然后针对性加固。
24.6 动手任务
任务 1:把 DVWA 的每关"修好"
DVWA 的每一关都提供 "Impossible" 版本的源码——那就是"正确写法"。
□ 打开每一层的 Impossible 代码
□ 对比 Low / Medium,看"修复"改了什么
□ 用"本章的原则"去解释:"它为什么安全?"
是参数化了?是白名单了?是输出编码了?
□ 这是理解"防御"最快的路径
💡 DVWA 的 Impossible 版本,就是一本"活的安全编码教科书"。
任务 2:给你的项目加一条"安全门禁"
选一个你自己的项目(或新建一个):
① 接入 Semgrep,跑一遍,看报什么
② 接入依赖扫描(npm audit / pip-audit / trivy)
③ 接入密钥扫描(gitleaks)
④ 在 CI 里加一步"高危就不许合并"
任务 3:写一份"安全编码规范"(给团队)
把 24.2 的内容,整理成你团队的规范文档,要求:
□ 每一条都"给出错误写法和正确写法"(对照)
□ 覆盖团队实际用的语言和框架
□ 语言要"开发能看懂"(别堆安全术语)
任务 4:做一次"威胁建模"
选一个你熟悉的功能(如"用户上传头像"):
① 画一个简单的数据流图(用户 → 前端 → 后端 → 存储)
② 标出"信任边界"(数据从"不可信"进入"可信"的地方)
③ 在每个边界上问:"这里可能出什么问题?"
④ 用 STRIDE 六类过一遍
⑤ 列出"应该有的防护"
任务 5:找一个"真实修复"来学习
去找一个"漏洞从发现到修复"的真实案例(CVE 的补丁、开源项目的修复 commit):
□ 看"漏洞长什么样"
□ 看"补丁改了什么"
□ 理解"为什么这么改就能修好"
□ 体会"根本解 vs 创可贴"在真实场景里的体现
24.7 本章小结
核心结论
- 会攻还要会守:企业需要的是"能让系统变安全的人",不是"只会制造焦虑的人"。
- 三条总原则:永不信任输入 / 数据与代码分离 / 纵深防御。
- 每类漏洞都有"正确写法":参数化查 SQL、输出编码防 XSS、属主校验防越权、白名单防上传、禁用实体防 XXE……
- 防 XSS 的关键是"输出编码",且要按"输出上下文"选编码方式(HTML / JS / URL)。
- 防越权是"业务型"防御:属主和权限校验,且用户 ID 必须来自"服务端会话";用"集中式鉴权 + 默认拒绝"。
- 防 SSRF 的根本解是"网络隔离 + 白名单",黑名单域名不可靠。
- 通用配置:安全响应头、Cookie 属性(HttpOnly/Secure/SameSite)、CORS 别全放开、密钥管理。
- 密钥泄露是最常见的重灾:别进代码、别提交 Git,一旦泄露必须轮换(删文件没用)。
- 安全左移:把安全嵌入 SDLC(需求→设计→编码→测试→部署→运行),接进 CI/CD 做"安全门禁"。
- 好架构追求"让错误变成不可能"(pit of success)——让正确的做法"最省事"。
- 隔离是最强的防线:网络 / 进程 / 数据 / 权限隔离——"假设某层必被突破,隔离决定后果"。
- 安全要和"方便"共舞:如果安全"很麻烦",人就会绕过它。最好的安全,是"让安全变得方便"。
随身口诀
永不信任输入,数据永不变成代码;SQL 参数化,XSS 输出编码,越权查属主;白名单 > 黑名单,隔离是最后一道墙;把安全嵌进流程,让"正确的做法"最省事。
24.8 预告:下一章我们去哪
到这里,你已经能"攻"、能"守",也能"交付项目"了。
最后一章,我们聊聊"人"的问题——这条路该怎么走远?
- 有哪些职业方向?(渗透测试 / 安全研发 / 应急响应 / 红蓝队 / 安全架构 / 合规)
- 每条路需要什么能力?
- 怎么持续学习、怎么不被淘汰?
- 认证有没有用?怎么规划自己的成长?
第 25 章:职业路径与成长。
📌 本章金句 "安全的最高境界,不是'发现所有漏洞',而是'让漏洞难以存在'。一个真正的高手,不是'能找到多少别人写的漏洞',而是'能设计出让别人写不出漏洞的系统'。 这也正是'攻'与'防'最终会汇合的地方——因为最好的防御者,一定曾是好的攻击者;而最好的攻击者,最终一定会理解防御。"