安全编码体系:从信任边界、最小权限到纵深防御

安全编码体系:从信任边界、最小权限到纵深防御

软件安全很容易被误解成“安全团队的工作”:防火墙、WAF、杀毒软件、漏洞扫描器、入侵检测、零信任网关都部署好了,应用似乎就安全了。

真正进入代码层以后,会发现事情恰好相反。很多严重安全事故的起点并不是一段“看起来像黑客代码”的实现,而是一次普通的字符串处理、一次整数加法、一次数组遍历、一次异常打印、一次对象序列化,甚至只是调用了一个自己没有真正理解的 API。

安全编码的困难也在这里:危险代码往往长得很普通。

一个成熟的安全编码体系,不应该只记忆 SQL 注入、XSS、缓冲区溢出等漏洞名称,而应该建立一套能够迁移到不同语言、框架和业务场景中的判断方法。贯穿这些案例的核心原则可以压缩为几句话:

  • 清楚调用接口的真实行为,不依赖“我猜它应该这样工作”。
  • 跨越信任边界的数据默认不可信,先验证,再使用。
  • 信息、资源和代码能力都遵循最小权限原则。
  • 尽量减少可变状态、公开接口和可扩展点,缩小攻击面。
  • 不把任何一道防线当作绝对可靠,使用纵深防御和相互独立的防护机制。
  • 面对不可预知的未来风险,既准备长期替代方案,也准备可以快速启用的应急开关。
  • 安全不仅是编码问题,也是依赖管理、漏洞响应、版本升级和组织执行问题。

本文围绕这些原则,把多个独立的安全案例重新组织成一套面向软件开发者的安全编码知识体系。


1. 为什么代码安全是整个信息安全体系的地基

安全问题有一个很不对称的特征:开发者只要遗漏一个关键检查点,攻击者就可能获得远大于这几行代码本身价值的收益。

2017 年 Apache Struts 2 的一个 multipart/form-data 请求处理漏洞,就是很典型的例子。

浏览器上传文件时,请求大致如下:

1
2
3
4
5
6
7
8
Content-Type: multipart/form-data; boundary=AaB03x

--AaB03x
Content-Disposition: form-data; name="upload-file"; filename="myfile.txt"
Content-Type: text/plain

... contents of myfile.txt ...
--AaB03x--

当时 Struts 2 的文件上传拦截器在解析 multipart 请求失败后,会调用 LocalizedTextUtil.findText() 查找本地化错误信息。而这个接口还有一个关键行为:错误消息中的 ${...} 内容可以按照 OGNL(Object Graph Navigation Language,对象图导航语言)表达式进行插值和求值。

于是形成了这样一条危险链路:

flowchart LR
    A[外部 multipart 请求] --> B[Multipart 解析器]
    B -->|解析失败| C[生成错误信息]
    C --> D[LocalizedTextUtil.findText]
    D --> E[对消息执行 OGNL 插值]
    E --> F[错误信息返回给请求方]

问题并不在“文件上传”本身,而在两个设计假设叠加后产生的效果:

  1. 调用方没有充分理解 findText() 的完整语义。
  2. 来自远程请求的数据最终进入了一个能够执行表达式的上下文。

单看其中任何一步都可能显得正常,但把完整数据流连起来,就形成了远程代码执行风险。

这类漏洞最值得记住的不是某个 Struts 版本号,而是两个可迁移的原则:

调用 API 之前,必须清楚它真正会做什么。

任何跨越信任边界的数据,都不能因为经过了几层函数调用就自动变可信。

1.1 漏洞公开之后,真正危险的是“补丁窗口”

安全漏洞还有一个工程上经常被低估的问题:补丁发布并不等于风险消失。

以该 Struts 2 漏洞为例,时间线中的关键节点包括:

  • 2017 年 1 月 29 日,漏洞报告进入 NVD。
  • 2017 年 3 月 6 日,公开渠道开始出现漏洞说明与攻击示例。
  • 2017 年 3 月 7 日,Apache Struts 发布修复版本。
  • 随后的几天里,更多攻击样例迅速传播。

从漏洞发现到补丁公开之前,负责任的安全研究通常会给维护者留出修复时间;但一旦补丁公开,补丁本身就可能成为攻击者逆向分析漏洞的线索。

这意味着最危险的时间段往往是:

1
2
3
4
5
漏洞细节已经公开

攻击方式快速扩散

你的生产系统还没有完成升级

Equifax 事件把这个问题放大到了极致。相关材料中给出的数据是:约 1.45 亿条信用记录被盗,其中包含 20 多万用户的支付卡信息;事件披露后公司股价大幅下跌,市值损失达到数十亿美元量级。造成事故的核心原因之一并不是“没有补丁”,而是补丁已经存在数月,但生产系统没有及时完成升级

因此,安全代码不能只理解成“开发时写对”,还必须包括:

  • 能否第一时间知道依赖出现高危漏洞;
  • 能否快速判断自己是否受影响;
  • 能否迅速验证补丁的兼容性、可用性和性能;
  • 能否在很短时间内完成发布;
  • 如果正式补丁暂时不能升级,是否有经过验证的临时缓解方案。

安全问题既是技术问题,也是管理和执行问题。


2. 建立统一的安全编码模型

如果只记漏洞名称,很快就会陷入知识爆炸。真正稳定的知识应该是“漏洞背后的共同结构”。

可以把整个安全编码体系抽象为五个核心问题:

维度 需要回答的问题 典型风险
接口语义 这个 API 到底保证了什么? 误用 read()、错误理解表达式求值
信任边界 数据最初来自哪里?是否可信? Heartbleed、注入、非法长度
权限 谁能看、谁能改、谁能调用? 权限绕过、过度授权
状态 数据在不同时间和位置会不会变化? TOCTOU、数组越界、共享可变量
风险控制 单点防线失败以后怎么办? 算法失效、补丁延迟、配置失控

这五个问题最终可以落到安全编码的五条主原则:

2.1 清楚调用接口的行为

没有写进规范里的行为,不应该成为业务正确性的依赖。

“运行了一万次都没问题”不能代替规范。

2.2 跨界的数据不可信任

这里的“跨界”不是简单指“函数参数”,而是指数据最原始的来源跨越了系统信任边界

2.3 最小授权

代码拥有的权限越多,漏洞被利用之后的破坏范围越大。

2.4 减小攻击面

公开接口、可变状态、序列化入口、可扩展类、特权代码、开放端口,每增加一个都意味着新的攻击面。

2.5 纵深防御

任何一层都可能失败,因此要把防线做成多层,并且尽量让不同防护手段彼此独立。


2.6 用几个真实案例把原则串起来

这些材料中的案例看似分散,实际上几乎都可以映射回同一组基本原则。

案例 直接问题 材料中的关键数据 违反的核心原则
Apache Struts 2 multipart/OGNL 外部错误信息进入表达式求值 危险等级 10.0 不理解 API;跨界数据不可信
Nginx HTTP Range 整数累计发生溢出 CVE-2017-7529,评分 7.5 数值边界必须验证
JavaScript 数组遍历 回调期间数组被修改,旧指针继续使用 评分 9.9 共享可变量存在竞态
OpenSSL Heartbleed 声明长度与真实 payload 不一致 曾导致社会保障号、医疗数据泄漏 外部长度未经验证
Equifax 修复版本已经存在但迟迟未升级 约 1.45 亿条信用记录受影响 漏洞响应和补丁管理失效

这些案例共同说明:安全漏洞通常不是“某一行代码突然变坏”,而是数据、状态、权限和接口语义在跨层组合之后形成了攻击路径


3. 信任边界:外部数据为什么必须先验证

Heartbleed(心脏滴血)是理解“信任边界”的经典案例。

TLS 心跳消息可以抽象成下面的结构:

1
2
3
4
5
6
struct {
HeartbeatMessageType type;
uint16 payload_length;
opaque payload[payload_length];
opaque padding[padding_length];
} HeartbeatMessage;

接收端会读取 payload_length,然后把对应长度的 payload 原样复制回响应。

危险版本的核心逻辑可以简化为:

1
2
3
4
5
6
payload_length = read_length_from_request();
pl = payload_pointer;

response = malloc(1 + 2 + payload_length + 16);

memcpy(response_payload, pl, payload_length);

正常情况下:

1
2
payload_length = 5
payload = "hello"

响应也是 5 个字节。

但如果请求声明:

1
2
payload_length = 1024
实际 payload 只有 5 字节

而代码没有验证“声明长度”和“真实请求长度”的一致性,那么 memcpy() 就会继续读取 payload 后面的内存。

这不是 memcpy() 的错。

真正的问题是:

1
2
3
4
5
6
7
8
9
外部输入的长度

未经校验

参与内存分配

参与内存复制

越界读取其他内存

修复逻辑的关键,就是在使用 payload_length 之前确认整个消息结构真实存在:

1
2
3
4
5
6
7
8
9
if (1 + 2 + 16 > request_length) {
return 0;
}

payload_length = read_length_from_request();

if (1 + 2 + payload_length + 16 > request_length) {
return 0;
}

3.1 不是“所有参数都校验”,而是“在正确的边界校验”

安全编码不能机械地变成:

每个函数收到参数后都重新检查一次。

这会导致大量重复代码,也会让真正的信任边界更加模糊。

关键是追踪数据来源。

flowchart LR
    U[用户/网络/文件/外部调用方]
    A[边界入口 A]
    B[内部函数 B]
    C[内部函数 C]
    O[对外输出]

    U -->|外部数据| A
    A -->|已完整校验的数据| B
    B --> C
    C --> O

如果 A 已经完整验证了某个字段,并且内部逻辑不会再次改变它的语义,那么 BC 可以建立内部契约,不必反复做同样的检查。

但如果 A 只验证了部分内容,后续函数使用了尚未验证的部分,就必须继续完成验证。

3.2 外部数据不仅来自 HTTP

常见的外部数据至少包括:

  • 用户输入:Web 表单、GUI、命令行、配置;
  • I/O 输入:TCP、UDP、文件、消息队列;
  • 公开接口输入:SDK/API 的调用参数;
  • 公开接口输出后再次影响内部状态的数据。

最后一类最容易忽略。

如果一个公开方法把内部可变数组直接返回:

1
2
3
public String[] getProtocols() {
return protocols;
}

调用者拿到的不是“一个结果”,而是“内部状态的一把遥控器”。

更稳妥的实现是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public String[] getProtocols() {
return protocols.clone();
}

public void setProtocols(String[] protocols) {
if (protocols == null) {
throw new IllegalArgumentException("protocols was null");
}

String[] copy = protocols.clone();

for (String protocol : copy) {
if (protocol == null || protocol.isEmpty()) {
throw new IllegalArgumentException("protocol was null/empty");
}
}

this.protocols = copy;
}

这里同时做了三件事:

  1. 校验公开接口输入;
  2. 拷贝外部传入的可变量;
  3. 拷贝对外返回的可变量。

3.3 无法识别来源的数据,按不可信处理

随着调用链变长,开发者经常无法立刻判断某个参数最初来自哪里。

这种情况下,最危险的策略是:

“大概是内部传过来的。”

更安全的默认规则是:

来源不明确,就不能建立信任。


4. 数值运算:最普通的加法也可能是漏洞

整数溢出是最典型的“道理都懂,代码还是会写错”。

HTTP Range 请求允许客户端请求文件的一部分:

1
2
3
GET /image.jpg HTTP/1.1
Host: www.example.com
Range: bytes=0-1023,-512

服务端可能需要累计多个 range 的长度:

1
2
3
4
5
*sum += end - start;

if (*sum > contentLength) {
return false;
}

问题在于,数学里的整数没有上限,机器整数有。

如果:

1
2
sum = 一个正常值
end - start = 一个非常大的值

那么:

1
sum + (end - start)

可能越过整数类型的最大值并发生回绕。

本来应该是一个极大的非法值,溢出之后却可能变成一个很小的正数或负数,从而绕过后续判断。

Nginx 的 CVE-2017-7529 就属于这一类风险。材料给出的 CVSS 评分为 7.5。

4.1 安全比较:尽量避免先生成“大数”

对于正整数,如果你要判断:

1
2
3
if (a < b + c) {
// ...
}

危险点是 b + c 可能先溢出。

如果业务约束允许,可以改写为产生更小中间值的形式:

1
2
3
if (a - b < c) {
// ...
}

这种转换不是任何场景都成立,特别要注意负数和边界条件,但它表达了一个很重要的思路:

做比较时,尽量选择不需要先生成巨大中间值的表达式。

4.2 给业务数据设置现实边界

抽象整数一旦进入业务,就不再是“任意整数”。

它可能代表:

  • 文件长度;
  • 内存大小;
  • 金额;
  • 记录条数;
  • 请求体长度;
  • 数组下标;
  • 超时时间;
  • 分页大小。

因此,安全检查不能只问:

这个数字能不能放进 int

还要问:

这个数字在业务世界里是否合理?

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
private static final int MAX_DATA_SIZE = 16_384;

static void receive(byte[] data) {
if (data == null || data.length == 0) {
throw new IllegalArgumentException("empty data");
}

if (data.length > MAX_DATA_SIZE) {
throw new IllegalArgumentException("data too large");
}

// 后续逻辑建立在 MAX_DATA_SIZE 这个业务约束上
}

如果实际业务只允许 14 位范围内的数据,而计算使用 32 位整数,就天然获得了较大的运算冗余空间。

4.3 使用更大的类型,但不要把它当作最终防线

一种常见办法是:

1
2
业务需要 32 位范围
→ 中间运算使用 64 位

这能显著降低风险,但并不能消除“业务数据过大”的问题。

long 不溢出,不代表申请几十 GB 内存就是合理请求。

4.4 使用 checked arithmetic

Java 从 Java 8 开始提供了精确整数运算方法:

1
int sum = Math.addExact(a, b);

如果溢出,会抛出 ArithmeticException

同类方法还包括乘法、减法等场景。

当你无法严格证明运算不会溢出时,checked arithmetic 比裸 +* 更容易把隐蔽错误转成可观察失败。

4.5 在运算之前显式检查剩余空间

C 代码中,一个典型修复方式是:

1
2
3
4
5
if (*sum > (NGX_MAX_OFF_T_VALUE - (end - start))) {
return false;
}

*sum += end - start;

它把问题从:

1
先加,再看有没有出事

变成:

1
先证明这次加法是安全的,再执行

4.6 “没溢出”仍然可能是拒绝服务

另一个常见模式是:

1
2
协议字段声明:后续数据大小 = 2^31
实际只发送:1 字节

如果服务器相信长度字段并立刻分配几 GB 内存,攻击者只需要并发发出少量请求,就可能耗尽服务器内存。

因此,长度字段必须同时满足:

1
2
3
4
5
6
7
机器类型范围

协议允许范围

业务合理范围

当前资源预算

这比“不会整数溢出”严格得多。


4.7 类型位宽不等于可用安全位数

另一个容易被忽略的整数问题来自数字证书序列号。

材料讨论了这样一个场景:证书序列号需要至少 64 位随机性。如果直接使用常见的 64 位有符号 long 表示正数,最高位还承担符号位的职责,正数实际可用的随机位并不等于完整 64 位。

这类问题提醒我们:

1
2
3
“数据类型是 64 位”

“业务获得了 64 位有效信息”

位宽、符号位、编码方式、有效熵、协议要求是几个不同概念。

类似地,下面这些表达式也都值得单独审查:

1
2
3
target - nums[i]
multiplied * scale
addOn + multiplied * scale

减法、乘法和组合表达式与加法一样,都可能在中间结果处溢出。


5. 可变量:时间和空间一起变化时,安全问题就来了

可变状态的危险,本质上是:

你检查它的时候是 A,不代表你使用它的时候还是 A。

这就是 TOCTOU(Time Of Check To Time Of Use,检查时刻到使用时刻)问题的核心。

5.1 JavaScript 数组:遍历过程中结构可能改变

考虑:

1
2
3
4
5
6
7
var mutableArray = [0, {
toString: function() {
mutableArray.length = 0;
}
}, 2];

mutableArray.join('');

如果 JavaScript 引擎在 join() 开始时缓存了数组的起止地址,然后逐个调用元素的字符串转换逻辑,就可能出现:

1
2
3
4
5
1. 读取第一个元素 0
2. 处理第二个对象时调用 toString()
3. toString() 把数组长度改成 0
4. 引擎仍按旧的结束地址继续遍历
5. 指针进入已经失效的数组区域

这类问题说明:当遍历逻辑会触发用户可控回调时,容器本身也必须被当作可能变化的对象。

5.2 TOCTOU:验证通过后,对象又被别人改了

以可变的 java.util.Date 为例:

1
2
3
4
public void verify(Date targetDate) {
// 1. 校验 targetDate 对应日期是否合法
// 2. 根据 targetDate 输出“合同在该日有效”
}

如果另一个线程能在步骤 1 和步骤 2 之间修改 targetDate,那么“验证的日期”和“最终使用的日期”就不再是同一个状态。

安全做法之一是把外部可变量局部化:

1
2
3
4
5
public void verify(Date targetDate) {
Date localDate = new Date(targetDate.getTime());

// 后续只使用 localDate
}

5.3 为什么不能盲目调用参数的 clone()

假设参数类型是 Date

1
Date copy = (Date) targetDate.clone();

看起来更简洁,但如果传进来的是 Date 的恶意或错误子类,它完全可能重写 clone()

因此,对于来自外部的可扩展类型,使用自己可控的拷贝逻辑通常更可靠:

1
Date copy = new Date(targetDate.getTime());

这也解释了一个更广泛的设计原则:

安全关键路径不要把关键语义交给不可控的动态分派。

5.4 浅拷贝与深拷贝

浅拷贝只复制引用:

1
2
对象 A ----> 可变内容 X
对象 B ----> 可变内容 X

深拷贝复制内容:

1
2
对象 A ----> 可变内容 X
对象 B ----> 独立副本 X'

如果字段本身是不可变量,例如 String,共享引用通常没有问题。

如果字段是 Date、数组、集合或其他可变对象,浅拷贝仍然保留共享状态,竞态风险并没有消失。

一个典型的混合拷贝实现是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
final class MyContract implements Cloneable {

private String title; // immutable
private Date signedDate; // mutable
private byte[] content; // mutable

@Override
public Object clone() throws CloneNotSupportedException {
MyContract cloned = (MyContract) super.clone();

// 不可变量可以共享
cloned.title = this.title;

// 可变量做深拷贝
cloned.signedDate = new Date(this.signedDate.getTime());
cloned.content = this.content.clone();

return cloned;
}
}

5.5 集合是最麻烦的可变量之一

ArrayList.clone() 等集合拷贝通常是浅拷贝。

这背后是一个很现实的权衡:集合如果默认做递归深拷贝,成本可能不可接受。

所以对于集合,更好的设计往往不是“到处深拷贝”,而是:

  • 不把共享可变集合放进存在竞态的边界;
  • 返回不可修改视图或独立副本;
  • 尽量使用不可变数据结构;
  • 把可变状态约束在尽可能小的作用域。

6. 敏感信息:先识别,再授权,再控制生命周期

敏感信息可以从信息安全三要素中的 Confidentiality(机密性)来理解:

未经授权不得泄露的信息,就是敏感信息。

常见类型包括:

类别 示例
个人基本信息 姓名、年龄、身份证号码、电话
健康信息 健康记录、服药记录
教育信息 教育经历、课程、考试成绩
消费信息 购买记录、订单、支付记录
账户信息 银行卡、支付账户、社交账户
隐私信息 家庭成员、照片、行程
商业秘密 设计、工艺、战略、商业模式
客户信息 客户资料、合同、合作关系
员工信息 员工资料、薪酬

真正落地时,不能靠一张通用清单解决问题。每个系统都应该结合自己的业务建立数据分类分级

6.1 授权的三个要素

一个完整的授权模型至少需要定义:

1
2
3
4
5
权限是什么
+
权限授予谁
+
权限如何归属

以 Java 传统权限模型为例,FilePermission 可以表达:

1
permission java.io.FilePermission "/home/myhome", "read";

主体可以表达为某个用户:

1
Principal com.sun.security.auth.UnixPrincipal "duke"

授权策略再把两者关联起来:

1
2
3
grant Principal com.sun.security.auth.UnixPrincipal "duke" {
permission java.io.FilePermission "/home/duke", "read, write";
};

这里最值得学习的不是某个历史 API,而是授权模型的结构:

1
Resource + Action + Subject + Grant

现代应用里的 RBAC、ABAC、IAM、数据库权限、云资源策略,本质上都在回答类似的问题。

6.2 异常信息会泄露数据

开发者很喜欢“友好的错误信息”,例如:

1
2
java.io.FileNotFoundException:
/home/duke/.ssh was not found

这条错误至少暴露了:

  • 用户名 duke
  • 用户目录结构;
  • .ssh 文件或目录的存在性信息。

危险的是,这些信息可能绕过原本的业务授权,直接通过异常返回、错误页或日志传播出去。

对外异常可以进行净化:

1
2
3
4
5
try {
openSensitiveFile();
} catch (FileNotFoundException e) {
throw new IOException("resource unavailable");
}

但仅仅换异常类型还不够。

如果保留:

1
Caused by: java.io.FileNotFoundException: /home/duke/.ssh

敏感信息仍然在堆栈里。

因此需要区分:

1
2
3
4
5
面向用户的错误信息

内部诊断信息

可以无条件写入日志的原始异常

尤其要避免把完整异常堆栈直接展示在 HTML 页面或 API 响应中。

6.3 日志不是敏感数据的避难所

“不给用户看,写日志总可以吧”也是一个常见误区。

日志往往比业务数据库拥有更宽的读取范围:

  • 开发人员;
  • 运维人员;
  • 日志平台管理员;
  • 第三方监控系统;
  • 测试和排障工具。

因此,敏感信息进入日志本身就可能是新的泄漏路径。

对于部分数据,可以使用脱敏,使数据失去与具体主体的可关联性。例如只保留统计意义而去掉个人身份映射。

6.4 高度敏感数据要控制整个生命周期

敏感数据的风险不只存在于数据库。

完整生命周期包括:

flowchart LR
    A[产生/采集] --> B[传输]
    B --> C[内存使用]
    C --> D[持久化]
    D --> E[日志/缓存/备份]
    E --> F[归档]
    F --> G[销毁]

每一步都要问:

  • 谁能访问?
  • 是否必须明文?
  • 是否需要完整保存?
  • 是否能够被复制?
  • 是否有残留?
  • 是否存在备份和缓存副本?

6.5 哈希不是加密,安全机制必须先分清目标

材料的讨论中特别纠正了一个常见说法:

MD5 是杂凑函数,不是“加密”。

这个区别非常重要。

1
2
3
4
5
加密:
目标通常是以后可以在持有密钥的条件下恢复明文

哈希:
目标通常是把输入映射成固定摘要,不依赖“解密”恢复原文

因此,选择敏感信息保护机制前,必须先明确自己解决的是:

1
2
3
4
机密性?
完整性?
身份验证?
不可逆校验?

不能因为某种算法“看起来把明文变乱了”,就把它当作通用安全方案。材料同时明确指出,MD5 的安全性已经不足,不应把它作为现代敏感数据保护手段。


7. 敏感数据归零:不是银弹,但值得做

下面这段代码涉及密钥导出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
byte[] encodedKey = null;

try {
encodedKey = key.getEncoded();

if (encodedKey == null || encodedKey.length == 0) {
throw new InvalidKeyException("Cannot get key encoding");
}

return doFinal(encodedKey, 0, encodedKey.length);
} finally {
if (encodedKey != null) {
Arrays.fill(encodedKey, (byte) 0x00);
}
}

为什么要在 finally 中归零?

因为“释放对象”通常只是让运行时不再引用它,并不意味着底层内存立刻被物理清空。

如果内存残留中曾经存放:

  • 密钥;
  • 口令;
  • 身份证号;
  • 社会保障号;
  • 认证令牌;

后续的内存泄漏、内存转储或其他缺陷就可能把这些内容带出去。

归零并不是绝对保证。仍然可能存在:

  • 归零之前就发生泄漏;
  • 内存管理层复制过该数据;
  • 运行时或编译器优化影响真正的清理行为;
  • 其他副本仍然存在。

所以它应该被理解为:

纵深防御中的一层,而不是最终防线。


8. 继承与可扩展性:便利本身也会扩大攻击面

面向对象设计经常把“可扩展性”当作优点,但安全关键类恰恰需要克制扩展。

java.io.FilePermission 被定义为 final,背后就是这个原因。

假设它可以被继承,那么攻击者或错误实现可以写出:

1
2
3
4
5
6
7
8
9
10
11
12
public final class MyFilePermission extends FilePermission {

@Override
public String getActions() {
return "read";
}

@Override
public boolean implies(Permission p) {
return true;
}
}

如果授权系统依赖多态调用:

1
2
3
if (myPermission.implies(requiredPermission)) {
// allowed
}

那么子类就可以改变父类原本的安全语义。

8.1 子类可以破坏父类安全约束

这是第一类问题:

1
2
3
4
5
6
7
父类规范:只允许有限权限

子类重写方法

行为被放宽

安全约束失效

8.2 父类升级也可能破坏子类

更隐蔽的是反方向。

假设一个安全敏感类继承 Hashtable,并重写了:

1
2
put()
remove()

在里面执行权限检查。

后来父类新增了:

1
entrySet()

entrySet() 返回的集合视图又可以删除底层映射。

如果子类没有同步覆盖新的访问路径,就会形成:

1
2
3
4
5
6
MySensitiveData sensitiveData = ...;

Set<Map.Entry<Object, Object>> entries =
sensitiveData.entrySet();

entries.remove(...); // 绕开原来的权限检查

这说明继承存在一个长期维护风险:

父类 API 的合法演进,可能无意中为子类增加新的绕过路径。

调用链越深,继承层级越多,这类问题越难人工发现。

8.3 安全敏感对象优先考虑组合/代理

相比:

1
2
3
public class MySensitiveData extends Hashtable<Object, Object> {
// ...
}

更可控的是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class MySensitiveData {

private final Hashtable<Object, Object> delegate =
new Hashtable<>();

public synchronized Object put(Object key, Object value) {
checkPermission();
return delegate.put(key, value);
}

public synchronized Object remove(Object key) {
checkPermission();
return delegate.remove(key);
}
}

组合并不是无代价的,它失去了继承带来的很多便利。

但安全设计本来就是权衡:

1
2
3
需要可扩展性?
还是
需要强约束和可预测性?

如果对象承担授权、密钥、身份、敏感数据等安全职责,默认选择“可随意扩展”通常不是最稳妥的起点。


9. 序列化:把对象变成数据,也把内部状态送出了边界

序列化的核心过程可以拆成三步:

1
打包 → 传输/存储 → 拆解

也就是:

1
Serialization → Transport/Storage → Deserialization

它的吸引力很明显:

  • 跨进程;
  • 跨机器;
  • 跨平台;
  • 延长对象生命周期;
  • 为 RPC、RMI、分布式对象等机制提供基础。

但从安全角度看,序列化的危险也来自同一件事:

原本只存在于进程内部的对象状态,被转化成了可复制、可传输、可修改的数据。

9.1 敏感字段会自然进入序列化结果

例如:

1
2
3
4
5
6
7
public class Person implements Serializable {

private String firstName;
private String lastName;
private String birthday;
private String socialSecurityNumber;
}

如果默认序列化整个对象,那么生日和社会保障号也会进入序列化数据。

一旦这段数据被读取,敏感信息就可能泄露;一旦这段数据被篡改,反序列化出来的对象状态也可能被改变。

9.2 第一选择:敏感对象不支持序列化

最稳妥的方式通常不是“给序列化加更多补丁”,而是:

如果没有非常明确的需求,就不要让包含敏感状态的对象进入通用序列化机制。

9.3 使用 transient 排除敏感字段

如果确实需要序列化,可以把敏感字段排除:

1
2
3
4
5
6
7
8
public class Person implements Serializable {

private String firstName;
private String lastName;

private transient String birthday;
private transient String socialSecurityNumber;
}

但代价也很明显:

1
2
3
序列化前对象
!=
反序列化后对象

如果这些字段是对象业务语义的一部分,排除之后对象可能已经不再完整。

9.4 白名单优于黑名单

transient 的思路类似:

1
2
默认都序列化
只有特别标记的字段不序列化

安全上更保守的思路是:

1
2
默认都不进入外部表示
明确允许的字段才进入

Java 还提供 serialPersistentFields、自定义 writeObject()writeReplace()Externalizable 等机制,让开发者更主动地决定哪些状态可以离开对象边界。

这里真正值得带走的是:

序列化字段应当显式设计,而不是让类中新增的字段自动获得“可外传”能力。

9.5 加密和签名解决的是另外两类问题

如果序列化数据必须穿越不可信网络:

  • 加密主要回答“谁能看”;
  • 签名或完整性校验主要回答“谁能改”。

它们很重要,但不能自动消除对象序列化自身的全部风险。

安全通道能保护“传输中的数据”,并不意味着“任何对象都适合反序列化”。


10. 最小权限:控制的不只是用户权限,还有代码本身的权力

最小授权可以拆成两个层次:

1
2
3
最小权力的设计
+
最小限度的授予

10.1 模块边界就是攻击面边界

Java 9 模块系统的一个重要能力,是把:

1
public

进一步分成:

1
对模块外公开

和:

1
只在模块内部 public

例如:

1
2
3
module example.coding {
exports com.example.coding;
}

com.example.coding 被导出,外部模块可以访问。

其他未导出的内部包,即使类本身声明为 public,也不自动成为模块对外 API。

这是一种非常重要的安全设计方式:

1
2
3
4
5
内部实现
|
| 受控出口
v
公开 API

公开面越小:

  • 需要长期维护的兼容性越少;
  • 需要做安全防护的入口越少;
  • 潜在攻击路径越少;
  • 代码演进空间越大。

10.2 访问修饰符按“最小开放”选择

从最小权限角度,访问范围的优先级是:

1
2
3
4
private
→ package-private
→ protected
→ public

设计接口时,不应问:

“这个方法能不能 public?”

而应该问:

“完成需求所需的最小访问范围是什么?”

10.3 final 也是权限控制

final 经常只被当作“Java 语法细节”,但从安全角度,它控制的是修改权

final class

1
2
public final class SecurityPolicy {
}

阻止继承改变类语义。

final method

1
2
public final void verify() {
}

阻止子类覆盖关键流程。

final field

1
private final Socket socket;

限制引用被重新赋值。

安全设计应该形成习惯:

1
2
3
这个类真的需要被继承吗?
这个方法真的需要被重写吗?
这个引用真的需要重新赋值吗?

10.4 AllPermission 是典型的高风险捷径

假设为了省事直接授予:

1
permission java.security.AllPermission;

那么安全性就依赖两个几乎无法长期保证的前提:

  1. 获得该权限的代码永远可信;
  2. 这些代码永远没有可被利用的漏洞。

第二个前提本身就不现实。

绝对权限越方便,对攻击者越有价值。

10.5 特权代码要短小

材料使用 AccessController.doPrivileged() 说明特权代码设计原则。

无论具体权限框架如何变化,这个原则仍然具有通用意义:

特权区域应该尽可能短,只做必须以高权限完成的事情。

不要把大量普通业务逻辑、用户输入处理、字符串拼接放进特权执行块。

理想结构是:

1
2
3
4
5
普通代码
→ 准备已经验证的数据
→ 很短的特权操作
→ 立即退出高权限上下文
→ 普通代码

11. API 规范:稳定安全的代码建立在“明确契约”上

C 语言的 read() 看起来再普通不过:

1
int n = read(socket, buffer, 1024);

但它的规范包含几个关键事实:

1
2
3
> 0  : 实际读取到的字节数
= 0 : EOF
= -1 : 错误,并通过 errno 提供原因

真正容易被误用的是第一条:

返回的是“这一次实际读取的字节数”,不是“你想要的完整消息长度”。

11.1 TCP read 不对应“完整业务消息”

发送端执行一次:

1
send(socket, hello, strlen(hello), 0);

接收端并不能假设一次:

1
read(socket, buffer, sizeof(buffer));

一定得到完整的:

1
Hello from server!

它可能只得到:

1
H

也可能得到:

1
Hello

也可能一次拿到多条上层消息的一部分组合。

因此,业务协议必须定义消息边界

常见方式包括:

1
2
3
4
固定长度
长度前缀
分隔符
协议帧

例如 HTTP 请求行使用 CRLF 作为结构的一部分。

11.2 read 到的是字节,不是 C 字符串

另一个常见错误:

1
2
3
4
5
char buffer[1024];

int n = read(socket, buffer, sizeof(buffer));

printf("%s\n", buffer);

read() 不保证结尾自动添加 \0

所以如果要按字符串输出,需要自己处理有效长度:

1
2
3
4
5
6
7
8
char buffer[1024];

ssize_t n = read(socket, buffer, sizeof(buffer) - 1);

if (n > 0) {
buffer[n] = '\0';
printf("%s\n", buffer);
}

这仍然只解决“字符串终止”问题,没有解决消息完整性。

11.3 EOF 必须服从规范,而不是实现者直觉

TLS 早期针对 CBC 风险的一段历史,恰好说明“违反一个小小 API 契约”可能造成多大长期影响。

某种修复思路需要先发送一个空数据段,再发送真正的数据。

按照 read() 语义:

1
2
3
返回 0
=
数据流结束

但曾经存在实现把“收到空 TLS 数据段”错误地向上表现为 read() == 0

结果是:

1
2
3
4
5
TLS 内部只是一个空记录

应用层却认为连接已经 EOF

后续真实数据无法继续读取

一个看起来很小的规范偏差,最后演化成巨大的互操作性问题,甚至阻碍安全修复的推广。

这带出一个非常重要的 API 原则:

对于接口规范,使用白名单思维:明确写出的行为才能依赖,没有写出的行为都不能作为契约。


12. 从 CBC、RC4 到 TLS 1.3:为什么安全系统需要“多引擎”

TLS 的算法演进非常适合说明风险预案。

简化后的历史脉络是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TLS 1.0
├─ CBC
└─ RC4

CBC 出现安全问题
→ 业界可以暂时转向 RC4

RC4 后来出现严重问题
→ 业界又可以转回修补后的 CBC

TLS 1.2
→ 引入 AEAD 等选择

TLS 1.3
→ 淘汰旧方案
→ 保留多种 AEAD 选项

这里最重要的不是某个算法本身,而是多引擎设计

12.1 备用能力必须日常处于可用状态

真正的双引擎不是:

1
2
主方案每天运行
备用方案从来没用过

而是:

1
2
3
多个方案都实际被实现
多个方案都有真实用户
多个方案都持续接受测试

只有这样,当一个方案突然失效时,另一个方案才真正可切换。

如果备用能力多年没有运行,它最多只能叫“文档里的 Plan B”。

12.2 算法列表不能写死在代码里

一个典型风险是:

1
2
3
4
5
return new String[] {
"SSL_RSA_WITH_RC4_128_MD5",
"SSL_RSA_WITH_RC4_128_SHA",
"TLS_RSA_WITH_AES_128_CBC_SHA"
};

如果优先级写死在代码里,一旦最高优先级算法出现漏洞,就必须:

1
2
3
4
5
改代码
→ 编译
→ 测试
→ 发布
→ 所有部署升级

对于已经大规模部署的软件,这个路径可能太慢。

因此需要“降落伞”式应急控制。

材料给出的 JDK 示例包括通过安全配置降低某算法优先级:

1
jdk.tls.legacyAlgorithms = RC4_128

或者通过应用级配置选择协议:

1
java -Djdk.tls.client.protocols="TLSv1.3" myApp

这些示例最重要的意义是:

高风险机制要尽量支持无需重新发布代码的紧急切换能力。

配置名称和具体语义需要以当前运行时版本文档为准,不应机械复制历史示例。

12.3 长期方案和短期方案要同时存在

可以把风险预案分成:

类型 目标 特征
双引擎 长期抗风险 多方案持续可用
降落伞 短期止血 快速、简单、无需大改代码

只有短期方案,没有长期替换,就会一直背着技术债。

只有长期方案,没有紧急开关,又可能等不到长期方案落地。


13. 纵深防御:假设每一层都会失败

Defence-in-Depth(纵深防御)的出发点非常现实:

不存在没有漏洞的单层防线。

如果整个系统只依赖最外围的一道验证:

1
2
3
WAF 没挡住
=
系统全部失守

那只是把系统安全寄托在单点上。

更合理的设计是让攻击者必须连续突破多层。

例如保护一段高敏感内存,可以从远到近考虑:

1
2
3
4
5
6
7
8
物理环境
→ 主机访问
→ 系统账户
→ 进程权限
→ 内存访问
→ 应用授权
→ 数据脱敏/加密
→ 敏感数据及时清理

每一层都不完美,但多层组合会显著提高攻击成本。

13.1 纵深不是“堆更多安全产品”

真正有效的防线必须能够运转。

每一层都要回答:

1
2
3
4
谁做决策?
谁执行?
谁监督?
失败怎么纠正?

可以映射到一个很朴素的闭环:

1
2
3
4
计划
→ 执行
→ 检查
→ 纠正

没有这个机制,安全配置文件只是纸面防线。

13.2 防护要有多样性,而且要相互独立

如果两道防线依赖同一个失效点,它们并不是两道真正独立的防线。

例如:

1
2
3
门禁系统
+
门卫

看起来是两层。

但如果门卫可以随意绕过门禁,那么只要门卫这一层失效,两层同时失效。

软件系统也是一样:

1
2
3
应用管理员
+
数据库管理员

如果最终都由同一个超级账户无条件控制,就很难称为独立防护。

多样性的重点不是数量,而是:

不同防线的失效模式是否足够独立。


14. 安全补丁与依赖管理:安全工程的另一半

写出安全代码只是开始。

生产系统还要面对:

1
2
3
4
5
6
7
8
9
第三方库
框架
JDK
操作系统
数据库
中间件
容器镜像
基础镜像
网络设备

任何一个依赖都可能出现新的漏洞。

14.1 建立安全更新信息源

不能等攻击发生以后再搜索“是不是某个组件有漏洞”。

组织应该主动获取:

  • 软件供应商安全公告;
  • 依赖组件安全版本;
  • NVD/CVE 信息;
  • 安全厂商或威胁情报;
  • 内部 SIRT/安全响应通知。

14.2 短期升级安全修复版,长期避免长期滞后

材料中的经验可以概括为:

1
2
3
4
5
6
7
短期:
优先升级最近的安全修复版
→ 尽量降低兼容性变化

长期:
持续跟进受支持的新版本
→ 不把多年升级成本一次性积累

长期不升级看起来“稳定”,实际上会积累:

  • API 变化;
  • 数据格式变化;
  • 配置变化;
  • 已知漏洞;
  • 生命周期结束风险。

最后一次跨多个大版本升级,往往比持续小步升级更痛苦。

14.3 “不对外开放”不能替代补丁

把 Redis、MySQL 等服务限制在内网当然是有价值的攻击面收缩手段。

但它不能把漏洞变成不存在。

原因包括:

1
2
3
4
5
内网也可能存在不可信主体
合作方也可能进入边界
其他被攻陷服务可能成为跳板
本机用户也可能不是完全可信
正常业务请求也可能触发漏洞

同理,在前面再加一层过滤也无法证明后端代码安全。

如果后端漏洞可以通过正常业务语义触发,通用前置过滤层可能根本看不出异常。

14.4 自动化回归测试是补丁速度的基础设施

很多团队不是“不想升级”,而是:

不敢升级。

他们担心:

  • 兼容性;
  • 性能;
  • 可用性;
  • 行为变化。

这正是自动化测试、灰度发布和回滚能力的价值。

安全修复速度,很大程度上取决于团队能不能快速回答:

1
这个版本升级以后,核心功能还正常吗?

15. 代码评审应该看什么:一份可执行的安全检查表

把前面的原理落到代码评审中,可以形成下面这张清单。

领域 评审问题
接口 是否真正读过调用 API 的规范?有没有依赖规范未保证的行为?
输入 数据最初来自哪里?哪些字段仍未校验?
数值 加减乘除会不会溢出?数值是否超出业务合理范围?
长度 长度字段是否同时满足协议、业务和资源约束?
内存 外部长度是否直接参与内存分配、复制、数组访问?
可变量 参数会不会在检查和使用之间被修改?
拷贝 对可变参数和返回值是否需要防御性拷贝?
集合 返回的是内部集合、浅拷贝还是不可修改结果?
继承 类/方法真的需要可扩展吗?父子类能否改变安全语义?
权限 当前主体拥有的权限还能不能更少?
API 可见性 public 能否改成 package-private 或 private
final 类、方法、字段是否可以限制修改?
特权代码 高权限代码是否足够短?是否处理了不必要的用户数据?
敏感信息 哪些字段属于敏感信息?谁能看、谁能改?
异常 异常消息和堆栈有没有暴露路径、账号、内部结构?
日志 日志是否记录了口令、Token、银行卡等不该记录的数据?
序列化 对象是否必须序列化?敏感字段是否会被外传?
传输 序列化或敏感数据的传输是否具备机密性和完整性保护?
清理 高度敏感的临时数据是否能够尽快清理?
边界 模块内部实现和公开接口是否清晰分离?
输出 可变输出是否可能反向修改内部状态?
预案 高风险组件是否有可切换替代方案?
配置 高风险特性能否通过安全配置快速禁用或降级?
依赖 是否使用受支持的安全修复版本?
响应 漏洞公开后,团队能否快速定位、验证、修复、上线?
防御 多层防御是否真的独立?是否只是重复依赖同一个控制点?

这张表真正的目的不是让开发者机械打勾,而是让几个关键问题形成条件反射:

1
2
3
4
5
6
边界在哪里?
这个数据可信吗?
这个权限是不是太大?
这个状态会不会变?
这个 API 真的保证了我依赖的行为吗?
这一层失败以后还有什么?

16. 如何把安全原则变成编码习惯

安全问题数量非常多。

仅仅 CWE 中就有大量不同类型的弱点。如果把学习目标设成:

1
把所有漏洞技巧背下来

几乎一定会失败。

更有效的学习路径是从技巧逐渐上升到原则。

16.1 第一阶段:记住几个基本原则

例如:

1
跨界的数据不可信任

一开始你可能只会联想到“整数长度不能太大”。

继续训练以后,它会扩展到:

1
2
3
4
5
6
7
浮点数是否合法?
用户名有多长?
密码长度是否有上限?
SQL 查询是不是外部拼出来的?
文件路径是否允许逃逸?
数组下标是否来自网络?
对象类型是否来自外部?

原则开始驱动你主动寻找适合当前场景的检查方法。

16.2 第二阶段:学习语言自己的安全编码指南

通用原则必须结合语言特性。

C/C++ 需要特别关注:

  • 整数;
  • 指针;
  • 内存;
  • 缓冲区;
  • 生命周期。

Java 需要特别关注:

  • 对象可变性;
  • 序列化;
  • 继承;
  • 反射;
  • 权限边界;
  • 集合和 API 契约。

不同语言的风险形态不同,但背后的信任、边界、状态和权限原则高度相通。

16.3 第三阶段:持续看真实漏洞和补丁

最有价值的问题不是:

“这个漏洞叫什么?”

而是:

1
2
3
4
5
6
攻击输入从哪里进入?
经过哪些函数?
哪条假设失败了?
为什么原代码看起来合理?
补丁增加了哪一个约束?
我们的系统有没有类似结构?

NVD、CVE、安全公告、源代码补丁和漏洞分析,都是把“见识”转化为编码能力的重要来源。


16.4 安全能力要跨过三道关

材料把安全能力的成长概括成三个层次:

Conscious:意识

先意识到:

1
2
安全问题重要
而且当前代码可能存在尚未被发现的安全威胁

没有这一层,开发者甚至不会主动寻找问题。

Awareness:知晓

知道:

1
2
3
4
系统是否存在漏洞?
影响哪些版本?
严重程度如何?
哪些组件受影响?

漏洞数据库、安全公告、依赖扫描等工具主要帮助解决这一层问题。

Visible:看到

真正看到:

1
2
3
4
漏洞根因在哪一行逻辑?
攻击路径如何形成?
补丁为什么有效?
自己的代码有没有同构问题?

这一步才会把“安全新闻”转化成自己的工程能力。

可以把它理解成:

1
2
3
4
意识到风险
→ 知道风险存在
→ 看见风险结构
→ 能主动修复和预防

17. 阅读源代码,也是安全能力的一部分

当框架出现漏洞时,只会等一篇二手分析文章,学习速度会很慢。

阅读源代码可以采用一种由外到内的顺序:

1
2
3
4
5
6
7
README
→ 用户指南
→ 开发者指南
→ 示例/测试
→ 公开接口
→ 接口实现
→ 调用链

这比一上来随机打开几万行源码有效得多。

两个很好用的阅读方式是:

剥洋葱

从一个公开 API 开始,一层一层进入实现:

1
2
3
4
5
API
→ helper
→ parser
→ state machine
→ low-level operation

挖井

找到一个具体问题,从这一点沿调用链一直向下:

1
2
3
4
5
某个异常
→ 谁抛出的
→ 谁传入参数
→ 数据最初从哪里来
→ 哪个边界没有检查

IDE 的“查找定义”“查找引用”“调用层次”“调试堆栈”都是非常重要的安全分析工具。


18. 代码质量和开发效率并不是天然矛盾

“考虑安全会不会导致开发变慢”是一个非常现实的问题。

如果只看一小时:

1
2
3
不校验
不设计
不测试

当然可以多写一些代码。

但软件工程的效率不应该按“今天写了多少行”定义,而应该看:

1
2
3
4
5
6
7
8
9
10
11
需求交付
+
返工成本
+
故障成本
+
维护成本
+
升级成本
+
安全事故成本

高质量代码经常让前半段显得慢一点,却减少了后续“往回跑”的次数。

代码评审也是类似的:

1
现在多花 30 分钟

可能换来:

1
未来少花几天排查事故

真正高效的团队不是没有质量流程,而是把质量要求逐渐变成:

  • 自动化检查;
  • 测试;
  • 模板;
  • 代码评审习惯;
  • 公共库;
  • 默认安全配置;
  • 团队共识。

当安全规则进入工程机制以后,它们就不再主要依赖每个开发者临时记忆。


19. 从“写代码的人”升级为“解决问题的人”

安全编码最终不是为了写出一份更漂亮的代码,而是为了让系统在真实世界中长期可靠地解决问题。

一个软件开发者真正需要掌握两类知识:

1
2
3
技术工具
+
问题领域

只理解技术工具:

1
2
3
4
Java
Spring
Redis
Kubernetes

竞争者很多。

如果同时理解:

1
2
3
4
5
6
7
支付
结算
交易
身份
安全
医疗
物流

就开始成为领域问题的解决者。

安全本身也是问题领域的一部分。

理解代码之外的:

  • 组织流程;
  • 用户行为;
  • 运维约束;
  • 版本生命周期;
  • 合规;
  • 事故响应;
  • 业务损失;

会让你更容易判断:

1
2
3
4
什么地方必须极其保守?
什么地方可以做工程权衡?
什么风险必须立即解决?
什么复杂度值得投入?

20. 总结:安全代码的核心不是“知道更多漏洞”,而是建立更好的默认思维

把整套安全编码方法压缩到最后,可以得到一条非常清晰的主线:

flowchart TD
    A[明确系统边界] --> B[识别外部数据]
    B --> C[校验合法性与业务范围]
    C --> D[限制可变状态与公开接口]
    D --> E[执行最小权限]
    E --> F[保护敏感信息全生命周期]
    F --> G[遵守 API 明确契约]
    G --> H[准备多引擎和应急开关]
    H --> I[部署纵深且独立的防御]
    I --> J[持续更新依赖与安全知识]
    J --> A

真正值得形成条件反射的,不是某个具体漏洞编号,而是下面这些问题:

这个数据最初来自哪里?

我真的理解这个 API 的契约吗?

这个数值在机器范围内合法,在业务上也合理吗?

这个对象在我检查以后还能被别人修改吗?

这个方法为什么必须是 public?

这个类为什么必须允许继承?

这段日志有没有把不该看到的信息带出去?

这个对象真的必须序列化吗?

当前代码拿到的权限是不是超过完成任务所需?

如果这一道防线失败,下一道防线是什么?

如果这个算法、框架或依赖明天被宣布不安全,我们能不能当天止血?

安全代码并不等于“永远没有漏洞的代码”。这样的目标并不现实。

更现实、更专业的目标是:

让漏洞更难产生,让攻击更难抵达,让权限更难扩大,让敏感数据更难泄露,让问题更早暴露,让补丁更快落地,并且在某一层失败之后,系统仍然有下一层可以阻断损失。

这才是一套真正能够长期运行的安全编码体系。


安全编码体系:从信任边界、最小权限到纵深防御
https://allendericdalexander.github.io/2026/08/12/geeker/144/1secure-coding-system/
作者
AtLuoFu
发布于
2026年8月12日
许可协议