作者:天融信阿尔法实验室
公众号: https://mp.weixin.qq.com/s/h9bhovkDoVdq6HHJcoREvg
首先先简单介绍一下Apache Solr
Apache Solr是一个强大的搜索服务器,它支持像API一样的REST。 Solr由Lucene提供支持,可以实现强大的匹配功能,例如短语,通配符,连接,分组和更多的各种数据类型。 它是高度优化的高流量使用Apache Zookeeper。
介绍完Apache Solr之后我们就来复现一下这次的 Apache Solr Velocity服务端模板注入漏洞
我们首先从Apache Solr官网上下载Apache Solr 8.2.0的服务端https://mirrors.tuna.tsinghua.edu.cn/apache/lucene/solr/8.2.0/solr-8.2.0.tgz下载完成之后解压
我们通过命令行终端进入bin目录然后输入“./solr start”命令!
Apache Solr就会默认在本地的8983端口启动服务,
我们访问一下地址 http://127.0.0.1:8983/solr/#/
查看左侧的Core Selector的集合名称!
使用burp Repeater模块像服务端发包修改指定集合的配置!
修改配置成功
然后发送事先构造好的payload!
漏洞复现完成,但是分析漏洞我们还需要一些前置知识,比如什么是模板注入漏洞,以及Velocity究竟是什么,
我们都知道,现在web开发讲究的是一个前后端分离的方式,MVC模式就是其经典的代表。如果抛弃前后端分离,仅仅开发一个能用的网站,只需要一个JSP其实就够了,但是这样很明显会导致开发时逻辑及其混乱,以及后期维护起来成本极高的问题,这样的开发完全违背的我们java这么一个面向对象语言优雅的编程思维。
我们在开发一个程序时希望的就是一个模块尽量是独立完成某一个功能而不依赖别的模块的,也就是我们的高内聚,低耦合的思想。
这种思想用到我们的web开发的架构时,就有了我们的MVC模式,即 Mode,
View,Controller。和我们的web三层架构,即表示层,业务逻辑层,和数据接口层。尽量保证每一层都是独立可用的,在这里特别提示一下,web三层架构是java独有的概念,而MVC架构则是通用的。
在这种情况下,每一层都出现了其相对应开源组件。
首先不得不提的两个使用量最高的MVC框架,Struts2,和SpringMVC。
表现层有我们的JSP和Thymeleaf,Velocity,Freemarker等模板引擎
业务层由我们最火热的开源组件Spring
数据层就有我们最常见的Mybaits和Hibernate两个Dao层框架
而这次我们要重点注意的就是位于我们的表现层,也就是我们的Velocity模板引擎。
对于web不太熟悉的同学可能暂时还不能理解什么是模板引擎,或者说模板引擎是做什么用的。但是相信大家都听过JSP,
JSP的全称是Java Server Package,与普通的静态html页面相比,区别在于我们可以在JSP页面上书写java代码,以实现和用户进行交互,从而达到动态的这么一个效果。
JSP一开始出现的时候是同时兼具前端和后端的作用,也就是说如果只是开发一个勉强能用的java动态网站,jsp其实就足够了。
在JSP出现之前,实现动态页面的效果用的是Servlet的技术,Servlet可以很好的实现接受用户传来的参数并进行处理。但是把数据返回到前端并输出html页面时确异常的麻烦和痛苦。同常需要一行一行的输出html代码,像下面这样
后来JSP出现了,如果说Servlet是java代码中写HTML的话,那Jsp就是HTML中穿插写java代码了,jsp相比于Servlet来说并不是一个新的技术,jsp是Servlet的一个扩展,其本质仍是Servlet,
我们看一个最简单的JSP页面
看起来就是一个普通的HTML页面,为什么我会说jsp的本质是Servlet呢?
当我们将项目编译打成war包部署在Tomcat下时,会放在Tomcat的WebApp目录下,里面有我们的项目后台的java文件编译成的.class文件。同时也有我们的jsp文件。
但是我们的jsp文件是不能直接被解析的,Jsp不像HTML拿来就能直接返返回给客户,因为jsp文件中是包含有java代码的,浏览器又不能解析我们jsp页面上的java代码,所以将jsp编译成浏览器能解析的html页面的工作就交由了我们的Tomcat来做
当我们启动Tomcat时第一次访问我们的这个jsp页面,往往速度都会稍微慢一些,往后在访问时速度就会很快。这是因为,第一访问时,Tomcat会在他的根目录的work/Catalina/localhos目录下生成我们对应项目名称的一个文件夹。
并生成一个名称为org.apache.jsp的一个package,我们去观察一下!
我们可以看到一个java文件和一个.class文件。还记得我刚刚才说过jsp的本质其实就是Servlet么?我们点开这个java文件来一探究竟。
我们从中观察到这这么几个重点
首先这是一个java类,它继承了HttpJspBase类同时实现了两个接口
第二个重点在这里
这是一个静态代码块,静态代码块在类进行加载时就会执行,先于构造代码块和构造方法,是一个java类中最先被执行的代码。
我们根据其代码内容不难看出这静态代码块的作用是用来import Java类的。
接下来是一个名叫_jspService的函数,是不是特别像servlet的doGET和doPost方法?
最后我们在看这里
我们发现我们之前看到的jsp文件中的html内容,在这里被替换成了通过
JspWriter对象一句一句的写出的。
此时是不是理解了我之前说的,Jsp的本质就是servlet。表面上上我们是在一堆HTML标签中插入了一个又一个的java代码,本质上Tmocat在接收到客户端对我们这个jsp的请求后,会将我们的整个jsp文件编译成java文件在编译成.class文件。将HTML一句一句通过JspWriter对象的write方法一行一行的输出。
讲解了JSP的基础知识后不知道大家有没有发现一个问题就是,Jsp虽然说是模板引擎的一种,但是如果只做为一个为前端服务的模板引擎来说,它的功能过于强大了,导致它不光可以书写前端页面,因为JSP可以毫无阻碍地访问底层的 Servlet API 和 Java 编程语言,所以同时也可以无缝书写后端的逻辑代码,在展示数据的同时也可以对数据进行处理。
这样就导致前端和后端完全就纠缠在了一起。完全违背了我们MVC的设计思想,你能想象一个前端页面是用Servlet输出,而后端代码使用Jsp来写的网站该怎么去维护么?
面向对象的优雅思想在这一刻荡然无存。
面向对象的核心思想就是,低耦合,高内聚。每一个模块的功能尽可能单一,尽可能的降低和别的模块和功能之间的耦合度。
所以Thymeleaf,Velocity,Freemarker等优秀模板引擎就一个接一个的出现了。
Velocity为主我们来了解,这个在MVC设计模式中,为View层服务的优秀模板引擎。
刚才通过对Jsp的介绍,我们理解了,一个模板引擎他的主要功能就是负责将后端代码也就是servlet处理完成的数据,提取并按照之前写好的样式展示出来。
Velocity是一个基于java的模板引擎(template engine)。它允许任何人仅仅使用简单的模板语言(template language)来引用由java代码定义的对象。
当Velocity应用于web开发时,界面设计人员可以和java程序开发人员同步开发一个遵循MVC架构的web站点,也就是说,页面设计人员可以只关注页面的显示效果,而由java程序开发人员关注业务逻辑编码。Velocity将java代码从web页面中分离出来,这样为web站点的长期维护提供了便利,同时也为我们在JSP和PHP之外又提供了一种可选的方案。
前面说了这么多,现在我们在这里简单演示下Velocity这个模板引擎,给大家一个更直观的概念。
首先导入以下的包
然后我们创建一个演示类
这里我们首先实例话了一个VelocityEngine,并设置加载加载classpath目录下的vm文件
然后初始化VelocityEngine,接着就是加载一个模板,这里模板的名字叫“Hellovelocity.vm” 接下来的操作就是我们向模板的上下文中添加我们要传递的参数和值了。
最后的t.merget就会开始循环遍历生成的Velocity AST语法书的各个节点,执行每个节点的渲染方法。
我们看一下我们加载的这个模板的具体实现
和最终的执行结果
我们看到这里可以将我们之前后端代码中传输的值直接取出也可以循环取出。
这样我们就可以提前将静态部分用HTML和JavaScript写好,然后需要动态交互的部分就可以使用Velocity语法来进行编写。
首先我们下载Apache Slor 8.2.0源码
https://mirrors.tuna.tsinghua.edu.cn/apache/lucene/solr/8.2.0/solr-8.2.0-src.tgz
下载完成后
我们进入Solr源码根目录
执行命令
ant ivy-bootstrap
然后再执行ant idea命令将源码转化成idea可以导入的模式!
然后我们打开idea,选择open!
最后导入完成后的样子
为了可以调试源码,我们需要再做一些配置
点开左上角的Edit Configuration
然后新增Remote
并按照如下配置
配置完成后我们进入solr的服务端的bin目录,并执行如下命令
然后我们带idea中点击debug按钮,当有如下显示时代表调试环境搭建成功
接下来我们就可以在自己想下断点的地方下断点了。
首先我们就来一步一步分析这个漏洞吧,审计一个web项目我们首先先看有没有web.xml这个文件
我们找到了web.xml这个文件,位置在solr/webapp/WEB-INF/目录下
我们打开看一下内容
首先这个web.xml文件一开始就是一个filter过滤器,这个过滤器类路径是
org.apache.solr.servlet.SolrDispatchFilter,拦截的范围是所有请求
所以我们首先就需要去这个SolrDispatchFilter这个类去观察
此时我们有两条分析接下来漏洞走向的方式,我们通过查阅网上的资料得知
我们去目录下查看一下
果然有这两个文件
然后我们看下两个文件的部分内容,先看下solrconfig.xml
可以看到velocity.params.resource.loader.enabled参数默认是flase,也就是说是默认是不开启的。
我们在看一看configoverlay.json文件
看到这里存储着我们上传上来的参数,这里我们将params.resource.loader.enabled制为true
我们可以通过观察该文件何时被修改来判断,是否该跟进代码中。
然后我们观察poc的时候不难发现请求的API为“/config”
我们通过查阅资料发现
Solr中有很多的RequestHandler,默认配置在solrconfig.xml中,同时也有很多没有配置在solrconfig.xml,称为隐式RequestHandler。而“/config”就是其中之一,我们可以看到SolrConfigHandler便是处理提交我们提交poc的API之一
但是为了,讲的更加清晰,我们还是从SolrDispatchFilter.doFilter方法来一步一步的跟踪。
首先SolrDispatchFilter.doFilter方法执行到第 423行的时候,
会调用HttpSolrCall.call方法
我们跟进这个方法
然后代码执行到execute()方法时configoverlay.json文件更新了 所以我们跟进这个函数继续跟进
按照上面的思路,执行到handler.handleRequest()继续跟进
此时就进入到了一开始我们从资料中所看到的“/config”所对应的类SolrConfigHandler
由于此时进入这个函数是为了调用它的handleRequestBody方法,所以我们接着向下执行
这里POST用来修改数据。GET用来查询数据,所以我们执行到
command.handlePOST()方法然后跟进
执行到handleCommands()方法 此时传入的opsCopy就是我们从前端传入的配置信息,而overlay时当前的配置信息
继续跟进,当执行到SolrResourceLoader.persistConfLocally()方法时
configoverlay.json,文件更新了
此时我们看到,关键参数时overlay.toButeArray()
而overlay参数最近的一次赋值动作是在这行代码里进行的,我们先跟进updateNamedPlugin()方法看一看
updateNamedPlugin方法中将op 和overlay参数都传入了进去
当执行到这个if判断时,判断为真,返回overlay,所以关键在于
Verifyclass()这个函数。
这里op仍然为我们 post传入的配置参数 clz的值为“solr.VelocityResponseWriter”
继续跟进
跟进函数之后我们看到这样一行代码
根据执行逻辑首先执行getCore方法,返回一个SolrCore对象
然后执行op.getDataMap()方法,返回一个Map对像
然后new 一个PluginInfo对象,构造方法里的主要操作就是向一个 NameList类型的对象中存值,存入的是我们POST传入的配置参数
createInitInstance()方法
泛型变量o是根据我们传入的参数PulginInfo对象的className属性“solr.VelocityResponseWriter”然后通过createInstance()方法反射获得的VelocityResponseWriter对象
因为VelocityResponseWriter对象实现了NamedListInitializedPlugin接口
所以执行
跟进
然后我们进入了VelocityResponseWriter对象的init方法,在这里有这么几行代码
可以看到在这里我们将VelocityResponseWriter对像的两个重要属性
paramsResourceLoaderEnabled,
solrResourceLoaderEnabled
设置为了true,也就是允许我们上传自定义模板了
紧接着init方法执行结束后,就会将VelocityResponseWriter对象按原路返回到SolrConfigHandler并赋值给overly属性
紧接着执行到第504行代码时configoverlay.json文件更新了,我们跟进这个方法
在调用SolrResourceLoader.persistConfLocally()方法时,可以看到我们将
overly作为参数传递了进去
此时观察代码我们就明白了,真正将我们post传递的配置参数写入文件的操作是在这一步进行的。至此 poc的第一部分追踪完毕。
接下来是poc执行的第二阶段
老规矩先从SolrDispatchFilter类看起
执行到HttpSolrCall.call步入
紧接着执行到HttpSolrCall.writeResponse方法
观察此刻传入的三个参数,solrRsp参数是一个 SolrQueryResponse对象,我们GET传入的playload存储在该对象的value属性中
这个responseWriter对象相当重要,这里我们看到了两个参数
paramsResourceLoaderEnabled和solrResourceLoaderEnabled
这是我们poc第一步中修改的两个配置属性,只有这两个属性为true我们才可以上传自定义模板成功
responseWriter参数指向的是一个VelocityResponseWriter对象,responseWriter最近一次被赋值是在,下面这行代码中
本着刨根问底,以及锻炼我们分析代码执行逻辑能力的目的,我们深入了解一下
我们跟踪进HttpSolrCall.getResponseWriter方法
可以看到,这里将我们GET穿入的key的值为wt的属性里面的值velocity取出并作为参数传给了core.getQueryResponseWriter方法,core参数指向的是一个SolrCore对象
跟入SolrCore.getQueryResponseWriter方法
跟入responseWriters.get方法
此时我们来到了一个PluginBag对象的get方法
在执行完T result = get(name)方法后 result的结果中是一个VelocityResponseWriter对象且
paramsResourceLoaderEnabled和solrResourceLoaderEnabled属性都已被置为true,就是说给这两个属性赋值的操作就在get(name)这个方法里。继续跟进
还是继续跟进result的无参get方法
到这里,就出现问题了
这里会判断一个名字叫lazyInst的属性是否为空,如果不为空,则返回这个属性
我们来看看此时这个lazyInst属性是什么,
可以看到就是我们最终返回的VelocityResponseWriter对象。
那么问题就来了,我们这执行过程中并没有看到lazyInst对象被赋值,那么lazyInst属性指向的VelocityResponseWriter对象是哪来的呢?
我们会退一步,观察这行代码
PluginHolder
registry是一个hashmap类型,有final标识符
观察此时registry里面的内容
又因为registry.get(name)传入的name参数的值为velocity
我们打开这里的velocity
赫然看到那个lazyInst就在里面,我们知道标示的final的属性就是常量了,在对像生成被赋值了一次以后就不会再更改了。我通过多次发送poc请求测试发现每次到这个断点时当前的对象ID都是相同的,所以每次执行调用的都是同一个对像。
我们重新发送poc的第一部分。Poc第一部分请求完成后再在此处下断点
此时lazyInst属性就为空了
我们继续执行
此时由于lazyInst为空了,所以不会直接返回,我们跟进createInst方法,
看到在createInst方法的最后lazyInst属性被赋值,我们向上寻找这个localInst变量
在下面这行代码中localInst第一次被赋值
此时localInst中的内容为
也就是说此时程序只是从solrconfig.xml中读取了默认的配置,还并没有读取
configoverlay.json中我们更新的配置。
所以这行就不跟进了。
我们清楚的看到参数被更新了,那我们就跟入这行代码
跟入((NamedListInitializedPlugin) inst).init(info.initArgs)
然后就又看到了我们执行poc第一部分时所碰到的代码了,至此获取
configoverlay.json中我们更新的配置信息的执行逻辑我们已经分析完毕
接下来继续原路返回到我们调用HttpSolrCall.getResponseWriter的位置
继续跟进writeResponse(solrRsp, responseWriter, reqMethod);
此时solrRsp中存放的是我们Get传入的poc,responseWriter中存放的是我们configoverlay.json文件中存放的更新配置。
跟入QueryResponseWriterUtil.writeQueryResponse方法
跟入responseWriter.write方法
我们执行createEngine()方法时生成了一个VelocityEngine对象
我们进入createEngine()方法后可以看到方法内的第一行代码就是new一个VelocityEngine对象
关键点在以下这几行代码,这里对
paramsResourceLoaderEnabled
solrResourceLoaderEnabled两个参数进行了判断,当
paramsResourceLoaderEnabled参数为true时执行
loaders.add("params");
engine.setProperty("params.resource.loader.instance", new SolrParamResourceLoader(request));
根据网上查到的资料我们可以看到params.resource.loader.instance这个属性的含义
也就是说当开启这个属性的时候,我们就可以通过Solr来上传我们自定义的模板了。
最后返回VelocityEngine对象
返回到responseWriter.write方法,继续执行到
Template template = getTemplate(engine, request);
这里我们生成了一个template
跟进去后我们看到
从我们Get传入的参数中获取V.template作为模板的名字
同时将我们传入的Poc也就时Velocity模板语句解析成AST抽象语法树
这里就要对velocity的AST抽象语法树做一下简单的介绍了
在计算机科学中,抽象语法树(abstract syntax tree 或者缩写为 AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。树上的每个节点都表示源代码中的一种结构。
之所以说语法是「抽象」的,是因为这里的语法并不会表示出真实语法中出现的每个细节。
Velocity是通过JavaCC和JJTree生成抽象语法树的,
javaCC 是一个能生成语法和词法分析器的生成程序。语法和词法分析器是字符串处理软件的重要组件, javacc 是类似lex/yacc的parser生成器,可以把一段文本转换为抽象语法树(AST)。
JJTree是javaCC的预处理器,用于在JavaCC生成的源代码中的各个地方插入表示语义动作的分析树
用网上的一张图来介绍一下AST的一些节点
Velocity的语法相对简单,所以它的语法节点并不是很多,总共有50几个,它们可以划分为如下几种类型。
块节点类型:主要用来表示一个代码块,它们本身并不表示某个具体的语法节点,也不会有什么渲染规则。这种类型的节点主要由ASTReference、ASTBlock和ASTExpression等组成。
扩展节点类型:这些节点可以被扩展,可以自己去实现,如我们上面提到的#foreach,它就是一个扩展类型的ASTDirective节点,我们同样可以自己再扩展一个ASTDirective类型的节点。
中间节点类型:位于树的中间,它的下面有子节点,它的渲染依赖于子节点才能完成,如ASTIfStatement和ASTSetDirective等。
叶子节点:它位于树的叶子上,没有子节点,这种类型的节点要么直接输出值,要么写到writer中,如ASTText和ASTTrue等。
我们再来看一下poc中的Velocity语句,和children中的节点信息
#set($x='')
#set(
x.class.forName('java.lang.Runtime'))
#set(
rt.getRuntime().exec('open /Applications/Calculator.app/'))
#set最终被解析为Velocity AST语法树中的ASTSetDirective类,根据上面的Velocity AST语法树的图我们看到ASTSetDirective节点有两个字节点
分别是ASTReference,和ASTExpression,
我们看到下标为0的ASTSetDirective类中有两个属性。right和left
分别代表了$x=''中“=”号的两边,左边的ASTReference有两种可能,
一就是用来进行赋值操作的变量名
例:#set( $iAmVariable = 'good!')将字面量“good”赋值给名字为iAmVariable的变量
第二种也是赋值操作,但是赋值操作的目标是一个对象的某个属性
例:#set($Persion.name = 'kkk')
这种赋值方式的本质其实是调用Persion的setName方法。
区分这两种赋值方式我们可以动过观察此时的ASTReference这个节点是否有子节点来判断
譬如第一种#set( $iAmVariable = 'good!') 我们观察一下
可以看到最后的children属性为空
再观察第二种#set($Persion.name = 'kkk')
可以看到children属性中,是有子节点的。
Velocity通过ASTReference类来表示一个变量和变量的方法调用,ASTReference类如果有子节点,就表示这个变量有方法调用,方法调用同样是通过“.”来区分的,每一个点后面会对应一个方法调用。ASTReference有两种类型的子节点,分别是ASTIdentifier和ASTMethod。它们分别代表两种类型的方法调用,其中ASTIdentifier主要表示隐式的“get”和“set”类型的方法调用。而ASTMethod表示所有其他类型的方法调用,如所有带括号的方法调用都会被解析成ASTMethod类型的节点。
所谓隐式方法调用在Velocity中通常有如下几种。
1.Set类型,如#set($person.name=”junshan”),如下: - person.setName(“junshan”)
person.setname(“junshan”)
person.put(“name”,”junshan”)
2.Get类型,如#set(
person.name)中的$person.name,如下:
person.getName()
person.getname()
person.get(“name”)
person.isname()
person.isName()
接下来我们来看ASTText节点,我们从节点图中看到ASTText没有任何子节点了,它是一个叶子结点,所以这种类型的节点要么直接输出值,要么写到writer中。
到这里我们简单介绍了下Velocity AST语法树的一些基础知识。接下来我们回归我们程序的执行逻辑。
接下来的velocity模板引擎的执行逻辑现在这里简单说明一下,其实也很简单,其实就是会不停的遍历和执行各个子节点中的render方法
首先根据Velocity AST语法树的那张图,我们看到总的根节点是ASTprocess
所以会首先调用ASTprocess的render方法,具体在哪里调用呢,我们来看代码
继续跟入
当执行到((SimpleNode)data).render(ica,writer);
这行代码是,我们可以看到此时的data就是ASTprocess节点,所以Template.merge方法中调用了AST的根节点(ASTprocess)的render方法((SimpleNode)data).render(ica,writer);。此调用将迭代处理各个子节点render方法。如果是ASTReference类型的节点则在render方法中会调用execute方法执行反射替换相关处理。
当进入到ASTprocess节点的render方法后会根据深度优先遍历算法开始遍历整棵树,遍历算法如下
即依次执行当前节点中的所有子节点的render方法,而每个节点的具体渲染规则都在其对应节点的render方法中实现。
这里我们可以打印一下我们poc所生成的语法树的详细结构
有了这个语法树结构后,程序的执行顺序就相当清晰了。
我们首先调用了ASTSetDirective类的render方法,看到该方法中首先调用了ASTExpression类value方法。
而ASTExpression类value方法中又调用了它的子节点ASTStringLiteral
节点的value方法
最后ASTStringLiteral类的value方法返回一个字面量
接着返回到ASTSetDirective类执行它的第二个子节点也就是等号左边的$x
这里对应的是ASTReference类,这里是调用了ASTReference类的setValue方法
跟入方法后可以看到,由于该ASTReference节点没有子节点了,所以
直接执行
context.put(rootString, value);这里的value就是我们刚刚获得的“=”号右边的字面量!
我们跟进去看一眼,能看得出后续就是赋值操作了,就不继续深入了
Poc第一行#set($x='')执行完毕
然后开始遍历第二个节点
第二个节点是ASTText节点,这个没什么好说的,就只是直接输出或着写到write中
然后开始遍历第三个节点
第三个节点仍然是ASTSetDirective类,它的render方法中仍然是先执行“=”号右边的子节点ASTExpression类的value方法
当执行到该方法时我们可以看到,此时的ASTExpression节点还有一个子节点,但是不是ASTStringLiteral节点了,而是ASTReference节点
所以此次执行的将会是ASTReference类的value方法
执行execute方法
我们重点看execute中的这行代码
Object result = getVariableValue(context, rootString);
这里返回的是我们给$x所赋的值“”然后程序会判断该值是否为空
如果一开始我们没有执行#set(
x赋一个值的话,此时会执行下面的
EventHandlerUtil.invalidGetMethod()方法,该方法会因为$x的值为空而不会向下继续执行。
所以我们poc的第一步就需要先为一个变量赋值,赋任何值都可以。
接下来执行到下面这些代码时,就开始遍历当前ASTReference的两个子节点
执行完ASTIdentifier类的execute返回一个Class对象
接下来就是遍历第二个节点也就是ASTMethod节点,
执行ASTMethod节点的execute方法。
Execute方法中执行了method的invoke方法跟入
最调用doInvoke方法
我们看一下doInvoke方法的内容
这一路下来的反射调用到最终获取Runtime类的class对象我用更直观的方式重写了一下方便理解
这一系列的操作等同于Class.forName("java.lang.Runtime")
后面的poc的第三行
#set(
rt.getRuntime().exec('open /Applications/Calculator.app/'))
执行逻辑和上面的如出一辙,就不再深入分析了,感兴趣的朋友可以自己跟踪代码分析一下。
最后放一下最终的一个调用链