内容纲要

前言

今年分析的第一个漏洞 , 记录在这里. 分析该漏洞的原因主要有两个 :

  1. 自己对 Tomcat 整体架构的流程不熟悉 , 年后工作中需要制作很多靶场环境, 其中就包含了不少Tomcat漏洞环境 . 由于没有怎么看过 Tomcat , 因此在环境搭建时出了很多问题 . 所以这里想要系统的学习下 Tomcat 组件 .

  2. 前段时间各种忙着忙那, 很久没有专心去学习一个知识了, 借着这个机会, 让自己浮躁的心沉稳下来 .

综上所述, 本文将会从 Tomcat 架构起步 , 从源码上分析 CVE-2020-1938 这个漏洞 .

在本文中参考了大量前辈们文章, 感谢各位前辈的付出 .


CVE-2020-1938 Tomcat-Ajp 协议 任意文件读取/JSP文件包含漏洞分析

漏洞概述

NVD 上详细的说明了漏洞出现的场景与危害, 总结下来有如下几点 :

  1. Apache Tomcat 9.00M1 - Apache Tomcat 9.0.0.30 , Apache Tomcat 8.5.0 - Apache Tomcat 8.5.50 , Apache Tomcat 7.0.0 - Apache Tomcat 7.0.99 中 , 默认启用了 AJP 连接器, 并且该连接器侦听所有已配置的IP地址 .

    Apache Tomcat 默认将 AJP 服务开启并绑定到IP地址 0.0.0.0/0 , 也就是监听任何人发送的 AJP 请求

  2. 攻击者可以利用该协议从Web应用程序中的任何地方返回任意文件( Web根目录下的任意文件读取漏洞 )

  3. 攻击者可以利用该协议将Web应用程序中的任何文件作为 JSP 文件来处理( JSP文件包含漏洞 )

  4. 如果Web应用程序允许攻击者上传文件, 且上传后的文件路径已知并可执行( 文件上传漏洞 ) , 那么攻击者就可以利用上传的文件和AJP协议实现一个 WebShell , 达到任意代码执行的目的 .

这么看来, 无论是哪种利用方式 , 该漏洞的危害性都是非常高的 . 后文也将围绕 Web根目录下的任意文件读取JSP文件包含实现WebShell 两个点来进行分析 .


漏洞复现

在分析之前,我们先进行一下漏洞复现, 确定漏洞存在, 然后再来调试源码 .


环境搭建

漏洞环境搭建的过程可以参考 水泡泡(kking) Tomcat AJP 任意文件读取和包含漏洞分析记录包华杰 IDEA导入Tomcat源码 两位前辈的文章, 写的非常详细 .

这里我们选择使用 Apache Tomcat 7.0.99 来进行分析 .

在将 Apache Tomcat 7.0.99 导入到 IDEA 后, IDEA 会自动安装依赖 , 这个时间比较长 , 等待所有依赖安装完毕后 , 配置 Tomcat 即可开启服务 .

若出现上图的输出信息 , 则说明环境已经搭建成功了 , 下面我们仅需要与 AJP 协议交互就可以了 . 目前比较流行的工具是 AJPy , 并且自带了 CVE-2020-1938 的 POC .


Web根目录下任意文件读取

这里我们直接使用 AJPy 给出的 POC 就可以了 , 启动 Tomcat , 然后在终端运行如下命令 :

python3 tomcat.py read_file --webapp=manager /WEB-INF/web.xml 127.0.0.1

即可成功读取 web.xml 文件


通过 JSP 文件包含实现任意代码执行

在 Web 目录下创建一个文件, 里面包含一个 JSP 执行命令的 Payload , 这个网上到处都是 . 这里以 demo1.html 为例 .

注 : JSP文件包含时 , 不考虑被包含文件的后缀名 . 若被包含文件中有 JSP 代码, 则解析代码并执行 , 若不包含 JSP 代码, 则将文件内容作为纯文本输出 .

 注意这里的 Web 根目录是在 home 目录下的 manager 目录 , 而不是 apache-tomcat-7.0.99-src 目录下的 manager 目录 , 不要弄混 .

 如上图所示 , 这里控制 Apache Tomcat 执行 " id " 系统命令 , 并将执行结果发送到本地 2333 端口

修改 tomcat.py 脚本 , 将要访问的文件改为任意一个 JSP 后缀的文件( 文件可以不存在 ) . 如下图所示 :

这里我们依旧可以通过 AJPy 来触发漏洞 , 在终端执行如下命令

python3 tomcat.py read_file --webapp=manager /demo1.html 127.0.0.1

同时在本地监听 2333 端口 .

nc -lvvvp 2333

发送POC , 即可收到 " id " 系统命令的执行结果 .

至此复现工作结束 , 我们成功通过 任意文件读取漏洞读取到了 web.xml 文件 , 也通过 JSP文件包含漏洞执行了系统命令并返回 .


Tomcat 架构分析

这部分内容我是参考徐刘根前辈 四张图带你了解Tomcat系统架构--让面试官颤抖的Tomcat回答系列! 来写的 , 这篇文章写的非常棒 , 思路很清晰 . 非常适合初学者 .

先放一张图 .

通过上图能比较清楚的看到各个部分之间的关联了 . 下面简单说明下 .


Server

  1. Server 代表整个容器 , 是 Tomcat 实例的顶层元素 , 由 org.apache.catalina.Server 接口定义 .

  2. Server 控制着整个 Tomcat 的生命周期 .

  3. 一个 Server 可以包含多个 Service .

  4. Server 标签绑定的端口号为 8005 , 存在属性 shutdown , 若 Tomcat 在 8005 端口监听到 SHUTDOWN 请求 , 那么就会直接关闭整个 Tomcat .

    server.xml 文件中可以看到 Server 标签的定义 , 如下所示

    我们可以通过请求 8005 端口来关闭 Tomcat .


Service

  1. Service 用于对外提供服务连接.

  2. Service 可以同时提供不同协议的连接 , 例如 HTTP , HTTPS , AJP , 这取决于 Connector 的配置

  3. Service 可以同时提供同一协议不同端口的连接 , 这取决于 Engine 的配置

  4. Service 由 一个 Container 和多个 Connector 组成 , 这两个组件是 Tomcat 的核心 , 也是我们需要重点分析的 .


Connector

  1. Connector 用于处理连接相关的事情

  2. Connector 使用底层 Socket 进行连接交互 , 并将收到的 Socket 请求或响应封装成 HTTP 或其他协议的响应与请求 . 然后将其提交给 Container 组件.

  3. 从 Connector 接收 Socket 请求到将封装后的请求( 比如 HTTP Request 请求 )发送给 Container 的过程如下 :

    Connector 使用 ProtocolHandler 来处理请求 , 不同的 ProtocolHandler 代表不同的连接类型( Socket / NioSocket ... ) , ProtocolHandler 包含三个组件 , 分别为 : Endpoint , Processor , Adapter .

    1. Endpoint : 用来处理底层的 Socket 网络连接 .

    2. Processor : 用来将处理后的 Socket 请求转换成 Request 请求 .

    3. Adapter : 用来将转后后的 Request 请求提交给 Container 进行具体的解析 .

    至此 , 外部来的 Socket 请求就被封装成 Request 请求 , 并被提交给 Container 进行具体分析 .


Container

  1. Container 用于封装和管理 Servlet , 并且具体处理从 Connector.Adapter 部件传来的 Request 请求 .

  2. Container 在处理完毕请求后 , 生成 Response 响应 , 返回给 Connector 组件

  3. Container 中包含四个子容器 , 分别为 : Engine , Host , Context , Wrapper , 它们之间是包含关系 , 其对应关系如下所示 :

    1. Engine : 一个 Service 只能有一个 Engine , 该部件用来处理所有来自 Connector 的请求 , 并将各个请求匹配到对应的虚拟主机来处理 .

    2. Host : 一个 Host 代表一个虚拟主机 , 一个虚拟主机包含了一个或多个 Web应用( Web程序 ) , 它负责安装,展开和运行这些Web应用 .

    3. Context : 一个 Context 代表一个 Web应用程序 , 这是平时开发中最常用的元素 . 它具备了 Servlet 运行的基本环境 . 用来管理其子容器 Wrapper .

    4. Wrapper : 一个 Wrapper 封装一个 Servlet , 它负责管理一个 Servlet , 包括的 Servlet 的装载 , 初始化 , 执行以及资源回收 . Wrapper 是 Tomcat 最底层的容器 , 它没有子容器了 .

      可以这么认为 : 当我们访问 http://abc.com 时 , 服务器端就会访问 webapps 目录 , 如果我们访问的是 http://abc.com/docs , 那么服务器端就会访问 webapps/docs 目录 .

      webapps下的每一个目录( ROOT , manager , docs )都是一个 Context , 区别在于可能 ROOT 目录下存放主应用 , 其他目录下存放子应用 . 当然这些映射关系都是可以手动修改的 .

  4. Container 中使用 Pipeline-valve 管道来处理请求 . Pipeline-value 管道是一种特殊的链式的流程顺序( 也叫特殊的责任链模式 )

    这里需要说明一下 普通的责任链模式 和这里 特殊的责任链模式 的区别

    普通的责任链模式 : 当接收到一个工作时 , 会有很多个处理者来进行处理 , 其中每个处理者会按照顺序完成属于自己的工作 . 完成后将处理结果返回 , 并将结果和剩余的任务传递给下一个处理者 .

    但是传统的责任链有个很明显的问题 , 就是 : " 某个处理者在处理工作时 , 外界不能对其进行干预 , 执行其他的操作 " . 因此 , Tomcat 在普通的责任链上进行了扩展 , 实现了一条特殊的责任链 . 如下图所示

    可能要完成的任务在处理过程中受到了外界干预 , 但是无论外界如何干预 , 在每部分任务的最后 , 都有一个处理者来做收尾工作 , 将这部分工作完成 , 然后提交给下一个处理者 . 而 Tomcat 在处理请求时 , 正是使用这一模式 . 而上图中这些 " 保底 " 的处理者 , 也被称为 BaseValve , 形如 StandardxxxxValve .

    Tomcat Container 四大容器的处理顺序如下所示 :

    需要注意这两个点 :

    1. 每个 Pipeline 都有特定的 Valve ,而且固定在管道的最后一个执行 , 进行收尾工作 . 这个 BaseValve 是唯一且不可被删除的 .

    2. 只有上层容器的管道的 BaseValve 才能调用子容器的管道 .

      Tomcat中的设计模式--责任链模式 一文中有个比喻说的非常棒 :

      实际上 Pipeline-Valve 模式是扩展了责任链的功能 , 使得在链往下传递过程中 , 能够接受外界的干预 . Pipeline 就是连接每个子容器的管子 , 里面传递的 Request 和 Response 对象好比管子里流的水 , 而 Valve 就是这个管子上开的一个个小口子 , 让你有机会能够接触到里面的水 , 做一些额外的事情 .

      为了防止水被引出来而不能流到下一个容器中 , 每个容器都有一个 BaseValue( StandardXXXValve ) . 确保最后总有一个节点保证它一定能流到下一个子容器

至此 , Tomcat 已经将外界请求提交给了 Java Servlet 来处理 . 整个流程分析到这里也就基本结束了 . 如果您还想更深入的了解 Tomcat 结构 , 可以参考 IBM Developer Tomcat 系统架构与设计模式 一文 .


源码分析

题外话

我是按照 水泡泡(kking) 前辈的文章进行复现的 , 他提供了一个复现时的思路 : 即查看 Github 上对应项目的 Commits . 这个点我觉得非常好 . 因此我也在 Github 上找到了对应的 Commits .

这里很大程度上对应了开头漏洞概述中的内容 , 因此漏洞产生的原因很可能就在这里 . 通过 Wireshark 抓取复现时 tomcat.py 发送的 AJP13 协议数据包 , 如下所示 :

从协议数据包来看 , AJP 协议与 HTTP 协议在结构上非常类似 . 甚至此处报文中的 Version 字段直接被指定为 HTTP/1.1 , Method 被指定为 GET 方法 , 因此后面分析时可以按照分析 HTTP 数据包的方式来分析 AJP 协议数据包 .

并且我们还能看出 , 在请求数据包最后指定了三个特殊的字段 , 而我们要读取的文件 : /WEB-INF/web.xml 就在其中 .


切入点 : prepareRequest() 函数

上文提到 , Connector 的 Endpoint 部件会接收外界传来的 Socket 请求 , 并将他们转换成对应的协议( 比如 HTTP 协议 ) , 然后提交给 Processor 部件 . 而这里我们使用了 AJP 连接器来访问 Tomcat . 因此我们的关注点放在了 AjpProcessor 文件中 .

AjpProcssor 文件中 , 以 prepareRequest() 函数作为起点 , 开始处理收到的 AJP 协议数据包 .

并且从注释中我们可以得知 , 该函数的作用是 : 启动了过滤器 , 并且解析了部分请求头 . 跟进该函数 , 发现该函数一共做了三件事 .

  1. 解析 HTTP Method

    虽然这里是在解析 HTTP Method , 但是之前抓包时发现 , AJP13 协议同样使用 HTTP/1.1 来传输数据包 , 因此这里会对 AJP13 协议报头进行初步的解析 , 获取一些基本的信息 .

  2. 解析 Headers

    这里执行的结果是创建了一个 headers 对象 , 并依次为该对象添加了9 条属性 , 具体信息如上图所示 .

  3. 解析额外的属性

    这部分代码存在一个 Switch 分支结构 , 判定条件是 attribuecode 变量的值 : 10 .

    然后顺序执行代码 , 进入到 Constants.SC_A_REQ_ATTRIBUTE 分支 , 该分支的值恰好为 10 , 代表含义是 : AJP 连接器中没有预定义的属性 .

    注意到之前在看 Github Commits 时 , 作者将 AJP 连接器无法识别的属性都锁定为 403 , 因此这里很可能是漏洞产生的地点 .

    这里定义了 n , v 两个变量 , 而这两个变量恰好是我们在攻击数据包中指定的 .

    然后依次判断变量 n 的值是否是 AJP 的私有请求属性 , 若不是则将 m->n 作为一个键值对并设置成属性 .

    很显然 n 的值是我们指定的 , 而非 AJP 预定义的 , 因此最终会封装成一个 HashMap , 内容是我们指定的三个属性 .

    最后跳出了 Switch 分支结构 , 依次判定是否设置过 Secret 密钥请求的 URL 是否以 http:// 开头 .

    若发现存在 Secret 密钥 , 则会直接返回 HTTP 403 错误 . 因此有的漏洞预警中也把配置 AJP Connector 的 Secret 密钥作为一种防御方法 .

    而我们访问的是 /manager/test1 这个不存在的文件 , 显然不是以 http:// 开头 , 因此后面的 if 条件判断语句都将跳过 .

    至此 , Processor 的工作已经全部完成 , 按照之前提到的 Tomcat 工作流程 , Processor 会将解析后的 Request 请求提交给 Adapter , 由该部件来完成后续的解析工作 .

    一句话来概括整个 Processor 部件的作用 :

    将从 endpoint 部件传来的 Request 解析 , 生成 Request 对象和 Response 对象 , 并将两个对象传递个 Adapter 部件做进一步处理 .


切入点 : adapter.service(request, response) 函数

Adapter 部件在 Tomcat 整个流程中至关重要 , 它用于连接 Connector 和 Container 两个 Tomcat 最核心的组件 , 起到承上启下的作用 .

Adapter 部件中最核心的函数就是 service() 函数 .

这段代码的开头先是创建了两个新对象 , 开始我也不明白具体原因 . 不过在 绝情谷前辈 Tomcat学习之Request/Response封装 一文中讲解的非常清楚 , 我这里也简单总结下 .

  1. 无论 Tomcat 收到的请求是怎样的 , 最后都将提交给 Servlet.service() 函数来处理 . 而此处 org.apache.coyote.Request 是没有实现 javax.servlet.http.HttpServletRequest 接口的 , 所以需要进行转换后才能被 Servlet 调用 .

  2. 为什么没有实现这个接口呢 ?

    绝情谷前辈提到 : 在 org.apache.coyote.Request 里面封闭了很多底层处理方法 , 这些方法不应当暴露给 web 开发人员使用 .

    因此 tomcat 就在 org.apache.coyote.RequestServletRequest 中间设置了一个间隔 .

    这个间隔就是 tomcat 容器独有的请求响应接口 : org.apache.catalina.connector.Requestorg.apache.catalina.connector.Response

  3. 因此 , 这一步的目的主要是为了创建 connector.Requestconnector.Response 两个对象 .

然后会给 Response 对象添加一个 X-Powered-By 属性 , 添加后就会调用 PostParseRequest() 正式解析请求 . 为之后调用 Container 组件做准备 .

PostParseRequest() 函数做的事很多 , 包括解析代理服务器 , 解析 URL 后的具体路径 , 处理请求映射 , 查找是否存在Session , 处理可能存在的重定向等问题 . 举个例子 , 在处理请求映射时 , Tomcat 就会确定之前提到的 Pipeline-value 责任链 .

这里有我们熟悉的 部件( Context , Wrapper ) 以及 BaseValue( StandardEngine , StandardHost , StandardContext , StandardWrapper )

在完成上述这些准备工作后 , Adapter 部件就会把解析工作交给 Container , 让 Container 去调用对应的 Servlet 来处理 .


切入点 : connector.getService().getContainer().getPipeline().getFirst().invoke(request , response) 函数

对于每一个请求 , Connector 都会调用其关联的 Container 的 invoke() 函数 , 因此这里就正式进入之前提到的特殊的 Pipeline-Value 责任链了 . 跟进该函数 , 发现进入了 StandardEngineValue.java 文件 .

在该 invoke 函数中 , 主要做了一件事 , 就是选择之后要调用的 Host , 然后调用它 . 注释已经写的非常清楚了

在该函数最后 , 访问了该 Pipeline 的第一个 Valueinvoke() 函数 . 待处理完毕后 , 再调用 Next() 函数访问下一个 Valueinvoke() 函数 .

处理流程最后会走到 BaseValue . 比如这里会执行到 StandardHostValue.java 文件 在 BaseValueInvoke() 函数中 , 会确定使用哪个子容器 , 然后去调用它 .

 对应于 Host 容器 , 其子容器就是 Context , 所以这里会选择将要使用的 Context , 然后去调用它 .

之后的流程与上文类似 , 去访问该 Pipeline 的第一个 Valueinvoke() 函数 .

然后在 invoke() 函数中调用 Next() 函数 . 访问下一个 Valueinvoke() 函数 .

最后请求流入到 StandardContextValue.javainvoke() 函数中 , 并且确定要使用的 Wrapper , 而一个 Wrapper 对应一个 Servlet , 因此这里其实就是确定要使用的 Servlet .

注意看下面 variables 选项卡中 , 选择了默认的 Servlet 来解析请求 . 在该函数的末尾就会去调用对应的 Wrapperinvoke() 函数 . 这个过程也是和上文完全一样的 .

WrapperContainer 中最小的容器 , 它没有子容器了 . 因此到这里 , 整个 PipeLine-Value 责任链就走完了 . 下面的事就该交给 Servlet 了 .


切入点 : StandardWrapperValue 类的 Invoke() 函数

为什么单独把这个 invoke() 函数拿出来说呢? 因为在该函数中 , 将会调用 Servlet 来解析请求 . 也就是说解析过程要结束了 .

最开始一些初始化和验证过程就不详细看了 , 直接定位到关键的地方 .

调用 Wrapper.allocate() 方法来分配一个Servlet实例 , 注意下面 variables 选项卡中 servlet 变量已经被初始化 .

这里创建了过滤器链 , 用于拦截请求 .

调用过滤器链的 doFilter() 函数 , 在该函数中会进行一些基本的基本操作 , 然后会调用 internalDoFilter() 函数 , 在函数的末尾会调用 Servlet.service() 函数 .

service() 函数中会先对 HTTP Method 进行判断 , 调用对应的方法 . 虽然我们使用的是 AJP13 协议 , 但是之前抓包时显示是 GET 方法 , 因此这里也会调用 doGet() 方法 .

然后就是解析数据包的过程 , 一路 F7 跟进 , 经过 serverResource() 函数 , 到达 getRelativePath() 函数 . 这是本文中最关键的关键点 , 也是漏洞真正出现的地方 !


关键点 : getRelativePath() 函数

再来看一遍我们复现时使用的攻击数据包

我们一共发送了三个属性 , 分别为 request_uri , path_info , servlet_path . 而在 getRelativePath() 函数中 , 会作出如下判断 .

request_uri 属性存在 , 则将 path_info , servlet_path 拼接后再返回 , 而这个返回的路径 ,恰好是我们读取文件的路径 .

然后会调用 lookupCache() 函数来判断该路径对应的文件是否在缓存中

跟进该函数 , 通过调用 lookupfind() 函数来查找文件是否在缓存中 , 而第一次读取时文件时 , 缓存中肯定不存在对应内容 . 因此创建了一个 file() 类来读取文件 .

函数调用栈如下所示

然后通过将 basename 两个参数拼接后读取该路径对应的文件内容 , 而这个路径恰好是我们要读取的文件( web.xml )的绝对路径 !

然后是对目标路径的文件进行判断 , 比如文件是否存在 , 文件可不可读之类的 , 反正是一些基本判断 .

并且还通过调用 normalize() 函数来限制跨目录读取 . 因此我们最终也只能读取 Web 根目录下的文件 .

后面的内容就没啥好说的了 , 调用 cacheLoad() 函数将此次请求响应内容存入缓存 , 并将读取到的文件内容构造成 Response 数据包 , 返回给客户端 .

至此 , Web根目录下任意文件读取漏洞就分析完毕了 , 我们成功通过构造携带恶意属性的 AJP13 数据包 , 实现了任意文件读取漏洞


JSP 文件包含漏洞 .

JSP 文件包含的流程和上文任意文件读取漏洞的流程几乎是类似的 , 只不过调用了不同的 Servlet ( JspServlet ) . 同样是创建了 File 对象读取文件内容 . 在读取文件时进行解析 , 将其中的代码当作 JSP 代码来执行 , 从而实现一个 文件上传 + 文件包含 的 WebShell .

因此这里就不多说了 , 仅截取一些流程 . 如果您看过上文内容 , 应该清楚这些截图的含义 .

  1. 选择 Servlet

  2. 判断 JSP 文件

  3. 调用 JSP Servlet

  4. 拼接后的绝对路径

    函数调用栈也几乎是相同的

    就说这么多了 , JspServlet 的解析过程也是比较有意思的 , 如果您有兴趣 , 可以学习一下 .

整个漏洞的分析过程就到这结束了 .


总结

这应该是我写过的最长的一篇分析报告了 , 全篇 20000 多字 , 修改了40几个版本 . 花了一周以上的下班空闲时间 . 终于把基本的流程搞明白了 .

一开始本想简单的复现一下 , 结果越钻研 , 越能感觉到源码的魅力 . 从最初的啥都看不懂 , 到能逐步理解 Tomcat 各个模块的功能 , 再到将各个模块拼接到一起 . 我能感觉到自己提升了很多 .

在学习过程中 , 翻阅了各种文档资料 , 由于数量众多 , 这里就不一一列举了 , 感谢各位前辈的文章与知识!

如果您觉得我上面的理解有误 , 欢迎评论 , 如果我这篇文章能帮助到您 , 那我会非常开心 !

最后修改日期:2020年6月24日

作者

留言

李狗蛋 

棒棒

撰写回覆或留言

发布留言必须填写的电子邮件地址不会公开。